Repository: datosgobar/series-tiempo-ar

Issue:
Implementar parámetros de validación modificables desde el admin de Django
Implica poder elegir los parámetros 

MINIMUM_VALUES = 2
MAX_MISSING_PROPORTION = 0.999
MAX_TOO_SMALL_PROPORTION = 0.02
MIN_TEMPORAL_FRACTION = 10
MAX_FIELD_TITLE_LEN = 60
MAX_NULL_SERIES_PROPORTION = 0.20

desde el admin de Django, en lugar de usar los valores hardcodeados en el código de series-tiempo-ar. Esta última librería debería mantenerlos como defaults que se overridean por pasar un JSON de configuración opcional que genere la aplicación de la API cuando la usa.

Discussion:

Patch:
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 4299463..6616c83 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -3,3 +3,4 @@ pylint
 black
 pyyaml
 cryptography
+regex!=2019.12.9
diff --git a/series_tiempo_ar/validations/csv_validations.py b/series_tiempo_ar/validations/csv_validations.py
index 384d797..09ba6e0 100644
--- a/series_tiempo_ar/validations/csv_validations.py
+++ b/series_tiempo_ar/validations/csv_validations.py
@@ -7,12 +7,39 @@ from pandas.core.dtypes.common import is_numeric_dtype
 from series_tiempo_ar import custom_exceptions as ce
 from series_tiempo_ar.helpers import freq_iso_to_pandas
 
-MINIMUM_VALUES = 2
-MAX_MISSING_PROPORTION = 0.999
-MAX_TOO_SMALL_PROPORTION = 0.02
-MIN_TEMPORAL_FRACTION = 10
-MAX_FIELD_TITLE_LEN = 60
-MAX_NULL_SERIES_PROPORTION = 0.20
+DEFAULT_OPTIONS = {
+    "minimum_values": 2,
+    "max_missing_proportion": 0.999,
+    "max_too_small_proportion": 0.02,
+    "min_temporal_fraction": 10,
+    "max_field_title_len": 60,
+    "max_null_series_proportion": 0.2,
+}
+
+
+class ValidationOptions:
+    def __init__(
+        self,
+        minimum_values,
+        max_missing_proportion,
+        max_too_small_proportion,
+        min_temporal_fraction,
+        max_field_title_len,
+        max_null_series_proportion,
+    ):
+        self.minimum_values = minimum_values
+        self.max_missing_proportion = max_missing_proportion
+        self.max_too_small_proportion = max_too_small_proportion
+        self.min_temporal_fraction = min_temporal_fraction
+        self.max_field_title_len = max_field_title_len
+        self.max_null_series_proportion = max_null_series_proportion
+
+    @classmethod
+    def create_with_defaults(cls, **kwargs):
+        options = DEFAULT_OPTIONS.copy()
+
+        options.update(kwargs)
+        return cls(**options)
 
 
 def _assert_repeated_value(field_name, field_values, exception):
@@ -22,190 +49,239 @@ def _assert_repeated_value(field_name, field_values, exception):
         raise exception(field_dups)
 
 
-def validate_field_few_values(df, _distrib_meta, _catalog):
-    # La mayoría de las series de una distrib. deben tener un mínimo de valores
-    series_too_small = 0
-    series_total = len(df.columns)
+class BaseValidation:
+    def __init__(self, df=None, distrib_meta=None, catalog=None, options=None):
+        if options is None:
+            options = ValidationOptions(**DEFAULT_OPTIONS)
 
-    for field in df.columns:
-        not_null_values = len(df[field][df[field].notnull()])
+        self.df = df
+        self.distrib_meta = distrib_meta
+        self.catalog = catalog
+        self.options = options
 
-        # se suma una nueva serie demasiado corta (si está vacía no la cuento)
-        if not_null_values > 0 and not not_null_values >= MINIMUM_VALUES:
-            series_too_small += 1
+    def validate(self):
+        raise NotImplementedError
 
