diff --git a/packtools/sps/validation/funding_group.py b/packtools/sps/validation/funding_group.py index e2c0d6d36..8cfbbe0d1 100644 --- a/packtools/sps/validation/funding_group.py +++ b/packtools/sps/validation/funding_group.py @@ -154,3 +154,295 @@ def validate_funding_statement(self): data=statements, error_level=self.params["funding_statement_error_level"], ) + + def validate_funding_group_uniqueness(self, error_level="ERROR"): + """ + Rule 1: Validates that appears at most once in . + + According to SPS 1.10, only one is allowed per . + + Params + ------ + error_level : str, optional + The severity level of the validation error, by default "ERROR". + + Yields + ------ + dict + Validation result for funding-group uniqueness. + """ + funding_groups = self.xml_tree.xpath(".//article-meta/funding-group") + count = len(funding_groups) + + funding_data = self.funding.data + parent = { + "parent": "article", + "parent_id": None, + "parent_article_type": funding_data.get("article_type"), + "parent_lang": funding_data.get("article_lang"), + } + + is_valid = count <= 1 + advice = None + if not is_valid: + advice = f"Found {count} elements in . Only one is allowed. Merge them into a single ." + + yield build_response( + title="funding-group uniqueness", + parent=parent, + item="funding-group", + sub_item=None, + validation_type="unique", + is_valid=is_valid, + expected="At most one in ", + obtained=f"{count} element(s) found", + advice=advice, + data={"count": count}, + error_level=error_level, + ) + + def validate_funding_statement_presence(self, error_level="CRITICAL"): + """ + Rule 2: Validates that is present in . + + According to SPS 1.10, is mandatory in all cases. + + Params + ------ + error_level : str, optional + The severity level of the validation error, by default "CRITICAL". + + Yields + ------ + dict + Validation result for funding-statement presence. + """ + funding_groups = self.xml_tree.xpath(".//article-meta/funding-group") + + if not funding_groups: + # No funding-group means no validation needed + return + + funding_data = self.funding.data + parent = { + "parent": "article", + "parent_id": None, + "parent_article_type": funding_data.get("article_type"), + "parent_lang": funding_data.get("article_lang"), + } + + funding_statement = self.funding.funding_statement + is_valid = funding_statement is not None + + advice = None + if not is_valid: + advice = "Add element inside . It is mandatory according to SPS 1.10." + + yield build_response( + title="funding-statement presence", + parent=parent, + item="funding-statement", + sub_item=None, + validation_type="exist", + is_valid=is_valid, + expected=" present in ", + obtained=funding_statement if funding_statement else "None", + advice=advice, + data={"funding_statement": funding_statement}, + error_level=error_level, + ) + + def validate_funding_source_in_award_group(self, error_level="CRITICAL"): + """ + Rule 3: Validates that is present when exists. + + According to SPS 1.10, when there are institutions declared via , + is mandatory. + + Params + ------ + error_level : str, optional + The severity level of the validation error, by default "CRITICAL". + + Yields + ------ + dict + Validation results for each award-group. + """ + funding_data = self.funding.data + parent = { + "parent": "article", + "parent_id": None, + "parent_article_type": funding_data.get("article_type"), + "parent_lang": funding_data.get("article_lang"), + } + + for item in self.funding.award_groups: + funding_sources = item["funding-source"] + + is_valid = len(funding_sources) > 0 + advice = None + if not is_valid: + advice = "Add at least one element inside this . It is mandatory when exists." + + yield build_response( + title="funding-source in award-group", + parent=parent, + item="award-group", + sub_item="funding-source", + validation_type="exist", + is_valid=is_valid, + expected="At least one in ", + obtained=f"{len(funding_sources)} element(s) found", + advice=advice, + data=item, + error_level=error_level, + ) + + def validate_label_absence(self, error_level="ERROR"): + """ + Rule 5: Validates that + + + """ + xml_tree = etree.fromstring(xml) + validator = FundingGroupValidation(xml_tree, self.params) + results = list(validator.validate_title_absence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "ERROR") + self.assertIn("Remove", results[0]["advice"]) + self.assertIn("", results[0]["advice"]) + + +class TestAwardIdFundingSourceConsistency(TestFundingValidationBase): + """Rule 7: Test <award-id> and <funding-source> consistency validation""" + + def test_support_without_contract_valid(self): + """Support without contract (0 award-ids) should be valid""" + xml = """ + <article article-type="research-article" xml:lang="en"> + <front> + <article-meta> + <funding-group> + <award-group> + <funding-source>FAPESP</funding-source> + </award-group> + <funding-statement>Funded by FAPESP</funding-statement> + </funding-group> + </article-meta> + </front> + </article> + """ + xml_tree = etree.fromstring(xml) + validator = FundingGroupValidation(xml_tree, self.params) + results = list(validator.validate_award_id_funding_source_consistency()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_single_contract_valid(self): + """Single contract (1 award-id) for multiple sources should be valid""" + xml = """ + <article article-type="research-article" xml:lang="en"> + <front> + <article-meta> + <funding-group> + <award-group> + <funding-source>FAPESP</funding-source> + <funding-source>CAPES</funding-source> + <award-id>04/08142-0</award-id> + </award-group> + <funding-statement>Funded by FAPESP and CAPES</funding-statement> + </funding-group> + </article-meta> + </front> + </article> + """ + xml_tree = etree.fromstring(xml) + validator = FundingGroupValidation(xml_tree, self.params) + results = list(validator.validate_award_id_funding_source_consistency()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_matching_quantities_valid(self): + """Matching quantities (N sources, N awards) should be valid""" + xml = """ + <article article-type="research-article" xml:lang="en"> + <front> + <article-meta> + <funding-group> + <award-group> + <funding-source>FAPESP</funding-source> + <funding-source>CAPES</funding-source> + <award-id>04/08142-0</award-id> + <award-id>05/09876-5</award-id> + </award-group> + <funding-statement>Funded by FAPESP and CAPES</funding-statement> + </funding-group> + </article-meta> + </front> + </article> + """ + xml_tree = etree.fromstring(xml) + validator = FundingGroupValidation(xml_tree, self.params) + results = list(validator.validate_award_id_funding_source_consistency()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_inconsistent_quantities_warning(self): + """Inconsistent quantities should trigger warning""" + xml = """ + <article article-type="research-article" xml:lang="en"> + <front> + <article-meta> + <funding-group> + <award-group> + <funding-source>FAPESP</funding-source> + <award-id>04/08142-0</award-id> + <award-id>05/09876-5</award-id> + <award-id>06/12345-6</award-id> + </award-group> + <funding-statement>Funded by FAPESP</funding-statement> + </funding-group> + </article-meta> + </front> + </article> + """ + xml_tree = etree.fromstring(xml) + validator = FundingGroupValidation(xml_tree, self.params) + results = list(validator.validate_award_id_funding_source_consistency()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "WARNING") + self.assertIn("Inconsistent quantities", results[0]["advice"]) + + +class TestCompleteValidExamples(TestFundingValidationBase): + """Test complete valid XML examples from the issue""" + + def test_example_1_funding_with_contract(self): + """Example 1: Funding with contract number""" + xml = """ + <article article-type="research-article" xml:lang="en"> + <front> + <article-meta> + <funding-group> + <award-group> + <funding-source>Fundação de Amparo à Pesquisa do Estado de São Paulo (FAPESP)</funding-source> + <award-id>04/08142-0</award-id> + </award-group> + <funding-statement>This study was supported by Fundação de Amparo à Pesquisa do Estado de São Paulo (FAPESP - Grant no. 04/08142-0; São Paulo, Brazil)</funding-statement> + </funding-group> + </article-meta> + </front> + </article> + """ + xml_tree = etree.fromstring(xml) + validator = FundingGroupValidation(xml_tree, self.params) + + # All validations should pass + uniqueness = list(validator.validate_funding_group_uniqueness()) + statement = list(validator.validate_funding_statement_presence()) + source = list(validator.validate_funding_source_in_award_group()) + label = list(validator.validate_label_absence()) + title = list(validator.validate_title_absence()) + consistency = list(validator.validate_award_id_funding_source_consistency()) + + self.assertEqual(uniqueness[0]["response"], "OK") + self.assertEqual(statement[0]["response"], "OK") + self.assertEqual(source[0]["response"], "OK") + self.assertEqual(label[0]["response"], "OK") + self.assertEqual(title[0]["response"], "OK") + self.assertEqual(consistency[0]["response"], "OK") + + def test_example_6_negative_funding_declaration(self): + """Example 6: Negative funding declaration""" + xml = """ + <article article-type="research-article" xml:lang="en"> + <front> + <article-meta> + <funding-group> + <funding-statement>Não houve financiamento para esta publicação</funding-statement> + </funding-group> + </article-meta> + </front> + </article> + """ + xml_tree = etree.fromstring(xml) + validator = FundingGroupValidation(xml_tree, self.params) + + # Should pass all checks (no award-group means no source validation) + uniqueness = list(validator.validate_funding_group_uniqueness()) + statement = list(validator.validate_funding_statement_presence()) + source = list(validator.validate_funding_source_in_award_group()) + label = list(validator.validate_label_absence()) + title = list(validator.validate_title_absence()) + + self.assertEqual(uniqueness[0]["response"], "OK") + self.assertEqual(statement[0]["response"], "OK") + self.assertEqual(len(source), 0) # No award-group, so no validation + self.assertEqual(label[0]["response"], "OK") + self.assertEqual(title[0]["response"], "OK") + + if __name__ == "__main__": unittest.main()