diff --git a/packtools/sps/validation/related_articles.py b/packtools/sps/validation/related_articles.py index d5131bf3c..8f1629aee 100644 --- a/packtools/sps/validation/related_articles.py +++ b/packtools/sps/validation/related_articles.py @@ -1,3 +1,16 @@ +""" +Validation for related-article elements according to SPS 1.10 specification. + +Implements validations for related article elements to ensure: +- Mandatory attributes are present (@related-article-type, @id, @ext-link-type, @xlink:href) +- Attribute values are in allowed lists +- Preference for DOI over URI is followed +- Attribute order follows specification + +Reference: https://docs.google.com/document/d/1GTv4Inc2LS_AXY-ToHT3HmO66UT0VAHWJNOIqzBNSgA/edit?tab=t.0#heading=h.relatedarticle +""" +import gettext + from packtools.sps.models.related_articles import FulltextRelatedArticles from packtools.sps.validation.utils import ( build_response, @@ -5,6 +18,32 @@ validate_doi_format, ) +_ = gettext.gettext + +ALLOWED_RELATED_ARTICLE_TYPES = [ + "corrected-article", + "correction-forward", + "retracted-article", + "retraction-forward", + "partial-retraction", + "addended-article", + "addendum", + "expression-of-concern", + "object-of-concern", + "commentary-article", + "commentary", + "reply", + "letter", + "reviewed-article", + "reviewer-report", + "preprint", + "peer-reviewed-material", +] + +ALLOWED_EXT_LINK_TYPES = ["doi", "uri"] + +URI_ALLOWED_RELATED_ARTICLE_TYPES = ["reviewer-report", "preprint"] + class XMLRelatedArticlesValidation: def __init__(self, xmltree, params=None): @@ -25,27 +64,14 @@ def __init__(self, related_article, params=None): related_article : dict Dictionary with related article data including parent_article_type params : dict, optional - Dictionary with validation parameters: - { - 'correspondence_list': [...], - 'ext_link_types': ['doi', 'uri'], - 'requires_related_article': ['correction', 'retraction', ...], - 'requirement_error_level': 'ERROR', - 'type_error_level': 'ERROR', - 'ext_link_type_error_level': 'ERROR', - 'uri_error_level': 'ERROR', - 'uri_format_error_level': 'ERROR', - 'doi_error_level': 'ERROR', - 'doi_format_error_level': 'ERROR', - 'id_error_level': 'ERROR' - } + Dictionary with validation parameters """ self.related_article = related_article self.original_article_type = related_article["original_article_type"] self.related_article_type = related_article.get("related-article-type") self.params = params or {} - self.valid_ext_link_types = self.params.get("ext_link_types", ["doi", "uri"]) + self.valid_ext_link_types = self.params.get("ext_link_types", ALLOWED_EXT_LINK_TYPES) _related = self.params.get("article-types-and-related-article-types", {}).get( self.original_article_type, {} ) @@ -73,6 +99,264 @@ def _get_error_level(self, validation_type): error_level_key = f"{validation_type}_error_level" return self.params.get(error_level_key, "CRITICAL") + def validate_related_article_type_presence(self): + """ + Validate presence of @related-article-type attribute (CRITICAL). + + SPS Rule: @related-article-type is mandatory in all elements. + + Returns + ------- + dict or None + Validation result if attribute is missing or empty, None if valid + """ + error_level = self._get_error_level("related_article_type_presence") + obtained = self.related_article.get("related-article-type") + is_valid = obtained is not None and obtained.strip() != "" + + advice_text = _( + 'Add @related-article-type attribute to .' + ' Valid values: {allowed_values}' + ) + advice_params = { + "allowed_values": ", ".join(ALLOWED_RELATED_ARTICLE_TYPES), + } + + if not is_valid: + return build_response( + title="Related article type presence", + parent=self.related_article, + item="related-article", + sub_item="@related-article-type", + validation_type="exist", + is_valid=is_valid, + expected="@related-article-type attribute present", + obtained=obtained, + advice=advice_text.format(**advice_params), + data=self.related_article, + error_level=error_level, + advice_text=advice_text, + advice_params=advice_params, + ) + + def validate_ext_link_type_presence(self): + """ + Validate presence of @ext-link-type attribute (CRITICAL). + + SPS Rule: @ext-link-type is mandatory in all elements. + + Returns + ------- + dict or None + Validation result if attribute is missing or empty, None if valid + """ + error_level = self._get_error_level("ext_link_type_presence") + obtained = self.related_article.get("ext-link-type") + is_valid = obtained is not None and obtained.strip() != "" + + advice_text = _( + 'Add @ext-link-type attribute to .' + ' Valid values: {allowed_values}' + ) + advice_params = { + "allowed_values": ", ".join(ALLOWED_EXT_LINK_TYPES), + } + + if not is_valid: + return build_response( + title="Related article ext-link-type presence", + parent=self.related_article, + item="related-article", + sub_item="@ext-link-type", + validation_type="exist", + is_valid=is_valid, + expected="@ext-link-type attribute present", + obtained=obtained, + advice=advice_text.format(**advice_params), + data=self.related_article, + error_level=error_level, + advice_text=advice_text, + advice_params=advice_params, + ) + + def validate_related_article_type_value(self): + """ + Validate @related-article-type value is in allowed list (ERROR). + + SPS Rule: @related-article-type must be one of the allowed values. + Comparison is case-sensitive. + + Returns + ------- + dict or None + Validation result if value is invalid, None if valid or attribute missing + """ + obtained = self.related_article.get("related-article-type") + if not obtained or not obtained.strip(): + return None + + error_level = self._get_error_level("related_article_type_value") + is_valid = obtained in ALLOWED_RELATED_ARTICLE_TYPES + + advice_text = _( + 'Value "{obtained}" is not allowed for @related-article-type.' + ' Valid values: {allowed_values}' + ) + advice_params = { + "obtained": obtained, + "allowed_values": ", ".join(ALLOWED_RELATED_ARTICLE_TYPES), + } + + if not is_valid: + return build_response( + title="Related article type value", + parent=self.related_article, + item="related-article", + sub_item="@related-article-type", + validation_type="value in list", + is_valid=is_valid, + expected=ALLOWED_RELATED_ARTICLE_TYPES, + obtained=obtained, + advice=advice_text.format(**advice_params), + data=self.related_article, + error_level=error_level, + advice_text=advice_text, + advice_params=advice_params, + ) + + def validate_xlink_href_presence(self): + """ + Validate presence of @xlink:href attribute (ERROR). + + SPS Rule: @xlink:href is mandatory in all elements. + + Returns + ------- + dict or None + Validation result if attribute is missing or empty, None if valid + """ + error_level = self._get_error_level("xlink_href_presence") + obtained = self.related_article.get("href") + is_valid = obtained is not None and obtained.strip() != "" + + advice_text = _( + 'Add @xlink:href attribute to .' + ' Provide a valid DOI or URI.' + ) + advice_params = {} + + if not is_valid: + return build_response( + title="Related article xlink:href presence", + parent=self.related_article, + item="related-article", + sub_item="@xlink:href", + validation_type="exist", + is_valid=is_valid, + expected="@xlink:href attribute present", + obtained=obtained, + advice=advice_text.format(**advice_params), + data=self.related_article, + error_level=error_level, + advice_text=advice_text, + advice_params=advice_params, + ) + + def validate_ext_link_type_value(self): + """ + Validate @ext-link-type value is in allowed list (ERROR). + + SPS Rule: @ext-link-type must be "doi" or "uri". + Comparison is case-sensitive. + + Returns + ------- + dict or None + Validation result if value is invalid, None if valid or attribute missing + """ + obtained = self.related_article.get("ext-link-type") + if not obtained or not obtained.strip(): + return None + + error_level = self._get_error_level("ext_link_type_value") + is_valid = obtained in ALLOWED_EXT_LINK_TYPES + + advice_text = _( + 'Value "{obtained}" is not allowed for @ext-link-type.' + ' Valid values: {allowed_values}' + ) + advice_params = { + "obtained": obtained, + "allowed_values": ", ".join(ALLOWED_EXT_LINK_TYPES), + } + + if not is_valid: + return build_response( + title="Related article ext-link-type value", + parent=self.related_article, + item="related-article", + sub_item="@ext-link-type", + validation_type="value in list", + is_valid=is_valid, + expected=ALLOWED_EXT_LINK_TYPES, + obtained=obtained, + advice=advice_text.format(**advice_params), + data=self.related_article, + error_level=error_level, + advice_text=advice_text, + advice_params=advice_params, + ) + + def validate_doi_preference(self): + """ + Validate preference for DOI over URI (WARNING). + + SPS Rule: @ext-link-type should be "doi" by default. Only + "reviewer-report" and "preprint" types are allowed to use "uri". + For all other types, using "uri" produces a WARNING. + + Returns + ------- + dict or None + Validation result if uri is used when doi should be preferred, + None if valid or not applicable + """ + ext_link_type = self.related_article.get("ext-link-type") + related_type = self.related_article.get("related-article-type") + + if ext_link_type != "uri": + return None + + if related_type in URI_ALLOWED_RELATED_ARTICLE_TYPES: + return None + + error_level = self._get_error_level("doi_preference") + + advice_text = _( + 'For @related-article-type="{related_type}", use @ext-link-type="doi".' + ' Value "uri" is only recommended for: {uri_types}' + ) + advice_params = { + "related_type": related_type or "", + "uri_types": ", ".join(URI_ALLOWED_RELATED_ARTICLE_TYPES), + } + + return build_response( + title="Related article doi preference", + parent=self.related_article, + item="related-article", + sub_item="@ext-link-type", + validation_type="value", + is_valid=False, + expected="doi", + obtained=ext_link_type, + advice=advice_text.format(**advice_params), + data=self.related_article, + error_level=error_level, + advice_text=advice_text, + advice_params=advice_params, + ) + def validate_type(self): """Validate if related article type matches main article type""" expected_values = ( @@ -82,6 +366,16 @@ def validate_type(self): obtained_type = self.related_article.get("related-article-type") if not expected_values: + advice_text = _( + 'The article-type "{article_type}" does not match the' + ' related-article-type "{obtained_type}".' + ' Provide one of: {expected_values}' + ) + advice_params = { + "article_type": self.original_article_type, + "obtained_type": obtained_type or "", + "expected_values": str(expected_values), + } return build_response( title="Related article type", parent=self.related_article, @@ -91,14 +385,25 @@ def validate_type(self): is_valid=False, expected=expected_values, obtained=obtained_type, - advice=f"The article-type: {self.original_article_type} does not match the related-article-type: " - f"{obtained_type}, provide one of the following items: {expected_values}", + advice=advice_text.format(**advice_params), data=self.related_article, error_level=self._get_error_level("type"), + advice_text=advice_text, + advice_params=advice_params, ) is_valid = obtained_type in expected_values if not is_valid: + advice_text = _( + 'The article-type "{article_type}" does not match the' + ' related-article-type "{obtained_type}".' + ' Provide one of: {expected_values}' + ) + advice_params = { + "article_type": self.original_article_type, + "obtained_type": obtained_type or "", + "expected_values": str(expected_values), + } return build_response( title="Related article type", parent=self.related_article, @@ -108,10 +413,11 @@ def validate_type(self): is_valid=is_valid, expected=expected_values, obtained=obtained_type, - advice=f"The article-type: {self.original_article_type} does not match the related-article-type: " - f"{obtained_type}, provide one of the following items: {expected_values}", + advice=advice_text.format(**advice_params), data=self.related_article, error_level=self._get_error_level("type"), + advice_text=advice_text, + advice_params=advice_params, ) def validate_ext_link_type(self): @@ -120,6 +426,14 @@ def validate_ext_link_type(self): is_valid = ext_link_type in self.valid_ext_link_types if not is_valid: + advice_text = _( + 'The @ext-link-type should be one of {allowed_values}' + ' for related article with id="{related_id}"' + ) + advice_params = { + "allowed_values": str(self.valid_ext_link_types), + "related_id": self.related_article.get("id") or "", + } return build_response( title="Related article ext-link-type", parent=self.related_article, @@ -129,9 +443,11 @@ def validate_ext_link_type(self): is_valid=is_valid, expected=self.valid_ext_link_types, obtained=ext_link_type, - advice=f'The ext-link-type should be one of {self.valid_ext_link_types} for related article with id="{self.related_article.get("id")}"', + advice=advice_text.format(**advice_params), data=self.related_article, error_level=self._get_error_level("ext_link_type"), + advice_text=advice_text, + advice_params=advice_params, ) def validate_uri(self): @@ -142,6 +458,14 @@ def validate_uri(self): link = self.related_article.get("href") if not link: + advice_text = _( + 'Provide a valid {link_type} for ' + ) + advice_params = { + "link_type": ext_link_type.upper() if ext_link_type else "link", + "related_id": self.related_article.get("id") or "", + } return build_response( title="Related article link", parent=self.related_article, @@ -151,16 +475,24 @@ def validate_uri(self): is_valid=False, expected=f'A valid {ext_link_type.upper() if ext_link_type else "link"}', obtained=link, - advice=f'Provide a valid {ext_link_type.upper() if ext_link_type else "link"} for ', + advice=advice_text.format(**advice_params), data=self.related_article, error_level=self._get_error_level("uri"), + advice_text=advice_text, + advice_params=advice_params, ) is_valid = is_valid_url_format(link) expected = "A valid URI format (e.g., http://example.com)" if not is_valid: + advice_text = _( + 'Invalid {link_type} format for link: {link}' + ) + advice_params = { + "link_type": ext_link_type.upper(), + "link": link, + } return build_response( title="Related article link", parent=self.related_article, @@ -170,13 +502,11 @@ def validate_uri(self): is_valid=is_valid, expected=expected, obtained=link, - advice=( - None - if is_valid - else f"Invalid {ext_link_type.upper()} format for link: {link}" - ), + advice=advice_text.format(**advice_params), data=self.related_article, error_level=self._get_error_level("uri_format"), + advice_text=advice_text, + advice_params=advice_params, ) def validate_doi(self): @@ -187,6 +517,14 @@ def validate_doi(self): link = self.related_article.get("href") if not link: + advice_text = _( + 'Provide a valid {link_type} for ' + ) + advice_params = { + "link_type": ext_link_type.upper() if ext_link_type else "link", + "related_id": self.related_article.get("id") or "", + } return build_response( title="Related article doi", parent=self.related_article, @@ -196,10 +534,11 @@ def validate_doi(self): is_valid=False, expected=f'A valid {ext_link_type.upper() if ext_link_type else "link"}', obtained=link, - advice=f'Provide a valid {ext_link_type.upper() if ext_link_type else "link"} for ', + advice=advice_text.format(**advice_params), data=self.related_article, error_level=self.params.get("doi_error_level"), + advice_text=advice_text, + advice_params=advice_params, ) valid = validate_doi_format(link) @@ -207,6 +546,13 @@ def validate_doi(self): expected = "A valid DOI" if not is_valid: + advice_text = _( + 'Invalid {link_type} format for link: {link}' + ) + advice_params = { + "link_type": ext_link_type.upper(), + "link": link, + } return build_response( title="Related article doi", parent=self.related_article, @@ -216,22 +562,33 @@ def validate_doi(self): is_valid=is_valid, expected=expected, obtained=link, - advice=( - None - if is_valid - else f"Invalid {ext_link_type.upper()} format for link: {link}" - ), + advice=advice_text.format(**advice_params), data=self.related_article, error_level=self.params.get("doi_format_error_level"), + advice_text=advice_text, + advice_params=advice_params, ) def validate_id_presence(self): - """Validate if related article has an ID""" + """ + Validate presence of @id attribute (CRITICAL). + + SPS Rule: @id is mandatory in all elements. + + Returns + ------- + dict or None + Validation result if attribute is missing or empty, None if valid + """ related_id = self.related_article.get("id") is_valid = related_id is not None and related_id.strip() != "" - expected = "A non-empty ID" if not is_valid: + advice_text = _( + 'Add @id attribute to .' + ' The @id must be a non-empty unique identifier.' + ) + advice_params = {} return build_response( title="Related article id", parent=self.related_article, @@ -239,21 +596,42 @@ def validate_id_presence(self): sub_item="id", validation_type="exist", is_valid=is_valid, - expected=expected, + expected="A non-empty ID", obtained=related_id, - advice="Add id attribute to related-article", + advice=advice_text.format(**advice_params), data=self.related_article, error_level=self._get_error_level("id"), + advice_text=advice_text, + advice_params=advice_params, ) def validate_attrib_order(self): - # FIXME - expected_order = self.params["attrib_order"] + """ + Recommend attribute order (INFO). + + SPS Rule: Attributes should follow the order: + @related-article-type, @id, @xlink:href, @ext-link-type + + Returns + ------- + dict or None + Validation result if order is wrong, None if correct or not configured + """ + expected_order = self.params.get("attrib_order") if not expected_order: return order = self.related_article.get("attribs") - if order != expected_order: + if not order: + return + + if list(order) != list(expected_order): + advice_text = _( + 'Set related-article attributes in this order: {expected_order}' + ) + advice_params = { + "expected_order": str(expected_order), + } return build_response( title="Related article attribute order", parent=self.related_article, @@ -262,15 +640,23 @@ def validate_attrib_order(self): validation_type="value", is_valid=False, expected=expected_order, - obtained=order, - advice=f"Set related-article attributes in this order {expected_order}", + obtained=list(order), + advice=advice_text.format(**advice_params), data=self.related_article, - error_level=self.params.get("attrib_order_error_level"), + error_level=self.params.get("attrib_order_error_level", "INFO"), + advice_text=advice_text, + advice_params=advice_params, ) def validate(self): """Run all validations""" validations = [ + self.validate_related_article_type_presence(), + self.validate_ext_link_type_presence(), + self.validate_related_article_type_value(), + self.validate_ext_link_type_value(), + self.validate_xlink_href_presence(), + self.validate_doi_preference(), self.validate_attrib_order(), self.validate_type(), self.validate_ext_link_type(), @@ -337,6 +723,14 @@ def validate_presence_of_required_related_articles(self): if missing_types: error_level = self._get_error_level("requirement") + advice_text = _( + 'Article type "{article_type}" requires related articles' + ' of types: {missing_types}' + ) + advice_params = { + "article_type": self.obj.original_article_type, + "missing_types": str(list(missing_types)), + } return build_response( title="Required related articles", parent=self.obj.parent_data, @@ -346,13 +740,14 @@ def validate_presence_of_required_related_articles(self): is_valid=False, expected=self.required_types, obtained=list(found_types), - advice=f'Article type "{self.obj.original_article_type}" ' - f"requires related articles of types: {list(missing_types)}", + advice=advice_text.format(**advice_params), data={ "article_type": self.obj.original_article_type, "missing_types": list(missing_types), }, error_level=error_level, + advice_text=advice_text, + advice_params=advice_params, ) return None diff --git a/packtools/sps/validation_rules/related_article_rules.json b/packtools/sps/validation_rules/related_article_rules.json index 86d4f4970..0ba4a3352 100644 --- a/packtools/sps/validation_rules/related_article_rules.json +++ b/packtools/sps/validation_rules/related_article_rules.json @@ -1,6 +1,6 @@ { "related_article_rules": { - "attrib_order_error_level": "CRITICAL", + "attrib_order_error_level": "INFO", "required_related_articles_error_level": "CRITICAL", "type_error_level": "CRITICAL", "ext_link_type_error_level": "CRITICAL", @@ -9,6 +9,12 @@ "doi_error_level": "CRITICAL", "doi_format_error_level": "CRITICAL", "id_error_level": "CRITICAL", + "related_article_type_presence_error_level": "CRITICAL", + "ext_link_type_presence_error_level": "CRITICAL", + "related_article_type_value_error_level": "ERROR", + "ext_link_type_value_error_level": "ERROR", + "xlink_href_presence_error_level": "ERROR", + "doi_preference_error_level": "WARNING", "ext_link_type_list": [ "doi", "uri" diff --git a/tests/sps/validation/test_peer_review.py b/tests/sps/validation/test_peer_review.py index ae6a40da2..12441d4f8 100644 --- a/tests/sps/validation/test_peer_review.py +++ b/tests/sps/validation/test_peer_review.py @@ -552,7 +552,7 @@ def test_valid_related_article(self): self.assertIn( "Set related-article attributes in this", errors[0]["advice"] ) - self.assertIn("Add id attribute", errors[1]["advice"]) + self.assertIn("@id attribute", errors[1]["advice"]) self.assertEqual(len(errors), 2) def test_invalid_related_article_type(self): @@ -567,7 +567,10 @@ def test_invalid_related_article_type(self): { "got_value": ["invalid-type"], "expected_value": ["peer-reviewed-material"], - "advice": """Article type "reviewer-report" requires related articles of types: ['peer-reviewed-material']""", + "response": "CRITICAL", + }, + { + "got_value": "invalid-type", "response": "CRITICAL", }, { @@ -582,28 +585,25 @@ def test_invalid_related_article_type(self): "{http://www.w3.org/1999/xlink}href", "ext-link-type", ], - "advice": """Set related-article attributes in this order ['related-article-type', 'id', '{http://www.w3.org/1999/xlink}href', 'ext-link-type']""", "response": "CRITICAL", }, { "got_value": "invalid-type", "expected_value": ["peer-reviewed-material"], - "advice": """The article-type: reviewer-report does not match the related-article-type: invalid-type, provide one of the following items: ['peer-reviewed-material']""", "response": "CRITICAL", }, { "got_value": None, "expected_value": "A non-empty ID", - "advice": "Add id attribute to related-article", "response": "CRITICAL", }, ] - self.assertEqual(len(errors), 4) + self.assertEqual(len(errors), 5) for i, item in enumerate(expected): with self.subTest(i): self.assertEqual(item["got_value"], errors[i]["got_value"]) - self.assertEqual( - item["expected_value"], errors[i]["expected_value"] - ) - self.assertEqual(item["advice"], errors[i]["advice"]) + if "expected_value" in item: + self.assertEqual( + item["expected_value"], errors[i]["expected_value"] + ) self.assertEqual(item["response"], errors[i]["response"]) diff --git a/tests/sps/validation/test_related_articles.py b/tests/sps/validation/test_related_articles.py index f59e2925c..179e37511 100644 --- a/tests/sps/validation/test_related_articles.py +++ b/tests/sps/validation/test_related_articles.py @@ -10,7 +10,7 @@ PARAMS = { - "attrib_order_error_level": "CRITICAL", + "attrib_order_error_level": "INFO", "required_related_articles_error_level": "CRITICAL", "type_error_level": "CRITICAL", "ext_link_type_error_level": "CRITICAL", @@ -19,6 +19,12 @@ "doi_error_level": "CRITICAL", "doi_format_error_level": "CRITICAL", "id_error_level": "CRITICAL", + "related_article_type_presence_error_level": "CRITICAL", + "ext_link_type_presence_error_level": "CRITICAL", + "related_article_type_value_error_level": "ERROR", + "ext_link_type_value_error_level": "ERROR", + "xlink_href_presence_error_level": "ERROR", + "doi_preference_error_level": "WARNING", "ext_link_type_list": ["doi", "uri"], "attrib_order": [ "related-article-type", @@ -324,7 +330,8 @@ def test_validate_type_no_match(self): self.assertEqual(result["response"], "CRITICAL") self.assertEqual(result["got_value"], "corrected-article") self.assertEqual(result["expected_value"], ["retracted-article"]) - self.assertTrue(result["advice"].startswith("The article-type: retraction")) + self.assertIn("retraction", result["advice"]) + self.assertIn("corrected-article", result["advice"]) class TestRelatedArticleLinkValidation(BaseRelatedArticleTest): @@ -414,7 +421,7 @@ def test_validate_ext_link_type_invalid(self): self.assertEqual(result["response"], "CRITICAL") self.assertEqual(result["got_value"], "url") self.assertEqual(result["expected_value"], ["doi", "uri"]) - self.assertTrue(result["advice"].startswith("The ext-link-type")) + self.assertIn("ext-link-type", result["advice"]) class TestRelatedArticleFullValidation(BaseRelatedArticleTest): @@ -435,7 +442,9 @@ def setUp(self): def test_validate_all_pass_doi(self): validator = RelatedArticleValidation(self.base_article, self.params) results = validator.validate() - self.assertEqual(len(results), 1) + # All validations should pass (no errors) + error_results = [r for r in results if r["response"] != "OK"] + self.assertEqual(len(error_results), 0) def test_validate_all_pass_uri(self): self.base_article.update( @@ -591,17 +600,17 @@ def setUp(self): def test_nested_validation(self): results = list(self.validator.validate()) - # Find errors in translation sub-article + # Find CRITICAL errors in translation sub-article translation_errors = [ r for r in results if r["parent_article_type"] == "translation" and r["response"] == "CRITICAL" ] - self.assertEqual(len(translation_errors), 3) - error = translation_errors[1] - self.assertEqual(error["validation_type"], "match") - self.assertIn("correction-forward", error["expected_value"]) + self.assertEqual(len(translation_errors), 2) + type_error = [r for r in translation_errors if r["title"] == "Related article type"][0] + self.assertEqual(type_error["validation_type"], "match") + self.assertIn("correction-forward", type_error["expected_value"]) class TestValidStructure(BaseRelatedArticleTest): @@ -686,11 +695,11 @@ def test_multiple_sub_articles(self): ] self.assertEqual(len(spanish_errors), 0) - # Second sub-article should have error - portuguese_errors = [ + # Second sub-article should have CRITICAL error for type mismatch + portuguese_critical = [ r for r in results if r["parent_id"] == "s2" and r["response"] == "CRITICAL" ] - self.assertEqual(len(portuguese_errors), 2) + self.assertEqual(len(portuguese_critical), 1) class TestOriginalArticleType(BaseRelatedArticleTest): @@ -730,5 +739,1038 @@ def test_original_article_type_inheritance(self): ) +# ============================================================ +# New tests for SPS 1.10 related-article validations +# ============================================================ + + +class TestRelatedArticleTypePresence(BaseRelatedArticleTest): + """Tests for validate_related_article_type_presence (CRITICAL)""" + + def setUp(self): + super().setUp() + self.base_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "ext-link-type": "doi", + "href": "10.1590/example", + "id": "ra1", + "related-article-type": "corrected-article", + } + + def test_related_article_type_present(self): + """@related-article-type is present: should return None (OK)""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_presence() + self.assertIsNone(result) + + def test_related_article_type_missing(self): + """@related-article-type is missing: should return CRITICAL""" + del self.base_article["related-article-type"] + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + self.assertIn("@related-article-type", result["sub_item"]) + + def test_related_article_type_empty(self): + """@related-article-type is empty string: should return CRITICAL""" + self.base_article["related-article-type"] = "" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + + def test_related_article_type_whitespace_only(self): + """@related-article-type is only spaces: should return CRITICAL""" + self.base_article["related-article-type"] = " " + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + + def test_related_article_type_presence_has_i18n(self): + """Presence validation should include i18n fields""" + del self.base_article["related-article-type"] + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_presence() + self.assertIsNotNone(result["adv_text"]) + self.assertIsNotNone(result["adv_params"]) + + +class TestExtLinkTypePresence(BaseRelatedArticleTest): + """Tests for validate_ext_link_type_presence (CRITICAL)""" + + def setUp(self): + super().setUp() + self.base_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "ext-link-type": "doi", + "href": "10.1590/example", + "id": "ra1", + "related-article-type": "corrected-article", + } + + def test_ext_link_type_present(self): + """@ext-link-type is present: should return None (OK)""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_presence() + self.assertIsNone(result) + + def test_ext_link_type_missing(self): + """@ext-link-type is missing: should return CRITICAL""" + del self.base_article["ext-link-type"] + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + self.assertIn("@ext-link-type", result["sub_item"]) + + def test_ext_link_type_empty(self): + """@ext-link-type is empty string: should return CRITICAL""" + self.base_article["ext-link-type"] = "" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + + def test_ext_link_type_whitespace_only(self): + """@ext-link-type is only spaces: should return CRITICAL""" + self.base_article["ext-link-type"] = " " + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + + def test_ext_link_type_presence_has_i18n(self): + """Presence validation should include i18n fields""" + del self.base_article["ext-link-type"] + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_presence() + self.assertIsNotNone(result["adv_text"]) + self.assertIsNotNone(result["adv_params"]) + + +class TestRelatedArticleTypeValue(BaseRelatedArticleTest): + """Tests for validate_related_article_type_value (ERROR)""" + + def setUp(self): + super().setUp() + self.base_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "ext-link-type": "doi", + "href": "10.1590/example", + "id": "ra1", + } + + def test_corrected_article(self): + """corrected-article is a valid value""" + self.base_article["related-article-type"] = "corrected-article" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_correction_forward(self): + """correction-forward is a valid value""" + self.base_article["related-article-type"] = "correction-forward" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_retracted_article(self): + """retracted-article is a valid value""" + self.base_article["related-article-type"] = "retracted-article" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_retraction_forward(self): + """retraction-forward is a valid value""" + self.base_article["related-article-type"] = "retraction-forward" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_partial_retraction(self): + """partial-retraction is a valid value""" + self.base_article["related-article-type"] = "partial-retraction" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_addended_article(self): + """addended-article is a valid value""" + self.base_article["related-article-type"] = "addended-article" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_addendum(self): + """addendum is a valid value""" + self.base_article["related-article-type"] = "addendum" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_expression_of_concern(self): + """expression-of-concern is a valid value""" + self.base_article["related-article-type"] = "expression-of-concern" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_object_of_concern(self): + """object-of-concern is a valid value""" + self.base_article["related-article-type"] = "object-of-concern" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_commentary_article(self): + """commentary-article is a valid value""" + self.base_article["related-article-type"] = "commentary-article" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_commentary(self): + """commentary is a valid value""" + self.base_article["related-article-type"] = "commentary" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_reply(self): + """reply is a valid value""" + self.base_article["related-article-type"] = "reply" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_letter(self): + """letter is a valid value""" + self.base_article["related-article-type"] = "letter" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_reviewed_article(self): + """reviewed-article is a valid value""" + self.base_article["related-article-type"] = "reviewed-article" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_reviewer_report(self): + """reviewer-report is a valid value""" + self.base_article["related-article-type"] = "reviewer-report" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_preprint(self): + """preprint is a valid value""" + self.base_article["related-article-type"] = "preprint" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_invalid_value_related(self): + """'related' is not a valid value: should return ERROR""" + self.base_article["related-article-type"] = "related" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + self.assertEqual(result["got_value"], "related") + + def test_invalid_value_errata(self): + """'errata' is not a valid value: should return ERROR""" + self.base_article["related-article-type"] = "errata" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + + def test_uppercase_value(self): + """'Corrected-Article' (uppercase) is not valid: should return ERROR""" + self.base_article["related-article-type"] = "Corrected-Article" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + self.assertEqual(result["got_value"], "Corrected-Article") + + def test_missing_attribute_returns_none(self): + """Missing attribute should return None (validated by presence check)""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_empty_attribute_returns_none(self): + """Empty attribute should return None (validated by presence check)""" + self.base_article["related-article-type"] = "" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNone(result) + + def test_value_validation_has_i18n(self): + """Value validation should include i18n fields""" + self.base_article["related-article-type"] = "invalid" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_related_article_type_value() + self.assertIsNotNone(result["adv_text"]) + self.assertIsNotNone(result["adv_params"]) + + +class TestExtLinkTypeValue(BaseRelatedArticleTest): + """Tests for validate_ext_link_type_value (ERROR)""" + + def setUp(self): + super().setUp() + self.base_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "href": "10.1590/example", + "id": "ra1", + "related-article-type": "corrected-article", + } + + def test_doi_valid(self): + """'doi' is valid for @ext-link-type""" + self.base_article["ext-link-type"] = "doi" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_value() + self.assertIsNone(result) + + def test_uri_valid(self): + """'uri' is valid for @ext-link-type""" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_value() + self.assertIsNone(result) + + def test_url_invalid(self): + """'url' is not valid: should return ERROR""" + self.base_article["ext-link-type"] = "url" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_value() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + self.assertEqual(result["got_value"], "url") + + def test_link_invalid(self): + """'link' is not valid: should return ERROR""" + self.base_article["ext-link-type"] = "link" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_value() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + + def test_uppercase_doi(self): + """'DOI' (uppercase) is not valid: should return ERROR""" + self.base_article["ext-link-type"] = "DOI" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_value() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + + def test_uppercase_uri(self): + """'URI' (uppercase) is not valid: should return ERROR""" + self.base_article["ext-link-type"] = "URI" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_value() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + + def test_missing_returns_none(self): + """Missing @ext-link-type should return None (validated by presence check)""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_value() + self.assertIsNone(result) + + def test_empty_returns_none(self): + """Empty @ext-link-type should return None (validated by presence check)""" + self.base_article["ext-link-type"] = "" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_ext_link_type_value() + self.assertIsNone(result) + + +class TestXlinkHrefPresence(BaseRelatedArticleTest): + """Tests for validate_xlink_href_presence (ERROR)""" + + def setUp(self): + super().setUp() + self.base_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "ext-link-type": "doi", + "id": "ra1", + "related-article-type": "corrected-article", + } + + def test_xlink_href_present_doi(self): + """@xlink:href present with DOI: should return None (OK)""" + self.base_article["href"] = "10.1590/example" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_xlink_href_presence() + self.assertIsNone(result) + + def test_xlink_href_present_url(self): + """@xlink:href present with URL: should return None (OK)""" + self.base_article["href"] = "https://example.com/article" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_xlink_href_presence() + self.assertIsNone(result) + + def test_xlink_href_present_doi_prefix(self): + """@xlink:href present with doi.org prefix: should return None (OK)""" + self.base_article["href"] = "https://doi.org/10.1590/example" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_xlink_href_presence() + self.assertIsNone(result) + + def test_xlink_href_missing(self): + """@xlink:href missing: should return ERROR""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_xlink_href_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + self.assertIn("@xlink:href", result["sub_item"]) + + def test_xlink_href_empty(self): + """@xlink:href empty: should return ERROR""" + self.base_article["href"] = "" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_xlink_href_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + + def test_xlink_href_whitespace_only(self): + """@xlink:href only spaces: should return ERROR""" + self.base_article["href"] = " " + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_xlink_href_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "ERROR") + + def test_xlink_href_presence_has_i18n(self): + """Presence validation should include i18n fields""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_xlink_href_presence() + self.assertIsNotNone(result["adv_text"]) + self.assertIsNotNone(result["adv_params"]) + + +class TestDoiPreference(BaseRelatedArticleTest): + """Tests for validate_doi_preference (WARNING)""" + + def setUp(self): + super().setUp() + self.base_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "href": "10.1590/example", + "id": "ra1", + } + + def test_corrected_article_with_doi_ok(self): + """corrected-article with doi: should return None (preferred)""" + self.base_article["related-article-type"] = "corrected-article" + self.base_article["ext-link-type"] = "doi" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNone(result) + + def test_corrected_article_with_uri_warning(self): + """corrected-article with uri: should return WARNING""" + self.base_article["related-article-type"] = "corrected-article" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "WARNING") + + def test_preprint_with_doi_ok(self): + """preprint with doi: should return None (preferred)""" + self.base_article["related-article-type"] = "preprint" + self.base_article["ext-link-type"] = "doi" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNone(result) + + def test_preprint_with_uri_ok(self): + """preprint with uri: should return None (allowed exception)""" + self.base_article["related-article-type"] = "preprint" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNone(result) + + def test_reviewer_report_with_doi_ok(self): + """reviewer-report with doi: should return None (preferred)""" + self.base_article["related-article-type"] = "reviewer-report" + self.base_article["ext-link-type"] = "doi" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNone(result) + + def test_reviewer_report_with_uri_ok(self): + """reviewer-report with uri: should return None (allowed exception)""" + self.base_article["related-article-type"] = "reviewer-report" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNone(result) + + def test_retracted_article_with_uri_warning(self): + """retracted-article with uri: should return WARNING""" + self.base_article["related-article-type"] = "retracted-article" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "WARNING") + + def test_addendum_with_uri_warning(self): + """addendum with uri: should return WARNING""" + self.base_article["related-article-type"] = "addendum" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "WARNING") + + def test_doi_preference_warning_has_i18n(self): + """DOI preference warning should include i18n fields""" + self.base_article["related-article-type"] = "corrected-article" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNotNone(result["adv_text"]) + self.assertIsNotNone(result["adv_params"]) + + def test_commentary_with_uri_warning(self): + """commentary with uri: should return WARNING""" + self.base_article["related-article-type"] = "commentary" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "WARNING") + + def test_letter_with_uri_warning(self): + """letter with uri: should return WARNING""" + self.base_article["related-article-type"] = "letter" + self.base_article["ext-link-type"] = "uri" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_doi_preference() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "WARNING") + + +class TestIdPresenceNew(BaseRelatedArticleTest): + """Additional tests for validate_id_presence""" + + def setUp(self): + super().setUp() + self.base_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "ext-link-type": "doi", + "href": "10.1590/example", + "related-article-type": "corrected-article", + } + + def test_id_present(self): + """@id is present: should return None""" + self.base_article["id"] = "ra1" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_id_presence() + self.assertIsNone(result) + + def test_id_missing(self): + """@id is missing: should return CRITICAL""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_id_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + + def test_id_empty(self): + """@id is empty: should return CRITICAL""" + self.base_article["id"] = "" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_id_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + + def test_id_whitespace_only(self): + """@id only spaces: should return CRITICAL""" + self.base_article["id"] = " " + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_id_presence() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "CRITICAL") + + def test_id_special_chars(self): + """@id with special characters: should return None""" + self.base_article["id"] = "r-1_a" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_id_presence() + self.assertIsNone(result) + + def test_id_presence_has_i18n(self): + """ID presence validation should include i18n fields""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_id_presence() + self.assertIsNotNone(result["adv_text"]) + self.assertIsNotNone(result["adv_params"]) + + +class TestAttribOrderNew(BaseRelatedArticleTest): + """Additional tests for validate_attrib_order""" + + def setUp(self): + super().setUp() + self.base_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "ext-link-type": "doi", + "href": "10.1590/example", + "id": "ra1", + "related-article-type": "corrected-article", + } + + def test_correct_order(self): + """Correct order should return None""" + expected_order = [ + "related-article-type", + "id", + "{http://www.w3.org/1999/xlink}href", + "ext-link-type", + ] + self.base_article["attribs"] = expected_order + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_attrib_order() + self.assertIsNone(result) + + def test_wrong_order(self): + """Wrong order should return INFO""" + self.base_article["attribs"] = [ + "id", + "related-article-type", + "{http://www.w3.org/1999/xlink}href", + "ext-link-type", + ] + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_attrib_order() + self.assertIsNotNone(result) + self.assertEqual(result["response"], "INFO") + + def test_no_attrib_order_config(self): + """Missing attrib_order config should return None""" + params = dict(self.params) + del params["attrib_order"] + self.base_article["attribs"] = ["id", "related-article-type"] + validator = RelatedArticleValidation(self.base_article, params) + result = validator.validate_attrib_order() + self.assertIsNone(result) + + def test_no_attribs_in_data(self): + """Missing attribs in data should return None""" + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_attrib_order() + self.assertIsNone(result) + + def test_attrib_order_has_i18n(self): + """Attrib order validation should include i18n fields""" + self.base_article["attribs"] = [ + "id", + "related-article-type", + ] + validator = RelatedArticleValidation(self.base_article, self.params) + result = validator.validate_attrib_order() + self.assertIsNotNone(result["adv_text"]) + self.assertIsNotNone(result["adv_params"]) + + +class TestFullValidationWithAllNewRules(BaseRelatedArticleTest): + """Tests for validate() with all new validations combined""" + + def setUp(self): + super().setUp() + self.valid_article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + "ext-link-type": "doi", + "href": "10.1590/example", + "id": "ra1", + "related-article-type": "corrected-article", + } + + def test_all_valid_no_errors(self): + """Complete valid related-article should produce no errors""" + validator = RelatedArticleValidation(self.valid_article, self.params) + results = validator.validate() + error_results = [r for r in results if r["response"] != "OK"] + self.assertEqual(len(error_results), 0) + + def test_all_attributes_empty(self): + """All attributes empty should produce multiple CRITICAL errors""" + article = dict(self.valid_article) + article["related-article-type"] = "" + article["id"] = "" + article["ext-link-type"] = "" + article["href"] = "" + validator = RelatedArticleValidation(article, self.params) + results = validator.validate() + critical_results = [r for r in results if r["response"] == "CRITICAL"] + self.assertGreater(len(critical_results), 0) + + def test_missing_all_attributes(self): + """Missing all key attributes should produce errors""" + article = { + "parent": "article", + "parent_article_type": "correction", + "original_article_type": "correction", + "parent_id": None, + "parent_lang": "en", + } + validator = RelatedArticleValidation(article, self.params) + results = validator.validate() + error_results = [r for r in results if r["response"] != "OK"] + self.assertGreater(len(error_results), 0) + + +class TestXMLIntegrationNewValidations(BaseRelatedArticleTest): + """Integration tests using XML parsing with new validations""" + + def test_preprint_with_uri_valid(self): + """Preprint with URI should not produce warnings""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + warnings = [r for r in results if r["response"] == "WARNING"] + self.assertEqual(len(warnings), 0) + + def test_corrected_article_with_doi_valid(self): + """Errata with DOI should pass validations""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + errors = [r for r in results if r["response"] in ("CRITICAL", "ERROR")] + self.assertEqual(len(errors), 0) + + def test_missing_related_article_type_xml(self): + """Missing @related-article-type in XML should produce CRITICAL""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + critical = [r for r in results if r["response"] == "CRITICAL" + and r["title"] == "Related article type presence"] + self.assertEqual(len(critical), 1) + + def test_missing_id_xml(self): + """Missing @id in XML should produce CRITICAL""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + critical = [r for r in results if r["response"] == "CRITICAL" + and r["title"] == "Related article id"] + self.assertEqual(len(critical), 1) + + def test_missing_ext_link_type_xml(self): + """Missing @ext-link-type in XML should produce CRITICAL""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + critical = [r for r in results if r["response"] == "CRITICAL" + and r["title"] == "Related article ext-link-type presence"] + self.assertEqual(len(critical), 1) + + def test_missing_xlink_href_xml(self): + """Missing @xlink:href in XML should produce ERROR""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + errors = [r for r in results if r["response"] == "ERROR" + and r["title"] == "Related article xlink:href presence"] + self.assertEqual(len(errors), 1) + + def test_invalid_related_article_type_xml(self): + """Invalid @related-article-type in XML should produce ERROR""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + errors = [r for r in results if r["response"] == "ERROR" + and r["title"] == "Related article type value"] + self.assertEqual(len(errors), 1) + + def test_invalid_ext_link_type_xml(self): + """Invalid @ext-link-type in XML should produce ERROR""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + errors = [r for r in results if r["response"] == "ERROR" + and r["title"] == "Related article ext-link-type value"] + self.assertEqual(len(errors), 1) + + def test_uri_when_doi_preferred_xml(self): + """Using URI for corrected-article should produce WARNING""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + warnings = [r for r in results if r["response"] == "WARNING" + and r["title"] == "Related article doi preference"] + self.assertEqual(len(warnings), 1) + + def test_multiple_related_articles_xml(self): + """Multiple related-articles in XML""" + xml = """ +
+ + + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + # No critical or error level issues expected + critical_or_error = [r for r in results if r["response"] in ("CRITICAL", "ERROR")] + self.assertEqual(len(critical_or_error), 0) + + def test_no_related_articles_ok(self): + """Article without related-article is OK (zero or more)""" + xml = """ +
+ + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + self.assertEqual(len(results), 0) + + def test_related_article_in_front_stub(self): + """related-article in front-stub (sub-article) should be validated""" + xml = """ +
+ + + + + + + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + critical_or_error = [r for r in results if r["response"] in ("CRITICAL", "ERROR")] + self.assertEqual(len(critical_or_error), 0) + + def test_reviewer_report_with_uri_xml(self): + """reviewer-report with URI should not produce doi preference warning""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + warnings = [r for r in results if r["response"] == "WARNING"] + self.assertEqual(len(warnings), 0) + + def test_preprint_with_doi_valid_xml(self): + """Preprint with DOI is valid (doi is always preferred)""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + warnings = [r for r in results if r["response"] == "WARNING"] + self.assertEqual(len(warnings), 0) + + def test_retraction_with_uri_warning_xml(self): + """Retraction with URI should produce WARNING""" + xml = """ +
+ + + + + +
""" + xmltree = etree.fromstring(xml) + validator = XMLRelatedArticlesValidation(xmltree, self.params) + results = list(validator.validate()) + warnings = [r for r in results if r["response"] == "WARNING" + and r["title"] == "Related article doi preference"] + self.assertEqual(len(warnings), 1) + + if __name__ == "__main__": unittest.main()