-        # chequea si hay demasiadas series cortas
-        if float(series_too_small) / series_total > MAX_TOO_SMALL_PROPORTION:
-            raise ce.FieldFewValuesError(field, not_null_values, MINIMUM_VALUES)
 
+class FieldViewValuesValidation(BaseValidation):
+    def validate(self):
+        # La mayoría de las series de una distrib. deben tener un mínimo de valores
+        series_too_small = 0
+        series_total = len(self.df.columns)
 
-def validate_distribution_null_series_amount(df, distrib_meta, _catalog):
-    distribution_identifier = distrib_meta["identifier"]
-    series_total = len(df.columns)
-    null_series_amount = 0
+        for field in self.df.columns:
+            not_null_values = len(self.df[field][self.df[field].notnull()])
 
-    for field in df.columns:
-        not_null_values = len(df[field][df[field].notnull()])
-        if not_null_values == 0:
-            null_series_amount += 1
+            # se suma una nueva serie demasiado corta (si está vacía no la cuento)
+            if 0 < not_null_values < self.options.minimum_values:
+                series_too_small += 1
 
-    null_proportion = float(null_series_amount) / series_total
-    if null_proportion >= MAX_NULL_SERIES_PROPORTION:
-        raise ce.DistributionTooManyNullSeriesError(
-            distribution_identifier, MAX_NULL_SERIES_PROPORTION, null_proportion
-        )
-
-
-def validate_field_title(df, _distrib_meta, _catalog):
-    # Los titulos de los campos deben tener caracteres ASCII + "_"
-    valid_field_chars = "abcdefghijklmnopqrstuvwxyz0123456789_"
-    for field in df.columns:
-        if "unnamed" in field.lower():
-            raise ce.InvalidFieldTitleError(field, is_unnamed=True)
-        for char in field:
-            if char not in valid_field_chars:
-                raise ce.InvalidFieldTitleError(
-                    field, char=char, valid_field_chars=valid_field_chars
+            # chequea si hay demasiadas series cortas
+            if (
+                float(series_too_small) / series_total
+                > self.options.max_too_small_proportion
+            ):
+                raise ce.FieldFewValuesError(
+                    field, not_null_values, self.options.minimum_values
                 )
 
 
-def validate_field_id(_df, distrib_meta, _catalog):
-    # Los ids de los campos deben tener caracteres ASCII + "_"
-    special_chars = "_-."
-    valid_field_chars = string.ascii_letters + string.digits + special_chars
-    for field_id in [field["id"] for field in distrib_meta["field"] if "id" in field]:
-        for char in field_id:
-            if char not in valid_field_chars:
-                raise ce.InvalidFieldIdError(field_id, char, valid_field_chars)
-
-
-def validate_title_length(df, _distrib_meta, _catalog):
-    # Los nombres de los campos tienen que tener un máximo de caracteres
-    for field in df.columns:
-        if len(field) > MAX_FIELD_TITLE_LEN:
-            raise ce.FieldTitleTooLongError(field, len(field), MAX_FIELD_TITLE_LEN)
+class DistributionNullSeriesValidation(BaseValidation):
+    def validate(self):
+        distribution_identifier = self.distrib_meta["identifier"]
+        series_total = len(self.df.columns)
+        null_series_amount = 0
+
+        for field in self.df.columns:
+            not_null_values = len(self.df[field][self.df[field].notnull()])
+            if not_null_values == 0:
+                null_series_amount += 1
+
+        null_proportion = float(null_series_amount) / series_total
+        if null_proportion >= self.options.max_null_series_proportion:
+            raise ce.DistributionTooManyNullSeriesError(
+                distribution_identifier,
+                self.options.max_null_series_proportion,
+                null_proportion,
+            )
+
+
+class FieldTitleValidation(BaseValidation):
+    def validate(self):
+        # Los titulos de los campos deben tener caracteres ASCII + "_"
+        valid_field_chars = "abcdefghijklmnopqrstuvwxyz0123456789_"
+        for field in self.df.columns:
+            if "unnamed" in field.lower():
+                raise ce.InvalidFieldTitleError(field, is_unnamed=True)
+            for char in field:
+                if char not in valid_field_chars:
+                    raise ce.InvalidFieldTitleError(
+                        field, char=char, valid_field_chars=valid_field_chars
+                    )
+
+
+class FieldIdValidation(BaseValidation):
+    def validate(self):
+        # Los ids de los campos deben tener caracteres ASCII + "_"
+        special_chars = "_-."
+        valid_field_chars = string.ascii_letters + string.digits + special_chars
+        for field_id in [
+            field["id"] for field in self.distrib_meta["field"] if "id" in field
+        ]:
+            for char in field_id:
+                if char not in valid_field_chars:
+                    raise ce.InvalidFieldIdError(field_id, char, valid_field_chars)
+
+
+class TitleLengthValidation(BaseValidation):
+    def validate(self):
+        # Los nombres de los campos tienen que tener un máximo de caracteres
+        for field in self.df.columns:
+            if len(field) > self.options.max_field_title_len:
+                raise ce.FieldTitleTooLongError(
+                    field, len(field), self.options.max_field_title_len
+                )
 
 
-def validate_missing_values(df, _distrib_meta, _catalog):
-    # Las series deben tener una proporción máxima de missings
-    for field in df.columns:
-        total_values = len(df[field])
-        positive_values = len(df[field][df[field].notnull()])
-        missing_values = total_values - positive_values
-        missing_values_prop = missing_values / float(total_values)
-        if not missing_values_prop <= MAX_MISSING_PROPORTION:
-            raise ce.FieldTooManyMissingsError(field, missing_values, positive_values)
+class MissingValuesValidation(BaseValidation):
+    def validate(self):
+        # Las series deben tener una proporción máxima de missings
+        for field in self.df.columns:
+            total_values = len(self.df[field])
+            positive_values = len(self.df[field][self.df[field].notnull()])
+            missing_values = total_values - positive_values
+            missing_values_prop = missing_values / float(total_values)
+            if not missing_values_prop <= self.options.max_missing_proportion:
+                raise ce.FieldTooManyMissingsError(
+                    field, missing_values, positive_values
+                )
 
 
-def validate_no_repeated_fields(_df, distrib_meta, catalog):
-    # 6. Los ids de fields no deben repetirse en todo un catálogo
-    field_ids = []
-    for dataset in catalog["dataset"]:
-        for distribution in dataset["distribution"]:
-            if (
-                "field" in distribution
-                and distribution["identifier"] != distrib_meta["identifier"]
-            ):
-                for field in distribution["field"]:
-                    if field.get("title") != "indice_tiempo" and "id" in field:
-                        field_ids.append(field["id"])
-    for field_distrib in distrib_meta.get("field", []):
-        if "id" in field_distrib and field_distrib["id"] in field_ids:
-            raise ce.FieldIdRepetitionError(field_distrib["id"])
-
-
-def validate_no_repeated_titles(_df, distrib_meta, _catalog):
-    # 7. Los títulos de fields no deben repetirse en una distribución
-    fields = distrib_meta["field"]
-    _assert_repeated_value("title", fields, ce.FieldTitleRepetitionError)
-
-
-def validate_no_repeated_descriptions(_df, distrib_meta, _catalog):
-    # 8. Las descripciones de fields no deben repetirse en una distribución
-    fields = [field for field in distrib_meta["field"] if "description" in field]
-    _assert_repeated_value("description", fields, ce.FieldDescriptionRepetitionError)
-
-
-def validate_values_are_numeric(df, distrib_meta, _catalog):
-    """Las series documentadas deben contener sólo valores numéricos."""
-    fields_title = [
-        field["title"]
-        for field in distrib_meta["field"]
-        if (field.get("specialType", "time_index") != "time_index")
-        and field.get("title") in list(df.columns)
-    ]
-    for field_title in fields_title:
-        if not is_numeric_dtype(df[field_title]):
-            raise ce.InvalidNumericField(field_title, df[field_title])
-
-
-def validate_missing_fields(df, distrib_meta, _catalog):
-    fields = [
-        field["title"]
-        for field in distrib_meta["field"]
-        if "specialType" not in field or field["specialType"] != "time_index"
-    ]
-    for field in fields:
-        if field not in df:
-            raise ce.FieldMissingInDistrbutionError(field, distrib_meta["identifier"])
-
-
-def validate_df_shape(df, distrib_meta, _catalog):
-    periodicity = None
-    for field in distrib_meta["field"]:
-        if field.get("specialType") == "time_index":
-            periodicity = field.get("specialTypeDetail")
-
-    freq = freq_iso_to_pandas(periodicity)
-    new_index = pd.date_range(df.index[0], df.index[-1], freq=freq)
-    columns = df.columns
-    data = np.array(df)
-    try:
-        pd.DataFrame(index=new_index, data=data, columns=columns)
-
-    except ValueError:
-        if freq == "D":
-            freq = "B"
-            new_index = pd.date_range(df.index[0], df.index[-1], freq=freq)
-            try:
-                pd.DataFrame(index=new_index, data=data, columns=columns)
-                return
-
-            except ValueError:
-                pass
-
-        raise ce.DistributionBadDataError(
-            distrib_meta["identifier"],
-            df.index[0],
-            df.index[-1],
-            periodicity,
-            len(new_index),
-            len(data),
+class NoRepeatedFieldsValidation(BaseValidation):
+    def validate(self):
+        # 6. Los ids de fields no deben repetirse en todo un catálogo
+        field_ids = []
+        for dataset in self.catalog["dataset"]:
+            for distribution in dataset["distribution"]:
+                if (
+                    "field" in distribution
+                    and distribution["identifier"] != self.distrib_meta["identifier"]
+                ):
+                    for field in distribution["field"]:
+                        if field.get("title") != "indice_tiempo" and "id" in field:
+                            field_ids.append(field["id"])
+        for field_distrib in self.distrib_meta.get("field", []):
+            if "id" in field_distrib and field_distrib["id"] in field_ids:
+                raise ce.FieldIdRepetitionError(field_distrib["id"])
+
+
+class NoRepeatedTitlesValidation(BaseValidation):
+    def validate(self):
+        # 7. Los títulos de fields no deben repetirse en una distribución
+        fields = self.distrib_meta["field"]
+        _assert_repeated_value("title", fields, ce.FieldTitleRepetitionError)
+
+
+class NoRepeatedDescriptionsValidation(BaseValidation):
+    def validate(self):
+        # 8. Las descripciones de fields no deben repetirse en una distribución
+        fields = [
+            field for field in self.distrib_meta["field"] if "description" in field
+        ]
+        _assert_repeated_value(
+            "description", fields, ce.FieldDescriptionRepetitionError
         )
 
 
-def validate_no_repeated_fields_in_distribution(_df, distrib_meta, _catalog):
-    """Verifica que los ID de los fields no estén repetidos dentro de
-    la misma distribución
-    """
-    fields = set()
-    for field in distrib_meta.get("field"):
-        _id = field.get("id")
-        if not _id:
-            continue
-
-        if field.get("id") in fields:
-            raise ce.FieldIdRepetitionError(_id)
-
-        fields.add(_id)
+class ValuesAreNumericValidation(BaseValidation):
+    def validate(self):
+        """Las series documentadas deben contener sólo valores numéricos."""
+        fields_title = [
+            field["title"]
+            for field in self.distrib_meta["field"]
+            if (field.get("specialType", "time_index") != "time_index")
+            and field.get("title") in list(self.df.columns)
+        ]
+        for field_title in fields_title:
+            if not is_numeric_dtype(self.df[field_title]):
+                raise ce.InvalidNumericField(field_title, self.df[field_title])
+
+
+class MissingFieldsValidation(BaseValidation):
+    def validate(self):
+        fields = [
+            field["title"]
+            for field in self.distrib_meta["field"]
+            if "specialType" not in field or field["specialType"] != "time_index"
+        ]
+        for field in fields:
+            if field not in self.df:
+                raise ce.FieldMissingInDistrbutionError(
+                    field, self.distrib_meta["identifier"]
+                )
 
 
-def validate_field_descriptions(_df, distrib_meta, _catalog):
-    fields = [
-        x
-        for x in distrib_meta.get("field", [])
-        if not x.get("specialType") == "time_index"
-    ]
-    for field in fields:
-        if "description" not in field:
-            raise ce.NonExistentDescriptionError(distrib_meta["identifier"])
+class DataFrameShapeValidation(BaseValidation):
+    def validate(self):
+        periodicity = None
+        for field in self.distrib_meta["field"]:
+            if field.get("specialType") == "time_index":
+                periodicity = field.get("specialTypeDetail")
+
+        freq = freq_iso_to_pandas(periodicity)
+        new_index = pd.date_range(self.df.index[0], self.df.index[-1], freq=freq)
+        columns = self.df.columns
+        data = np.array(self.df)
+        try:
+            pd.DataFrame(index=new_index, data=data, columns=columns)
+
+        except ValueError:
+            if freq == "D":
+                freq = "B"
+                new_index = pd.date_range(
+                    self.df.index[0], self.df.index[-1], freq=freq
+                )
+                try:
+                    pd.DataFrame(index=new_index, data=data, columns=columns)
+                    return
+
+                except ValueError:
+                    pass
+
+            raise ce.DistributionBadDataError(
+                self.distrib_meta["identifier"],
+                self.df.index[0],
+                self.df.index[-1],
+                periodicity,
+                len(new_index),
+                len(data),
+            )
+
+
+class NoRepeatedFieldsInDistributionValidation(BaseValidation):
+    def validate(self):
+        """Verifica que los ID de los fields no estén repetidos dentro de
+        la misma distribución
+        """
+        fields = set()
+        for field in self.distrib_meta.get("field"):
+            _id = field.get("id")
+            if not _id:
+                continue
+
+            if field.get("id") in fields:
+                raise ce.FieldIdRepetitionError(_id)
+
+            fields.add(_id)
+
+
+class FieldDescriptionsValidation(BaseValidation):
+    def validate(self):
+        fields = [
+            x
+            for x in self.distrib_meta.get("field", [])
+            if not x.get("specialType") == "time_index"
+        ]
+        for field in fields:
+            if "description" not in field:
+                raise ce.NonExistentDescriptionError(self.distrib_meta["identifier"])
diff --git a/series_tiempo_ar/validations/validators.py b/series_tiempo_ar/validations/validators.py
index db7438a..9aec25a 100644
--- a/series_tiempo_ar/validations/validators.py
+++ b/series_tiempo_ar/validations/validators.py
@@ -3,44 +3,65 @@
 
 """Módulo con métodos para hacer validaciones"""
 
-# pylint: disable=W0614
-# pylint: disable=W0401
 from series_tiempo_ar.custom_exceptions import TimeSeriesError
-from series_tiempo_ar.validations.xlsx_validations import *
-from . import csv_validations
+from series_tiempo_ar.validations.xlsx_validations import (
+    validate_header_cell_field_id,
+    validate_header_cell_field_id_or_blank,
+    validate_distinct_scraping_start_cells,
+)
+from .csv_validations import ValidationOptions, BaseValidation
 
 
-def _is_validation_function(func):
-    return (
-        callable(func)
-        and func.__module__ == csv_validations.__name__
-        and func.__name__[0] != "_"
-    )
+class Validator:
+    def __init__(self, catalog, distrbution_id, validations, options=None):
+        self.catalog = catalog
+        self.distribution_id = distrbution_id
+        self.validations = validations
+        self.options = options
 
+    def get_distribution_errors(self):
+        """Ejecuta todas las validaciones y devuelve los errores (excepciones)
+        levantadas
+        """
+        distribution = self.catalog.get_distribution(self.distribution_id)
+        df = self.catalog.load_ts_distribution(self.distribution_id)
+        errors = []
+        for validation in self.validations:
+            try:
+                validation(df, distribution, self.catalog, self.options).validate()
+            except TimeSeriesError as e:
+                errors.append(e)
 
-CSV_VALIDATIONS = [
-    x
-    for x in [getattr(csv_validations, x) for x in dir(csv_validations)]
-    if _is_validation_function(x)
-]
+        return errors
 
+    def validate_distribution(self, df=None):
+        """Ejecuta validaciones. Lanza una excepción de tipo TimeSeriesError
+        ante un error
+        """
+        distribution = self.catalog.get_distribution(self.distribution_id)
+        df = (
+            df
+            if df is not None
+            else self.catalog.load_ts_distribution(self.distribution_id)
+        )
+
+        for validation in self.validations:
+            validation(df, distribution, self.catalog).validate()
 
-def validate_distribution(df, catalog, _dataset_meta, distrib_meta, _=None):
-    for validation in CSV_VALIDATIONS:
-        validation(df, distrib_meta, catalog)
 
+# Entry points "legacy"
+
+
+def validate_distribution(df, catalog, _dataset_meta, distrib_meta, _=None):
+    Validator(
+        catalog, distrib_meta["identifier"], BaseValidation.__subclasses__()
+    ).validate_distribution(df)
 
-def get_distribution_errors(catalog, distribution_id):
-    distribution = catalog.get_distribution(distribution_id)
-    df = catalog.load_ts_distribution(distribution_id)
-    errors = []
-    for validation in CSV_VALIDATIONS:
-        try:
-            validation(df, distribution, catalog)
-        except TimeSeriesError as e:
-            errors.append(e)
 
-    return errors
+def get_distribution_errors(catalog, distribution_id, options=None):
+    return Validator(
+        catalog, distribution_id, BaseValidation.__subclasses__(), options
+    ).get_distribution_errors()
 
 
 def validate_distribution_scraping(


Test patch:
diff --git a/tests/samples/70_character_long_column_title.csv b/tests/samples/70_character_long_column_title.csv
new file mode 100644
index 0000000..10a6b0f
--- /dev/null
+++ b/tests/samples/70_character_long_column_title.csv
@@ -0,0 +1,3 @@
+indice_tiempo,columna_muy_largacolumna_muy_largacolumna_muy_largacolumna_muy_largaaa
+1990-01-01,1
+1991-01-01,2
diff --git a/tests/samples/all_null.csv b/tests/samples/all_null.csv
new file mode 100644
index 0000000..623216c
--- /dev/null
+++ b/tests/samples/all_null.csv
@@ -0,0 +1,3 @@
+indice_tiempo,values
+1990-01-01,
+1991-01-01,
diff --git a/tests/samples/few_values.csv b/tests/samples/few_values.csv
new file mode 100644
index 0000000..b13a1c0
--- /dev/null
+++ b/tests/samples/few_values.csv
@@ -0,0 +1,4 @@
+indice_tiempo,serie
+1991-01-01,
+1991-01-02,
+1991-01-03,2
diff --git a/tests/samples/single_null.csv b/tests/samples/single_null.csv
new file mode 100644
index 0000000..2709fd2
--- /dev/null
+++ b/tests/samples/single_null.csv
@@ -0,0 +1,3 @@
+indice_tiempo,value
+1990-01-01,
+1991-01-01,2
diff --git a/tests/test_validations/test_field_description_validation.py b/tests/test_validations/test_field_description_validation.py
index 5ab9385..455dc83 100644
--- a/tests/test_validations/test_field_description_validation.py
+++ b/tests/test_validations/test_field_description_validation.py
@@ -1,7 +1,7 @@
 from unittest import TestCase
 
 from series_tiempo_ar.custom_exceptions import NonExistentDescriptionError
-from series_tiempo_ar.validations.csv_validations import validate_field_descriptions
+from series_tiempo_ar.validations.csv_validations import FieldDescriptionsValidation
 from tests.helpers import read_data_json
 
 
@@ -11,4 +11,4 @@ class ValidateDescriptionExistsTests(TestCase):
         distribution = catalog.get_distributions()[0]
         df = None
         with self.assertRaises(NonExistentDescriptionError):
-            validate_field_descriptions(df, distribution, catalog)
+            FieldDescriptionsValidation(df, distribution, catalog).validate()
diff --git a/tests/test_validations/test_field_few_values.py b/tests/test_validations/test_field_few_values.py
new file mode 100644
index 0000000..9bfeaee
--- /dev/null
+++ b/tests/test_validations/test_field_few_values.py
@@ -0,0 +1,39 @@
+from unittest import TestCase
+
+
+from series_tiempo_ar.custom_exceptions import FieldFewValuesError
+from series_tiempo_ar.readers.csv_reader import CSVReader
+from series_tiempo_ar.validations.csv_validations import (
+    FieldViewValuesValidation,
+    ValidationOptions,
+)
+from tests.helpers import csv_path
+
+
+class TestFieldFewValues(TestCase):
+    def setUp(self) -> None:
+        self.distribution = {
+            "field": [{"specialType": "time_index", "title": "indice_tiempo"}]
+        }
+
+    def test_validation_one_value_is_invalid(self):
+        df = CSVReader(self.distribution, file_source=csv_path("few_values.csv")).read()
+        with self.assertRaises(FieldFewValuesError):
+            FieldViewValuesValidation(df).validate()
+
+    def test_validation_with_custom_minimum_values(self):
+        df = CSVReader(self.distribution, file_source=csv_path("few_values.csv")).read()
+
+        FieldViewValuesValidation(
+            df,
+            None,
+            None,
+            options=ValidationOptions.create_with_defaults(minimum_values=0),
+        ).validate()
+
+    def test_validation_with_custom_max_too_small_proportion(self):
+        df = CSVReader(self.distribution, file_source=csv_path("few_values.csv")).read()
+
+        # Siempre es válido
+        options = ValidationOptions.create_with_defaults(max_too_small_proportion=1.01)
+        FieldViewValuesValidation(df, options=options).validate()
diff --git a/tests/test_validations/test_null_series_validation.py b/tests/test_validations/test_null_series_validation.py
new file mode 100644
index 0000000..955da2d
--- /dev/null
+++ b/tests/test_validations/test_null_series_validation.py
@@ -0,0 +1,32 @@
+from unittest import TestCase
+
+from series_tiempo_ar.custom_exceptions import DistributionTooManyNullSeriesError
+from series_tiempo_ar.readers.csv_reader import CSVReader
+from series_tiempo_ar.validations.csv_validations import (
+    DistributionNullSeriesValidation,
+    ValidationOptions,
+)
+from tests.helpers import csv_path
+
+
+class TestNullSeriesValidation(TestCase):
+    def setUp(self) -> None:
+        self.distribution = {
+            "field": [{"specialType": "time_index", "title": "indice_tiempo"}],
+            "identifier": "test_id",
+        }
+
+    def test_all_null_distribution_is_invalid(self):
+        df = CSVReader(self.distribution, file_source=csv_path("all_null.csv")).read()
+        with self.assertRaises(DistributionTooManyNullSeriesError):
+            DistributionNullSeriesValidation(df, self.distribution, None).validate()
+
+    def test_custom_null_proportion(self):
+        df = CSVReader(self.distribution, file_source=csv_path("all_null.csv")).read()
+
+        options = ValidationOptions.create_with_defaults(
+            max_null_series_proportion=1.1
+        )  # Se permite 100% null
+        DistributionNullSeriesValidation(
+            df, self.distribution, options=options
+        ).validate()
diff --git a/tests/test_validations/test_title_length.py b/tests/test_validations/test_title_length.py
new file mode 100644
index 0000000..99317f9
--- /dev/null
+++ b/tests/test_validations/test_title_length.py
@@ -0,0 +1,33 @@
+from unittest import TestCase
+
+from series_tiempo_ar.custom_exceptions import FieldTitleTooLongError
+from series_tiempo_ar.readers.csv_reader import CSVReader
+from series_tiempo_ar.validations.csv_validations import (
+    TitleLengthValidation,
+    ValidationOptions,
+)
+from tests.helpers import csv_path
+
+
+class TestTitleLengthValidation(TestCase):
+    def setUp(self) -> None:
+        self.distribution = {
+            "field": [{"specialType": "time_index", "title": "indice_tiempo"}],
+            "identifier": "test_id",
+        }
+
+    def test_too_long_title(self):
+        df = CSVReader(
+            self.distribution,
+            file_source=csv_path("70_character_long_column_title.csv"),
+        ).read()
+        with self.assertRaises(FieldTitleTooLongError):
+            TitleLengthValidation(df).validate()
+
+    def test_custom_length(self):
+        df = CSVReader(
+            self.distribution,
+            file_source=csv_path("70_character_long_column_title.csv"),
+        ).read()
+        options = ValidationOptions.create_with_defaults(max_field_title_len=71)
+        TitleLengthValidation(df, options=options).validate()
diff --git a/tests/test_validations/test_too_many_missings.py b/tests/test_validations/test_too_many_missings.py
new file mode 100644
index 0000000..b6c3808
--- /dev/null
+++ b/tests/test_validations/test_too_many_missings.py
@@ -0,0 +1,31 @@
+from unittest import TestCase
+
+from series_tiempo_ar.custom_exceptions import FieldTooManyMissingsError
+from series_tiempo_ar.readers.csv_reader import CSVReader
+from series_tiempo_ar.validations.csv_validations import (
+    MissingValuesValidation,
+    ValidationOptions,
+)
+from tests.helpers import csv_path
+
+
+class TestTooManyMissings(TestCase):
+    def setUp(self) -> None:
+        self.distribution = {
+            "field": [{"specialType": "time_index", "title": "indice_tiempo"}],
+            "identifier": "test_id",
+        }
+
+    def test_full_serie_ok(self):
+        df = CSVReader(
+            self.distribution, file_source=csv_path("sample_data.csv")
+        ).read()
+        MissingValuesValidation(df).validate()
+
+    def test_single_null_not_ok_with_custom_option(self):
+        df = CSVReader(
+            self.distribution, file_source=csv_path("single_null.csv")
+        ).read()
+        options = ValidationOptions.create_with_defaults(max_missing_proportion=0.2)
+        with self.assertRaises(FieldTooManyMissingsError):
+            MissingValuesValidation(df, options=options).validate()
diff --git a/tests/test_validations/test_values_are_numeric.py b/tests/test_validations/test_values_are_numeric.py
index ad006c6..cd9ceb3 100644
--- a/tests/test_validations/test_values_are_numeric.py
+++ b/tests/test_validations/test_values_are_numeric.py
@@ -1,6 +1,6 @@
 from unittest import TestCase
 
-from series_tiempo_ar.validations.csv_validations import validate_values_are_numeric
+from series_tiempo_ar.validations.csv_validations import ValuesAreNumericValidation
 from tests.helpers import read_data_json
 
 
@@ -9,4 +9,4 @@ class ValuesAreNumericTests(TestCase):
         catalog = read_data_json("distribution_missing_column_in_data.json")
         distrib_meta = catalog.get_distribution(identifier="125.1")
         df = catalog.load_ts_distribution("125.1")
-        validate_values_are_numeric(df, distrib_meta, catalog)
+        ValuesAreNumericValidation(df, distrib_meta, catalog).validate()