diff --git a/.gitignore b/.gitignore index e330edc72..cb5191afc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ nosetests.xml .venv .idea +src/ diff --git a/packtools/sps/validation/front_articlemeta_issue.py b/packtools/sps/validation/front_articlemeta_issue.py index 19962e40f..165057e4f 100644 --- a/packtools/sps/validation/front_articlemeta_issue.py +++ b/packtools/sps/validation/front_articlemeta_issue.py @@ -1,5 +1,6 @@ from packtools.sps.models.front_articlemeta_issue import ArticleMetaIssue from packtools.sps.validation.utils import build_response +import re def is_valid_value(value, zero_is_allowed): @@ -242,6 +243,244 @@ def validate_expected_issues(self): error_level=self.params["expected_issues_error_level"], ) + def validate_issue_element_uniqueness(self): + """ + Validates that element appears at most once in . + According to SPS 1.10, only one element is allowed. + + Returns: + dict: Validation response with results + """ + issue_elements = self.xml_tree.findall(".//front/article-meta/issue") + count = len(issue_elements) + is_valid = count <= 1 + + return build_response( + title="issue element uniqueness", + parent={"parent": "article"}, + item="issue", + sub_item=None, + validation_type="unique", + is_valid=is_valid, + expected="at most one element in ", + obtained=f"{count} element(s) found", + advice=f"Remove duplicate elements from . Found {count} elements, expected at most 1.", + data={"issue_count": count, "issue_values": [elem.text for elem in issue_elements]}, + error_level=self.params.get("issue_element_uniqueness_error_level", "ERROR"), + ) + + def validate_issue_no_punctuation(self): + """ + Validates that value does not contain punctuation marks. + According to SPS 1.10, punctuation like . , - / : ; are not allowed. + + Returns: + dict: Validation response with results + """ + if not self.article_issue.issue: + return None + + issue_value = self.article_issue.issue + # Check for common punctuation marks + punctuation_marks = ['.', ',', '-', '/', ':', ';', '!', '?', '(', ')', '[', ']', '{', '}', '"', "'"] + found_punctuation = [p for p in punctuation_marks if p in issue_value] + is_valid = len(found_punctuation) == 0 + + return build_response( + title="issue value without punctuation", + parent={"parent": "article"}, + item="issue", + sub_item=None, + validation_type="format", + is_valid=is_valid, + expected="issue value without punctuation marks", + obtained=issue_value, + advice=f"Remove punctuation marks {found_punctuation} from value '{issue_value}'", + data={"issue": issue_value, "punctuation_found": found_punctuation}, + error_level=self.params.get("issue_no_punctuation_error_level", "ERROR"), + ) + + def validate_issue_no_uppercase(self): + """ + Validates that value does not contain uppercase letters. + According to SPS 1.10, all letters must be lowercase. + + Returns: + dict: Validation response with results + """ + if not self.article_issue.issue: + return None + + issue_value = self.article_issue.issue + has_uppercase = any(c.isupper() for c in issue_value) + is_valid = not has_uppercase + + return build_response( + title="issue value without uppercase", + parent={"parent": "article"}, + item="issue", + sub_item=None, + validation_type="format", + is_valid=is_valid, + expected="issue value in lowercase only", + obtained=issue_value, + advice=f"Convert uppercase letters to lowercase in value '{issue_value}'. Expected: '{issue_value.lower()}'", + data={"issue": issue_value, "expected": issue_value.lower()}, + error_level=self.params.get("issue_no_uppercase_error_level", "ERROR"), + ) + + def validate_issue_supplement_nomenclature(self): + """ + Validates that supplement uses correct nomenclature 'suppl'. + According to SPS 1.10, must use 'suppl' not 'supl', 'supplement', 'sup'. + + Returns: + dict: Validation response with results + """ + if not self.article_issue.issue: + return None + + issue_value = self.article_issue.issue + issue_lower = issue_value.lower() + + # Check if issue contains supplement-related terms + if "sup" not in issue_lower: + return None + + # Check for invalid supplement nomenclatures using regex + invalid_patterns = [] + + # Check for specific invalid patterns + if re.search(r'\bsupl\b', issue_lower): + invalid_patterns.append('supl') + if re.search(r'\bsupplement\b', issue_lower): + invalid_patterns.append('supplement') + if re.search(r'\bsup\b', issue_lower): + invalid_patterns.append('sup') + + is_valid = len(invalid_patterns) == 0 + + return build_response( + title="issue supplement nomenclature", + parent={"parent": "article"}, + item="issue", + sub_item="supplement nomenclature", + validation_type="format", + is_valid=is_valid, + expected="supplement nomenclature as 'suppl'", + obtained=issue_value, + advice=f"Use 'suppl' for supplement nomenclature in value '{issue_value}'. Invalid terms found: {invalid_patterns}", + data={"issue": issue_value, "invalid_terms": invalid_patterns}, + error_level=self.params.get("issue_supplement_nomenclature_error_level", "ERROR"), + ) + + def validate_issue_special_nomenclature(self): + """ + Validates that special issues use correct nomenclature 'spe'. + According to SPS 1.10, must use 'spe' not 'esp', 'nesp', 'nspe', 'especial', 'noesp'. + + Returns: + dict: Validation response with results + """ + if not self.article_issue.issue: + return None + + issue_value = self.article_issue.issue + issue_lower = issue_value.lower() + + # Check if issue contains special issue indicators + special_indicators = ['esp', 'especial', 'nesp', 'nspe', 'noesp'] + found_invalid = [] + + for indicator in special_indicators: + if indicator in issue_lower: + found_invalid.append(indicator) + + # If no special issue indicators found, check if 'spe' is present + if not found_invalid and 'spe' not in issue_lower: + return None + + is_valid = len(found_invalid) == 0 + + return build_response( + title="issue special nomenclature", + parent={"parent": "article"}, + item="issue", + sub_item="special issue nomenclature", + validation_type="format", + is_valid=is_valid, + expected="special issue nomenclature as 'spe'", + obtained=issue_value, + advice=f"Use 'spe' for special issue nomenclature in value '{issue_value}'. Invalid terms found: {found_invalid}", + data={"issue": issue_value, "invalid_terms": found_invalid}, + error_level=self.params.get("issue_special_nomenclature_error_level", "ERROR"), + ) + + def validate_no_supplement_element(self): + """ + Validates that element does not exist in . + According to SPS 1.10, is not allowed in . + Supplements should be identified in element instead. + + Returns: + dict: Validation response with results + """ + supplement_elements = self.xml_tree.findall(".//front/article-meta/supplement") + count = len(supplement_elements) + is_valid = count == 0 + + return build_response( + title="supplement element not allowed", + parent={"parent": "article"}, + item="supplement", + sub_item=None, + validation_type="unexpected", + is_valid=is_valid, + expected="no element in ", + obtained=f"{count} element(s) found", + advice="Remove element(s) from . Use element to indicate supplements (e.g., '4 suppl 1').", + data={"supplement_count": count, "supplement_values": [elem.text for elem in supplement_elements]}, + error_level=self.params.get("no_supplement_element_error_level", "CRITICAL"), + ) + + def validate_issue_no_leading_zeros(self): + """ + Validates that numeric parts of do not have leading zeros. + According to SPS 1.10, should use '4' not '04'. + + Returns: + dict: Validation response with results + """ + if not self.article_issue.issue: + return None + + issue_value = self.article_issue.issue + parts = issue_value.split() + + # Check each numeric part for leading zeros + issues_found = [] + for part in parts: + # Check if part is numeric and has leading zero + if part.isdigit() and len(part) > 1 and part[0] == '0': + issues_found.append(part) + + is_valid = len(issues_found) == 0 + expected_value = ' '.join([(part.lstrip('0') or '0') if part.isdigit() else part for part in parts]) + + return build_response( + title="issue value without leading zeros", + parent={"parent": "article"}, + item="issue", + sub_item=None, + validation_type="format", + is_valid=is_valid, + expected="numeric values without leading zeros", + obtained=issue_value, + advice=f"Remove leading zeros from numeric parts in value '{issue_value}'. Expected: '{expected_value}'", + data={"issue": issue_value, "parts_with_leading_zeros": issues_found, "expected": expected_value}, + error_level=self.params.get("issue_no_leading_zeros_error_level", "WARNING"), + ) + def validate(self): """ Performs all validation checks for the issue. @@ -255,7 +494,15 @@ def validate(self): yield self.validate_number_format() yield self.validate_supplement_format() yield self.validate_issue_format() - yield self.validate_expected_issues() + yield self.validate_expected_issues() + # New SPS 1.10 validations for element + yield self.validate_issue_element_uniqueness() + yield self.validate_issue_no_punctuation() + yield self.validate_issue_no_uppercase() + yield self.validate_issue_supplement_nomenclature() + yield self.validate_issue_special_nomenclature() + yield self.validate_no_supplement_element() + yield self.validate_issue_no_leading_zeros() class PaginationValidation: diff --git a/tests/sps/validation/test_front_articlemeta_issue.py b/tests/sps/validation/test_front_articlemeta_issue.py index bdfc07c13..fc141d935 100644 --- a/tests/sps/validation/test_front_articlemeta_issue.py +++ b/tests/sps/validation/test_front_articlemeta_issue.py @@ -943,3 +943,627 @@ def test_validation_pages_and_e_location_exists_fail(self): self.assertEqual(obtained["response"], "OK") self.assertEqual(obtained["message"], "Got elocation-id: e51467, fpage: 220, lpage: 240, expected elocation-id or fpage + lpage") self.assertIsNone(obtained["advice"]) + + +class IssueElementUniquenessTest(TestCase): + """Tests for Rule 1: Validate uniqueness of element""" + + def setUp(self): + self.params = { + "issue_element_uniqueness_error_level": "ERROR", + } + + def test_single_issue_element_valid(self): + """Test with exactly one element - should pass""" + xml = """ +
+ + + 10 + 4 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_element_uniqueness() + + self.assertEqual(obtained["response"], "OK") + self.assertEqual(obtained["title"], "issue element uniqueness") + self.assertIsNone(obtained["advice"]) + + def test_no_issue_element_valid(self): + """Test with no element - should pass (0 is <= 1)""" + xml = """ +
+ + + 10 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_element_uniqueness() + + self.assertEqual(obtained["response"], "OK") + + def test_multiple_issue_elements_invalid(self): + """Test with multiple elements - should fail""" + xml = """ +
+ + + 10 + 4 + 5 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_element_uniqueness() + + self.assertEqual(obtained["response"], "ERROR") + self.assertEqual(obtained["title"], "issue element uniqueness") + self.assertIn("Remove duplicate", obtained["advice"]) + self.assertEqual(obtained["data"]["issue_count"], 2) + + +class IssueNoPunctuationTest(TestCase): + """Tests for Rule 2: Validate no punctuation in value""" + + def setUp(self): + self.params = { + "issue_no_punctuation_error_level": "ERROR", + } + + def test_issue_without_punctuation_valid(self): + """Test with valid issue without punctuation""" + xml = """ +
+ + + 4 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_punctuation() + + self.assertEqual(obtained["response"], "OK") + self.assertEqual(obtained["title"], "issue value without punctuation") + + def test_issue_with_suppl_no_punctuation_valid(self): + """Test with supplement format without punctuation""" + xml = """ +
+ + + 4 suppl 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_punctuation() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_with_period_invalid(self): + """Test with period in issue value - should fail""" + xml = """ +
+ + + 4.5 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_punctuation() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn(".", obtained["data"]["punctuation_found"]) + + def test_issue_with_hyphen_invalid(self): + """Test with hyphen in issue value - should fail""" + xml = """ +
+ + + 4-5 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_punctuation() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn("-", obtained["data"]["punctuation_found"]) + + def test_issue_with_slash_invalid(self): + """Test with slash in issue value - should fail""" + xml = """ +
+ + + 4/5 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_punctuation() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn("/", obtained["data"]["punctuation_found"]) + + +class IssueNoUppercaseTest(TestCase): + """Tests for Rule 3: Validate no uppercase in value""" + + def setUp(self): + self.params = { + "issue_no_uppercase_error_level": "ERROR", + } + + def test_issue_lowercase_valid(self): + """Test with all lowercase - should pass""" + xml = """ +
+ + + 4 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_uppercase() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_spe_lowercase_valid(self): + """Test with 'spe' in lowercase - should pass""" + xml = """ +
+ + + spe1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_uppercase() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_suppl_lowercase_valid(self): + """Test with 'suppl' in lowercase - should pass""" + xml = """ +
+ + + 4 suppl 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_uppercase() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_with_uppercase_invalid(self): + """Test with uppercase 'Suppl' - should fail""" + xml = """ +
+ + + 4 Suppl 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_uppercase() + + self.assertEqual(obtained["response"], "ERROR") + self.assertEqual(obtained["data"]["expected"], "4 suppl 1") + + def test_issue_spe_with_uppercase_invalid(self): + """Test with uppercase 'SPE' - should fail""" + xml = """ +
+ + + SPE1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_uppercase() + + self.assertEqual(obtained["response"], "ERROR") + self.assertEqual(obtained["data"]["expected"], "spe1") + + +class IssueSupplementNomenclatureTest(TestCase): + """Tests for Rule 4: Validate supplement nomenclature""" + + def setUp(self): + self.params = { + "issue_supplement_nomenclature_error_level": "ERROR", + } + + def test_issue_suppl_valid(self): + """Test with correct 'suppl' nomenclature - should pass""" + xml = """ +
+ + + 4 suppl 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_supplement_nomenclature() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_suppl_only_valid(self): + """Test with 'suppl' only - should pass""" + xml = """ +
+ + + suppl 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_supplement_nomenclature() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_supl_invalid(self): + """Test with 'supl' instead of 'suppl' - should fail""" + xml = """ +
+ + + 4 supl 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_supplement_nomenclature() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn("supl", obtained["data"]["invalid_terms"]) + + def test_issue_supplement_invalid(self): + """Test with 'supplement' instead of 'suppl' - should fail""" + xml = """ +
+ + + 4 supplement 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_supplement_nomenclature() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn("supplement", obtained["data"]["invalid_terms"]) + + def test_issue_sup_invalid(self): + """Test with 'sup' instead of 'suppl' - should fail""" + xml = """ +
+ + + 4 sup 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_supplement_nomenclature() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn("sup", obtained["data"]["invalid_terms"]) + + +class IssueSpecialNomenclatureTest(TestCase): + """Tests for Rule 5: Validate special issue nomenclature""" + + def setUp(self): + self.params = { + "issue_special_nomenclature_error_level": "ERROR", + } + + def test_issue_spe_valid(self): + """Test with correct 'spe' nomenclature - should pass""" + xml = """ +
+ + + spe1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_special_nomenclature() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_spe_with_number_valid(self): + """Test with 'spe' and number - should pass""" + xml = """ +
+ + + 4 spe 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_special_nomenclature() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_esp_invalid(self): + """Test with 'esp' instead of 'spe' - should fail""" + xml = """ +
+ + + esp1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_special_nomenclature() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn("esp", obtained["data"]["invalid_terms"]) + + def test_issue_nesp_invalid(self): + """Test with 'nesp' instead of 'spe' - should fail""" + xml = """ +
+ + + nesp1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_special_nomenclature() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn("nesp", obtained["data"]["invalid_terms"]) + + def test_issue_especial_invalid(self): + """Test with 'especial' instead of 'spe' - should fail""" + xml = """ +
+ + + especial + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_special_nomenclature() + + self.assertEqual(obtained["response"], "ERROR") + self.assertIn("especial", obtained["data"]["invalid_terms"]) + + +class NoSupplementElementTest(TestCase): + """Tests for Rule 6: Validate absence of element""" + + def setUp(self): + self.params = { + "no_supplement_element_error_level": "CRITICAL", + } + + def test_no_supplement_element_valid(self): + """Test without element - should pass""" + xml = """ +
+ + + 10 + 4 suppl 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_no_supplement_element() + + self.assertEqual(obtained["response"], "OK") + self.assertEqual(obtained["title"], "supplement element not allowed") + + def test_with_supplement_element_invalid(self): + """Test with element - should fail""" + xml = """ +
+ + + 10 + 4 + 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_no_supplement_element() + + self.assertEqual(obtained["response"], "CRITICAL") + self.assertIn("Remove ", obtained["advice"]) + self.assertEqual(obtained["data"]["supplement_count"], 1) + + def test_with_multiple_supplement_elements_invalid(self): + """Test with multiple elements - should fail""" + xml = """ +
+ + + 10 + 4 + 1 + 2 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_no_supplement_element() + + self.assertEqual(obtained["response"], "CRITICAL") + self.assertEqual(obtained["data"]["supplement_count"], 2) + + +class IssueNoLeadingZerosTest(TestCase): + """Tests for Rule 7: Validate no leading zeros""" + + def setUp(self): + self.params = { + "issue_no_leading_zeros_error_level": "WARNING", + } + + def test_issue_without_leading_zeros_valid(self): + """Test with issue without leading zeros - should pass""" + xml = """ +
+ + + 4 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_leading_zeros() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_suppl_without_leading_zeros_valid(self): + """Test with supplement without leading zeros - should pass""" + xml = """ +
+ + + 4 suppl 1 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_leading_zeros() + + self.assertEqual(obtained["response"], "OK") + + def test_issue_with_leading_zero_invalid(self): + """Test with leading zero in issue - should fail""" + xml = """ +
+ + + 04 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_leading_zeros() + + self.assertEqual(obtained["response"], "WARNING") + self.assertIn("04", obtained["data"]["parts_with_leading_zeros"]) + self.assertEqual(obtained["data"]["expected"], "4") + + def test_issue_suppl_with_leading_zero_invalid(self): + """Test with leading zero in supplement number - should fail""" + xml = """ +
+ + + 4 suppl 01 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_leading_zeros() + + self.assertEqual(obtained["response"], "WARNING") + self.assertIn("01", obtained["data"]["parts_with_leading_zeros"]) + self.assertEqual(obtained["data"]["expected"], "4 suppl 1") + + def test_issue_zero_alone_valid(self): + """Test with single zero (0) - should pass as it's not a leading zero""" + xml = """ +
+ + + 0 + + +
+ """ + xml_tree = etree.fromstring(xml) + validator = IssueValidation(xml_tree, params=self.params) + obtained = validator.validate_issue_no_leading_zeros() + + self.assertEqual(obtained["response"], "OK")