diff --git a/packtools/sps/locale/es/LC_MESSAGES/packtools_sps.po b/packtools/sps/locale/es/LC_MESSAGES/packtools_sps.po index c84a1d9e9..0bfcf6b7a 100644 --- a/packtools/sps/locale/es/LC_MESSAGES/packtools_sps.po +++ b/packtools/sps/locale/es/LC_MESSAGES/packtools_sps.po @@ -763,6 +763,16 @@ msgstr "Agregue c msgid "Add with " msgstr "Agregue con " +#: packtools/sps/validation/dates.py +#, python-brace-format +msgid " ({pub_date}) must not be later than {limit}" +msgstr " ({pub_date}) no debe ser posterior a {limit}" + +#: packtools/sps/validation/dates.py +#, python-brace-format +msgid " ({pub_date}) must not be more than {tolerance_months} months before year ({collection_year})" +msgstr " ({pub_date}) no debe ser más de {tolerance_months} meses anterior al año de ({collection_year})" + #: packtools/sps/validation/dates.py:411 #, python-brace-format msgid "Set @publication-format=\"electronic\" in " diff --git a/packtools/sps/locale/pt_BR/LC_MESSAGES/packtools_sps.po b/packtools/sps/locale/pt_BR/LC_MESSAGES/packtools_sps.po index b802bd091..c0867d7e1 100644 --- a/packtools/sps/locale/pt_BR/LC_MESSAGES/packtools_sps.po +++ b/packtools/sps/locale/pt_BR/LC_MESSAGES/packtools_sps.po @@ -763,6 +763,16 @@ msgstr "Adicione msgid "Add with " msgstr "Adicione com " +#: packtools/sps/validation/dates.py +#, python-brace-format +msgid " ({pub_date}) must not be later than {limit}" +msgstr " ({pub_date}) não deve ser posterior a {limit}" + +#: packtools/sps/validation/dates.py +#, python-brace-format +msgid " ({pub_date}) must not be more than {tolerance_months} months before year ({collection_year})" +msgstr " ({pub_date}) não deve ser mais de {tolerance_months} meses anterior ao ano de ({collection_year})" + #: packtools/sps/validation/dates.py:411 #, python-brace-format msgid "Set @publication-format=\"electronic\" in " diff --git a/packtools/sps/validation/dates.py b/packtools/sps/validation/dates.py index a0ddc438b..0cddd34d8 100644 --- a/packtools/sps/validation/dates.py +++ b/packtools/sps/validation/dates.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, timedelta from packtools.sps.models.dates import FulltextDates from packtools.sps.validation.utils import build_response, get_future_date @@ -284,6 +284,12 @@ def _get_default_params(self): "pub_date_uniqueness_error_level": "ERROR", "day_value_error_level": "ERROR", "month_value_error_level": "ERROR", + "pub_date_future_error_level": "CRITICAL", + "pub_date_past_collection_error_level": "CRITICAL", + # pub-date sanity tolerances (issue #1268) + "pub_date_future_tolerance_days": 0, + "pub_date_past_collection_tolerance_months": 12, + "today": None, # Event lists — alinhados com article_dates_rules.json "required_events": ["received", "accepted"], "pre_pub_ordered_events": [ @@ -329,6 +335,8 @@ def validate(self): yield from self.validate_pub_date_collection_required_year() yield from self.validate_pub_date_collection_no_day() yield from self.validate_day_month_values() + yield from self.validate_pub_date_not_in_future() + yield from self.validate_pub_date_not_too_far_before_collection() yield from self.validate_article_date() yield from self.validate_collection_date() yield from self.validate_history_dates() @@ -553,6 +561,85 @@ def validate_day_month_values(self): error_level=self.params["month_value_error_level"], ) + def validate_pub_date_not_in_future(self): + """Rule 9: Validate that pub-date[@date-type='pub'] is not later than + today + tolerance (days). Catches typos such as year 2029 instead of + 2026, which OPAC silently hides from public access (issue #1268). + Only applies to main article (not sub-articles). + """ + if self.fulltext.tag != "article": + return + epub_date_model = self.fulltext.epub_date_model + pub_date = epub_date_model and epub_date_model.date + if not pub_date: + return + + tolerance_days = self.params["pub_date_future_tolerance_days"] + today = self.params.get("today") or date.today() + limit = today + timedelta(days=tolerance_days) + is_valid = pub_date <= limit + + yield build_response( + title="pub-date pub not in future", + parent=self.params["parent"], + item="pub-date", + sub_item="pub", + validation_type="value", + is_valid=is_valid, + expected=f' no later than {limit.isoformat()}', + obtained=pub_date.isoformat(), + advice=f' ({pub_date.isoformat()}) must not be later than {limit.isoformat()}', + advice_text=i18n._(' ({pub_date}) must not be later than {limit}'), + advice_params={ + "pub_date": pub_date.isoformat(), + "limit": limit.isoformat(), + }, + data=self.fulltext.epub_date, + error_level=self.params["pub_date_future_error_level"], + ) + + def validate_pub_date_not_too_far_before_collection(self): + """Rule 10: Validate that pub-date[@date-type='pub'] is not more than + N months before the collection year (issue #1268, regra 2). + Only applies to main article (not sub-articles). + """ + if self.fulltext.tag != "article": + return + epub_date_model = self.fulltext.epub_date_model + pub_date = epub_date_model and epub_date_model.date + collection_date = self.fulltext.collection_date + collection_year = collection_date and collection_date.get("year") + if not pub_date or not collection_year: + return + try: + collection_start = date(int(collection_year), 1, 1) + except (ValueError, TypeError): + return + + tolerance_months = self.params["pub_date_past_collection_tolerance_months"] + earliest_allowed = collection_start - timedelta(days=30 * tolerance_months) + is_valid = pub_date >= earliest_allowed + + yield build_response( + title="pub-date pub not too far before collection", + parent=self.params["parent"], + item="pub-date", + sub_item="pub", + validation_type="value", + is_valid=is_valid, + expected=f' no earlier than {earliest_allowed.isoformat()} ({tolerance_months} months before collection year {collection_year})', + obtained=pub_date.isoformat(), + advice=f' ({pub_date.isoformat()}) must not be more than {tolerance_months} months before year ({collection_year})', + advice_text=i18n._(' ({pub_date}) must not be more than {tolerance_months} months before year ({collection_year})'), + advice_params={ + "pub_date": pub_date.isoformat(), + "tolerance_months": tolerance_months, + "collection_year": collection_year, + }, + data=self.fulltext.epub_date, + error_level=self.params["pub_date_past_collection_error_level"], + ) + def validate_article_date(self): """Validate the main article date.""" if article_date := self.fulltext.article_date: diff --git a/packtools/sps/validation_rules/article_dates_rules.json b/packtools/sps/validation_rules/article_dates_rules.json index 9f882345c..80451dbe3 100644 --- a/packtools/sps/validation_rules/article_dates_rules.json +++ b/packtools/sps/validation_rules/article_dates_rules.json @@ -19,6 +19,10 @@ "pub_date_uniqueness_error_level":"ERROR", "day_value_error_level":"ERROR", "month_value_error_level":"ERROR", + "pub_date_future_error_level":"CRITICAL", + "pub_date_past_collection_error_level":"CRITICAL", + "pub_date_future_tolerance_days":0, + "pub_date_past_collection_tolerance_months":12, "required_events":[ "received", "accepted" diff --git a/tests/sps/validation/test_dates.py b/tests/sps/validation/test_dates.py index dfd890e26..e4994a5f0 100644 --- a/tests/sps/validation/test_dates.py +++ b/tests/sps/validation/test_dates.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, timedelta from unittest import TestCase from unittest.mock import Mock, patch @@ -1452,3 +1452,152 @@ def test_missing_only_absent_events(self): "'accepted' deve constar em missing_events") self.assertNotIn("received", validator.missing_events, "'received' está presente no histórico e não deve aparecer em missing_events") + + +class TestPubDateFutureAndCollectionDistanceValidation(TestCase): + """Testes para as regras 9 e 10 (issue #1268): + + - pub-date[@date-type="pub"] não pode estar no futuro além de uma + tolerância em dias (regra 1 da issue). Reproduz o bug real: o artigo + 0102-6720-abcd-39-e1948 teve pub-date pub=2029 (digitado por engano + no lugar de 2026) e ficou oculto na produção sem gerar erro. + - pub-date pub não pode ser mais de N meses anterior ao ano de + pub-date[@date-type="collection"] (regra 2 da issue). + - Coleções retrospectivas (pub muito posterior ao collection, mas não + no futuro) continuam permitidas (regra 3 da issue) — testado como + guarda de regressão, já que não há checagem que bloqueie esse caso. + + O parâmetro "today" é injetado nos params para tornar os testes + determinísticos, sem depender do relógio real da máquina. + """ + + TODAY = date(2026, 6, 15) + + def _make_params(self, **overrides): + params = { + "parent": {"parent": "article"}, + "required_events": [], + "pre_pub_ordered_events": [ + "preprint", "received", "rev-request", "rev-recd", "revised", "accepted" + ], + "pos_pub_ordered_events": ["pub", "resubmitted", "corrected", "retracted"], + "required_history_events_for_article_type": {}, + "required_history_events_for_related_article_type": {}, + "today": self.TODAY, + } + params.update(overrides) + return params + + def _article_xml(self, pub_date, collection_year=None): + collection_block = "" + if collection_year is not None: + collection_block = f""" + + {collection_year} + """ + return f""" +
+ + + + {pub_date.day:02d}{pub_date.month:02d}{pub_date.year} + {collection_block} + + +
+ """ + + def _results(self, pub_date, collection_year=None, **param_overrides): + tree = etree.fromstring(self._article_xml(pub_date, collection_year)) + validator = FulltextDatesValidation(tree, self._make_params(**param_overrides)) + results = list(validator.validate()) + future = [r for r in results if r["title"] == "pub-date pub not in future"] + distance = [r for r in results if r["title"] == "pub-date pub not too far before collection"] + return future, distance + + # Regra 1: pub não pode estar no futuro ----------------------------- + + def test_pub_equal_today_is_ok(self): + future, _ = self._results(self.TODAY, collection_year=self.TODAY.year) + self.assertEqual(1, len(future)) + self.assertEqual("OK", future[0]["response"]) + + def test_pub_within_future_tolerance_is_ok(self): + pub = self.TODAY + timedelta(days=5) + future, _ = self._results( + pub, collection_year=self.TODAY.year, pub_date_future_tolerance_days=5 + ) + self.assertEqual("OK", future[0]["response"]) + + def test_pub_far_in_future_is_error(self): + """Reproduz o bug real da issue: pub 3 anos à frente (2029 vs 2026).""" + pub = date(self.TODAY.year + 3, 1, 1) + future, _ = self._results(pub, collection_year=self.TODAY.year) + self.assertEqual("CRITICAL", future[0]["response"]) + self.assertIn("must not be later than", future[0]["advice"]) + + # Regra 4: pub == collection ----------------------------------------- + + def test_pub_equal_collection_year_is_ok(self): + pub = date(self.TODAY.year, 3, 1) + future, distance = self._results(pub, collection_year=self.TODAY.year) + self.assertEqual("OK", future[0]["response"]) + self.assertEqual("OK", distance[0]["response"]) + + # Regra 2: pub não pode ser muito anterior ao collection ------------- + + def test_pub_up_to_12_months_before_collection_is_ok(self): + collection_year = self.TODAY.year + pub = date(collection_year - 1, 2, 1) # dentro da tolerância de 12 meses + _, distance = self._results(pub, collection_year=collection_year) + self.assertEqual("OK", distance[0]["response"]) + + def test_pub_more_than_12_months_before_collection_is_error(self): + collection_year = self.TODAY.year + pub = date(collection_year - 2, 1, 1) # bem além da tolerância de 12 meses + _, distance = self._results(pub, collection_year=collection_year) + self.assertEqual("CRITICAL", distance[0]["response"]) + self.assertIn("must not be more than", distance[0]["advice"]) + + # Regra 3: coleção retrospectiva (pub muito posterior ao collection) - + + def test_pub_many_years_after_collection_but_not_future_is_ok(self): + """Coleção retrospectiva: pub muito posterior ao collection, mas <= hoje.""" + collection_year = self.TODAY.year - 100 + future, distance = self._results(self.TODAY, collection_year=collection_year) + self.assertEqual("OK", future[0]["response"]) + self.assertEqual("OK", distance[0]["response"]) + + def test_pub_many_years_after_collection_and_in_future_is_error(self): + """Mesmo em coleção retrospectiva, pub não pode estar no futuro.""" + collection_year = self.TODAY.year - 100 + pub = date(self.TODAY.year + 3, 1, 1) + future, distance = self._results(pub, collection_year=collection_year) + self.assertEqual("CRITICAL", future[0]["response"]) + # A distância para trás não é violada (pub é muito posterior ao collection) + self.assertEqual("OK", distance[0]["response"]) + + # Casos sem collection ------------------------------------------------- + + def test_no_collection_date_skips_distance_rule(self): + _, distance = self._results(self.TODAY, collection_year=None) + self.assertEqual([], distance) + + def test_no_pub_date_skips_both_rules(self): + tree = etree.fromstring(""" +
+ + + + 2026 + + + +
+ """) + validator = FulltextDatesValidation(tree, self._make_params()) + results = list(validator.validate()) + future = [r for r in results if r["title"] == "pub-date pub not in future"] + distance = [r for r in results if r["title"] == "pub-date pub not too far before collection"] + self.assertEqual([], future) + self.assertEqual([], distance)