diff --git a/spp_cel_vocabulary/README.rst b/spp_cel_vocabulary/README.rst index 94a6d768..7f30a0d3 100644 --- a/spp_cel_vocabulary/README.rst +++ b/spp_cel_vocabulary/README.rst @@ -10,9 +10,9 @@ OpenSPP CEL Vocabulary Integration !! source digest: sha256:187208d6a52a5dda86e67b1024f417772f64d60f710b523e275e30c8adbdeb0c !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status - :alt: Alpha + :alt: Beta .. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 @@ -124,10 +124,6 @@ Dependencies ``spp_cel_domain``, ``spp_vocabulary`` -.. IMPORTANT:: - This is an alpha version, the data model and design can change at any time without warning. - Only for development or testing purpose, do not use in production. - **Table of contents** .. contents:: diff --git a/spp_cel_vocabulary/__init__.py b/spp_cel_vocabulary/__init__.py index c77867a3..5eeeb990 100644 --- a/spp_cel_vocabulary/__init__.py +++ b/spp_cel_vocabulary/__init__.py @@ -29,7 +29,7 @@ def _ensure_concept_groups(env): standard_groups = [ { "name": "feminine_gender", - "display_name": "Feminine Gender", + "label": "Feminine Gender", "cel_function": "is_female", "target_field": "gender_id", "description": ( @@ -40,7 +40,7 @@ def _ensure_concept_groups(env): }, { "name": "masculine_gender", - "display_name": "Masculine Gender", + "label": "Masculine Gender", "cel_function": "is_male", "target_field": "gender_id", "description": ( @@ -51,7 +51,7 @@ def _ensure_concept_groups(env): }, { "name": "head_of_household", - "display_name": "Head of Household", + "label": "Head of Household", "cel_function": "is_head", "description": ( "Relationship types indicating head of household role. " @@ -61,7 +61,7 @@ def _ensure_concept_groups(env): }, { "name": "pregnant_eligible", - "display_name": "Pregnant/Eligible", + "label": "Pregnant/Eligible", "cel_function": "is_pregnant", "target_field": "pregnancy_status_id", "description": ( @@ -72,7 +72,7 @@ def _ensure_concept_groups(env): }, { "name": "climate_hazards", - "display_name": "Climate-related Hazards", + "label": "Climate-related Hazards", "cel_function": None, "description": ( "Hazard types related to climate and weather events " @@ -82,7 +82,7 @@ def _ensure_concept_groups(env): }, { "name": "geophysical_hazards", - "display_name": "Geophysical Hazards", + "label": "Geophysical Hazards", "cel_function": None, "description": ( "Hazard types related to earth processes " @@ -92,7 +92,7 @@ def _ensure_concept_groups(env): }, { "name": "children", - "display_name": "Children", + "label": "Children", "cel_function": None, "description": ( "Age group codes representing children (typically under 18 years). " @@ -101,7 +101,7 @@ def _ensure_concept_groups(env): }, { "name": "adults", - "display_name": "Adults", + "label": "Adults", "cel_function": None, "description": ( "Age group codes representing adults. Add codes from your age group vocabulary if applicable." @@ -109,7 +109,7 @@ def _ensure_concept_groups(env): }, { "name": "elderly", - "display_name": "Elderly/Senior Citizens", + "label": "Elderly/Senior Citizens", "cel_function": None, "description": ( "Age group codes representing elderly or senior citizens. " @@ -118,7 +118,7 @@ def _ensure_concept_groups(env): }, { "name": "persons_with_disability", - "display_name": "Persons with Disability", + "label": "Persons with Disability", "cel_function": None, "description": ( "Disability type codes. " diff --git a/spp_cel_vocabulary/__manifest__.py b/spp_cel_vocabulary/__manifest__.py index e229158f..3c8e39e2 100644 --- a/spp_cel_vocabulary/__manifest__.py +++ b/spp_cel_vocabulary/__manifest__.py @@ -7,7 +7,7 @@ "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", "license": "LGPL-3", - "development_status": "Alpha", + "development_status": "Beta", "depends": [ "spp_cel_domain", "spp_vocabulary", diff --git a/spp_cel_vocabulary/models/cel_vocabulary_functions.py b/spp_cel_vocabulary/models/cel_vocabulary_functions.py index bb6d0970..e182fe4f 100644 --- a/spp_cel_vocabulary/models/cel_vocabulary_functions.py +++ b/spp_cel_vocabulary/models/cel_vocabulary_functions.py @@ -89,9 +89,9 @@ def register_vocabulary_functions(self): # Register with CEL function registry if registry.register(name, marked): count += 1 - _logger.debug(f"[CEL Vocabulary] Registered function '{name}'") + _logger.debug("[CEL Vocabulary] Registered function '%s'", name) - _logger.info(f"[CEL Vocabulary] Registered {count} vocabulary function(s)") + _logger.info("[CEL Vocabulary] Registered %d vocabulary function(s)", count) return count @@ -110,9 +110,9 @@ def unregister_vocabulary_functions(self): for name in vocab_funcs.VOCABULARY_FUNCTIONS.keys(): if registry.unregister(name): count += 1 - _logger.debug(f"[CEL Vocabulary] Unregistered function '{name}'") + _logger.debug("[CEL Vocabulary] Unregistered function '%s'", name) - _logger.info(f"[CEL Vocabulary] Unregistered {count} vocabulary function(s)") + _logger.info("[CEL Vocabulary] Unregistered %d vocabulary function(s)", count) return count diff --git a/spp_cel_vocabulary/models/cel_vocabulary_translator.py b/spp_cel_vocabulary/models/cel_vocabulary_translator.py index 1ff9409d..bd639221 100644 --- a/spp_cel_vocabulary/models/cel_vocabulary_translator.py +++ b/spp_cel_vocabulary/models/cel_vocabulary_translator.py @@ -33,9 +33,6 @@ class CelVocabularyTranslator(models.AbstractModel): "is_male": "masculine_gender", "is_head": "head_of_household", "is_pregnant": "pregnant_eligible", - "is_caregiver": "caregiver", - "is_mother": "mother", - "is_father": "father", } def _to_plan(self, model: str, node: Any, cfg: dict[str, Any], ctx: dict[str, Any]): # noqa: C901 @@ -61,7 +58,7 @@ def _to_plan(self, model: str, node: Any, cfg: dict[str, Any], ctx: dict[str, An # Handle semantic helpers: is_female(field), is_male(field), etc. if func_name in self.SEMANTIC_HELPERS and len(node.args) >= 1: - _logger.debug(f"[CEL Vocabulary] Handling semantic helper {func_name} for model {model}") + _logger.debug("[CEL Vocabulary] Handling semantic helper %s for model %s", func_name, model) return self._handle_semantic_helper(model, node, cfg, ctx, func_name) # Handle comparisons with code() calls @@ -105,7 +102,7 @@ def _handle_in_group(self, model: str, node: P.Call, cfg: dict[str, Any], ctx: d field_name, field_model = self._resolve_field(model, field_node, cfg, ctx) field_name = self._normalize_field_name(field_model or model, field_name) except Exception as e: - _logger.warning(f"[CEL Vocabulary] Failed to resolve field in in_group(): {e}") + _logger.warning("[CEL Vocabulary] Failed to resolve field in in_group(): %s", e) return ( LeafDomain(model, [("id", "=", 0)]), "in_group() [FIELD RESOLUTION ERROR]", @@ -115,7 +112,7 @@ def _handle_in_group(self, model: str, node: P.Call, cfg: dict[str, Any], ctx: d try: group_name = self._eval_literal(node.args[1], ctx) except Exception as e: - _logger.warning(f"[CEL Vocabulary] Failed to evaluate group name in in_group(): {e}") + _logger.warning("[CEL Vocabulary] Failed to evaluate group name in in_group(): %s", e) return ( LeafDomain(field_model or model, [("id", "=", 0)]), f"in_group({field_name}, ?) [EVAL ERROR]", @@ -132,7 +129,7 @@ def _handle_in_group(self, model: str, node: P.Call, cfg: dict[str, Any], ctx: d group = self.env["spp.vocabulary.concept.group"].search([("name", "=", group_name)], limit=1) if not group: - _logger.warning(f"[CEL Vocabulary] Concept group '{group_name}' not found, returning empty domain") + _logger.warning("[CEL Vocabulary] Concept group '%s' not found, returning empty domain", group_name) # Return domain that matches nothing return ( LeafDomain(field_model or model, [("id", "=", 0)]), @@ -143,7 +140,7 @@ def _handle_in_group(self, model: str, node: P.Call, cfg: dict[str, Any], ctx: d uri_list = group.get_code_uris() if not uri_list: - _logger.warning(f"[CEL Vocabulary] Concept group '{group_name}' has no codes") + _logger.warning("[CEL Vocabulary] Concept group '%s' has no codes", group_name) return ( LeafDomain(field_model or model, [("id", "=", 0)]), f"in_group({field_name}, '{group_name}') [EMPTY GROUP]", @@ -188,7 +185,7 @@ def _handle_semantic_helper( """ group_name = self.SEMANTIC_HELPERS.get(func_name) if not group_name: - _logger.warning(f"[CEL Vocabulary] Unknown semantic helper: {func_name}") + _logger.warning("[CEL Vocabulary] Unknown semantic helper: %s", func_name) return ( LeafDomain(model, [("id", "=", 0)]), f"{func_name}() [UNKNOWN HELPER]", @@ -200,7 +197,7 @@ def _handle_semantic_helper( field_name, field_model = self._resolve_field(model, field_node, cfg, ctx) field_name = self._normalize_field_name(field_model or model, field_name) except Exception as e: - _logger.warning(f"[CEL Vocabulary] Failed to resolve field in {func_name}(): {e}") + _logger.warning("[CEL Vocabulary] Failed to resolve field in %s(): %s", func_name, e) return ( LeafDomain(model, [("id", "=", 0)]), f"{func_name}() [FIELD RESOLUTION ERROR]", @@ -211,7 +208,9 @@ def _handle_semantic_helper( if not group: _logger.warning( - f"[CEL Vocabulary] Concept group '{group_name}' not found for {func_name}(), returning empty domain" + "[CEL Vocabulary] Concept group '%s' not found for %s(), returning empty domain", + group_name, + func_name, ) return ( LeafDomain(field_model or model, [("id", "=", 0)]), @@ -222,7 +221,7 @@ def _handle_semantic_helper( uri_list = group.get_code_uris() if not uri_list: - _logger.warning(f"[CEL Vocabulary] Concept group '{group_name}' has no codes for {func_name}()") + _logger.warning("[CEL Vocabulary] Concept group '%s' has no codes for %s()", group_name, func_name) return ( LeafDomain(field_model or model, [("id", "=", 0)]), f"{func_name}({field_name}) [GROUP EMPTY]", @@ -264,7 +263,7 @@ def _handle_code_eq(self, model: str, node: P.Call, cfg: dict[str, Any], ctx: di field_name, field_model = self._resolve_field(model, field_node, cfg, ctx) field_name = self._normalize_field_name(field_model or model, field_name) except Exception as e: - _logger.warning(f"[CEL Vocabulary] Failed to resolve field in code_eq(): {e}") + _logger.warning("[CEL Vocabulary] Failed to resolve field in code_eq(): %s", e) return ( LeafDomain(model, [("id", "=", 0)]), "code_eq() [FIELD RESOLUTION ERROR]", @@ -274,7 +273,7 @@ def _handle_code_eq(self, model: str, node: P.Call, cfg: dict[str, Any], ctx: di try: identifier = self._eval_literal(node.args[1], ctx) except Exception as e: - _logger.warning(f"[CEL Vocabulary] Failed to evaluate identifier in code_eq(): {e}") + _logger.warning("[CEL Vocabulary] Failed to evaluate identifier in code_eq(): %s", e) return ( LeafDomain(field_model or model, [("id", "=", 0)]), f"code_eq({field_name}, ?) [EVAL ERROR]", @@ -290,7 +289,8 @@ def _handle_code_eq(self, model: str, node: P.Call, cfg: dict[str, Any], ctx: di if not target_code: _logger.warning( - f"[CEL Vocabulary] Could not resolve code identifier '{identifier}', returning empty domain" + "[CEL Vocabulary] Could not resolve code identifier '%s', returning empty domain", + identifier, ) return ( LeafDomain(field_model or model, [("id", "=", 0)]), @@ -351,7 +351,7 @@ def _handle_code_comparison( # Only support equality/inequality for code comparisons if op not in ("=", "!="): - _logger.warning(f"[CEL Vocabulary] code() comparisons only support == and !=, got {node.op}") + _logger.warning("[CEL Vocabulary] code() comparisons only support == and !=, got %s", node.op) return super()._to_plan(model, node, cfg, ctx) # Resolve the field @@ -359,7 +359,7 @@ def _handle_code_comparison( field_name, field_model = self._resolve_field(model, field_node, cfg, ctx) field_name = self._normalize_field_name(field_model or model, field_name) except Exception as e: - _logger.warning(f"[CEL Vocabulary] Failed to resolve field in code comparison: {e}") + _logger.warning("[CEL Vocabulary] Failed to resolve field in code comparison: %s", e) return ( LeafDomain(model, [("id", "=", 0)]), "code() comparison [FIELD RESOLUTION ERROR]", @@ -369,7 +369,7 @@ def _handle_code_comparison( try: identifier = self._eval_literal(code_node.args[0], ctx) if code_node.args else None except Exception as e: - _logger.warning(f"[CEL Vocabulary] Failed to evaluate code identifier: {e}") + _logger.warning("[CEL Vocabulary] Failed to evaluate code identifier: %s", e) identifier = None if not identifier: @@ -386,7 +386,7 @@ def _handle_code_comparison( target_code = self.env["spp.vocabulary.code"].resolve_alias(identifier) if not target_code: - _logger.warning(f"[CEL Vocabulary] Could not resolve code '{identifier}'") + _logger.warning("[CEL Vocabulary] Could not resolve code '%s'", identifier) return ( LeafDomain(field_model or model, [("id", "=", 0)]), f"{field_name} {op} code('{identifier}') [NOT FOUND]", diff --git a/spp_cel_vocabulary/services/cel_vocabulary_functions.py b/spp_cel_vocabulary/services/cel_vocabulary_functions.py index e2ba663f..d824aced 100644 --- a/spp_cel_vocabulary/services/cel_vocabulary_functions.py +++ b/spp_cel_vocabulary/services/cel_vocabulary_functions.py @@ -79,7 +79,7 @@ def in_group(env, code_value, group_name): uri_set = ConceptGroup._get_group_uris_cached(group_name) if uri_set is None: - _logger.warning(f"[CEL Vocabulary] Concept group '{group_name}' not found") + _logger.warning("[CEL Vocabulary] Concept group '%s' not found", group_name) return False if not uri_set: diff --git a/spp_cel_vocabulary/static/description/index.html b/spp_cel_vocabulary/static/description/index.html index 9d0d487f..6fd1f7f0 100644 --- a/spp_cel_vocabulary/static/description/index.html +++ b/spp_cel_vocabulary/static/description/index.html @@ -369,7 +369,7 @@

OpenSPP CEL Vocabulary Integration

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:187208d6a52a5dda86e67b1024f417772f64d60f710b523e275e30c8adbdeb0c !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Beta License: LGPL-3 OpenSPP/OpenSPP2

Integrates vocabulary-aware functions into the CEL (Common Expression Language) expression engine for eligibility rules. Extends the CEL translator to resolve vocabulary codes by URI or alias and translate @@ -490,11 +490,6 @@

Extension Points

Dependencies

spp_cel_domain, spp_vocabulary

-
-

Important

-

This is an alpha version, the data model and design can change at any time without warning. -Only for development or testing purpose, do not use in production.

-

Table of contents

Key Models

- ---- - - - - - - - - - - - - - - - - -
ModelDescription
spp.gis.indicator.layerConfiguration linking a CEL variable -to color scale and classification -settings
spp.gis.color.scaleColor scheme definition with JSON -array of hex colors
spp.gis.data.layerExtended with choropleth geo -representation option
+

Configuration

@@ -443,28 +425,11 @@

UI Location

Security

- ---- - - - - - - - - - - - - - - - - -
GroupAccess
spp_security.group_spp_userRead
spp_security.group_spp_managerRead/write/create (no delete)
spp_security.group_spp_adminFull CRUD
+

Extension Points

@@ -480,12 +445,7 @@

Extension Points

Dependencies

-

spp_gis, spp_hxl_area

-
-

Important

-

This is an alpha version, the data model and design can change at any time without warning. -Only for development or testing purpose, do not use in production.

-
+

spp_gis, spp_hxl_area, spp_registry, spp_security

Table of contents

    diff --git a/spp_gis_indicators/tests/__init__.py b/spp_gis_indicators/tests/__init__.py index 721eb799..e324fe3a 100644 --- a/spp_gis_indicators/tests/__init__.py +++ b/spp_gis_indicators/tests/__init__.py @@ -1,5 +1,6 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. from . import test_color_scale +from . import test_coverage_gaps from . import test_data_layer from . import test_indicator_layer diff --git a/spp_gis_indicators/tests/test_coverage_gaps.py b/spp_gis_indicators/tests/test_coverage_gaps.py new file mode 100644 index 00000000..eb5cbc2a --- /dev/null +++ b/spp_gis_indicators/tests/test_coverage_gaps.py @@ -0,0 +1,1084 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import json + +from odoo.exceptions import ValidationError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestQuantileBreaksEdgeCases(TransactionCase): + """Test _compute_quantile_breaks edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.IndicatorLayer = cls.env["spp.gis.indicator.layer"] + + cls.color_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Test Blues", + "scale_type": "sequential", + "colors_json": json.dumps(["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"]), + } + ) + + cls.variable = cls.env["spp.cel.variable"].create( + { + "name": "test_quantile_edge", + "cel_accessor": "test_quantile_edge", + "label": "Test Quantile Edge", + "value_type": "number", + "source_type": "constant", + } + ) + + def test_quantile_breaks_empty_values(self): + """Test _compute_quantile_breaks with empty values list.""" + layer = self.IndicatorLayer.create( + { + "name": "Test Layer", + "variable_id": self.variable.id, + "color_scale_id": self.color_scale.id, + } + ) + result = layer._compute_quantile_breaks([], 3) + self.assertEqual(result, []) + + def test_quantile_breaks_num_classes_less_than_2(self): + """Test _compute_quantile_breaks with num_classes < 2.""" + layer = self.IndicatorLayer.create( + { + "name": "Test Layer", + "variable_id": self.variable.id, + "color_scale_id": self.color_scale.id, + } + ) + result = layer._compute_quantile_breaks([10, 20, 30], 1) + self.assertEqual(result, []) + + def test_quantile_breaks_unique_values_less_than_num_classes(self): + """Test _compute_quantile_breaks when unique values < num_classes returns unique_values[:-1].""" + layer = self.IndicatorLayer.create( + { + "name": "Test Layer", + "variable_id": self.variable.id, + "color_scale_id": self.color_scale.id, + } + ) + # 2 unique values but requesting 5 classes + result = layer._compute_quantile_breaks([10, 10, 20, 20], 5) + # unique values are [10, 20], so [:-1] = [10] + self.assertEqual(result, [10]) + + def test_quantile_breaks_three_unique_for_five_classes(self): + """Test _compute_quantile_breaks when 3 unique values but 5 classes requested.""" + layer = self.IndicatorLayer.create( + { + "name": "Test Layer", + "variable_id": self.variable.id, + "color_scale_id": self.color_scale.id, + } + ) + result = layer._compute_quantile_breaks([10, 20, 30], 5) + # unique values [10, 20, 30], 3 < 5, so returns [10, 20] + self.assertEqual(result, [10, 20]) + + +@tagged("post_install", "-at_install") +class TestLegendHtmlEdgeCases(TransactionCase): + """Test _compute_legend_html edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.color_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Test Blues", + "scale_type": "sequential", + "colors_json": json.dumps(["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"]), + } + ) + + cls.variable = cls.env["spp.cel.variable"].create( + { + "name": "test_legend_edge", + "cel_accessor": "test_legend_edge", + "label": "Test Legend Edge", + "value_type": "number", + "source_type": "constant", + } + ) + + def test_legend_html_empty_break_values(self): + """Test legend HTML is empty when break_values is empty.""" + # Create a layer with no indicator data so break_values is empty + var_empty = self.env["spp.cel.variable"].create( + { + "name": "test_legend_empty", + "cel_accessor": "test_legend_empty", + "label": "Test Legend Empty", + "value_type": "number", + "source_type": "constant", + } + ) + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Empty Breaks Layer", + "variable_id": var_empty.id, + "period_key": "2099-01", + "color_scale_id": self.color_scale.id, + } + ) + # No indicators exist for this variable/period, so break_values should be empty + self.assertEqual(layer.break_values, "") + self.assertEqual(layer.legend_html, "") + + def test_legend_html_with_valid_data(self): + """Test legend HTML is generated when color_scale_id and break_values are present.""" + area1 = self.env["spp.area"].create( + { + "draft_name": "Legend Test Area 1", + "code": "LTA1", + } + ) + area2 = self.env["spp.area"].create( + { + "draft_name": "Legend Test Area 2", + "code": "LTA2", + } + ) + self.env["spp.hxl.area.indicator"].create( + { + "area_id": area1.id, + "variable_id": self.variable.id, + "period_key": "2024-12", + "value": 50.0, + } + ) + self.env["spp.hxl.area.indicator"].create( + { + "area_id": area2.id, + "variable_id": self.variable.id, + "period_key": "2024-12", + "value": 100.0, + } + ) + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "With Color Scale Layer", + "variable_id": self.variable.id, + "period_key": "2024-12", + "color_scale_id": self.color_scale.id, + "num_classes": 2, + } + ) + # Should have legend content since we have data and color scale + self.assertTrue(layer.break_values) + self.assertTrue(layer.legend_html) + self.assertIn("gis-choropleth-legend", layer.legend_html) + + +@tagged("post_install", "-at_install") +class TestGetFeatureColorsEdgeCases(TransactionCase): + """Test get_feature_colors edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.color_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Test Blues", + "scale_type": "sequential", + "colors_json": json.dumps(["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"]), + } + ) + + cls.variable = cls.env["spp.cel.variable"].create( + { + "name": "test_feature_edge", + "cel_accessor": "test_feature_edge", + "label": "Test Feature Edge", + "value_type": "number", + "source_type": "constant", + } + ) + + cls.area1 = cls.env["spp.area"].create( + { + "draft_name": "Feature Area 1", + "code": "FA1", + } + ) + cls.area2 = cls.env["spp.area"].create( + { + "draft_name": "Feature Area 2", + "code": "FA2", + } + ) + cls.area3 = cls.env["spp.area"].create( + { + "draft_name": "Feature Area 3", + "code": "FA3", + } + ) + cls.area4 = cls.env["spp.area"].create( + { + "draft_name": "Feature Area 4", + "code": "FA4", + } + ) + + def test_get_feature_colors_no_indicators(self): + """Test get_feature_colors returns empty dict when no indicators exist.""" + var_empty = self.env["spp.cel.variable"].create( + { + "name": "test_fc_empty", + "cel_accessor": "test_fc_empty", + "label": "Test FC Empty", + "value_type": "number", + "source_type": "constant", + } + ) + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "No Indicators Layer", + "variable_id": var_empty.id, + "period_key": "2099-01", + "color_scale_id": self.color_scale.id, + } + ) + result = layer.get_feature_colors([self.area1.id, self.area2.id]) + self.assertEqual(result, {}) + + def test_get_feature_colors_empty_break_values(self): + """Test get_feature_colors when break_values is truly empty string returns empty dict.""" + var_nodata = self.env["spp.cel.variable"].create( + { + "name": "test_fc_nodata", + "cel_accessor": "test_fc_nodata", + "label": "Test FC No Data", + "value_type": "number", + "source_type": "constant", + } + ) + # No indicators at all => break_values will be empty string + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Empty Breaks Layer", + "variable_id": var_nodata.id, + "period_key": "2099-01", + "color_scale_id": self.color_scale.id, + "classification_method": "equal_interval", + "num_classes": 3, + } + ) + # No data => break_values is empty string + self.assertEqual(layer.break_values, "") + result = layer.get_feature_colors([self.area1.id, self.area2.id]) + self.assertEqual(result, {}) + + def test_get_feature_colors_all_same_values(self): + """Test get_feature_colors when all values are the same (breaks is []).""" + var_single = self.env["spp.cel.variable"].create( + { + "name": "test_fc_single", + "cel_accessor": "test_fc_single", + "label": "Test FC Single", + "value_type": "number", + "source_type": "constant", + } + ) + for area in [self.area1, self.area2]: + self.env["spp.hxl.area.indicator"].create( + { + "area_id": area.id, + "variable_id": var_single.id, + "period_key": "2024-12", + "value": 100.0, + } + ) + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Same Values Layer", + "variable_id": var_single.id, + "period_key": "2024-12", + "color_scale_id": self.color_scale.id, + "classification_method": "equal_interval", + "num_classes": 3, + } + ) + # All values equal => breaks is "[]", which is truthy + self.assertEqual(layer.break_values, "[]") + # With 0 breaks => 1 class, all areas get the first color + result = layer.get_feature_colors([self.area1.id, self.area2.id]) + self.assertEqual(len(result), 2) + # All should have the same color (first color) + colors = list(result.values()) + self.assertEqual(colors[0], colors[1]) + + def test_get_feature_colors_missing_area_not_in_result(self): + """Test that areas without indicators are not in the result.""" + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area1.id, + "variable_id": self.variable.id, + "period_key": "2024-06", + "value": 50.0, + } + ) + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area2.id, + "variable_id": self.variable.id, + "period_key": "2024-06", + "value": 200.0, + } + ) + # area3 has no indicator for this period/variable + + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Missing Area Layer", + "variable_id": self.variable.id, + "period_key": "2024-06", + "color_scale_id": self.color_scale.id, + "classification_method": "quantile", + "num_classes": 2, + } + ) + + area_ids = [self.area1.id, self.area2.id, self.area3.id] + result = layer.get_feature_colors(area_ids) + # area3 has no indicator => not in result + self.assertNotIn(self.area3.id, result) + # area1 and area2 should have colors + self.assertIn(self.area1.id, result) + self.assertIn(self.area2.id, result) + + def test_get_feature_colors_zero_value_not_skipped(self): + """Test that indicators with value=0 are NOT skipped.""" + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area1.id, + "variable_id": self.variable.id, + "period_key": "2024-07", + "value": 0.0, + } + ) + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area2.id, + "variable_id": self.variable.id, + "period_key": "2024-07", + "value": 100.0, + } + ) + + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Zero Value Layer", + "variable_id": self.variable.id, + "period_key": "2024-07", + "color_scale_id": self.color_scale.id, + "classification_method": "quantile", + "num_classes": 2, + } + ) + + area_ids = [self.area1.id, self.area2.id] + result = layer.get_feature_colors(area_ids) + # value=0 should NOT be skipped + self.assertIn(self.area1.id, result) + self.assertIn(self.area2.id, result) + + def test_get_feature_colors_boundary_values(self): + """Test boundary value classification in get_feature_colors.""" + # Create 4 indicators with known values + for area, val in [(self.area1, 10.0), (self.area2, 50.0), (self.area3, 90.0), (self.area4, 100.0)]: + self.env["spp.hxl.area.indicator"].create( + { + "area_id": area.id, + "variable_id": self.variable.id, + "period_key": "2024-08", + "value": val, + } + ) + + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Boundary Layer", + "variable_id": self.variable.id, + "period_key": "2024-08", + "color_scale_id": self.color_scale.id, + "classification_method": "manual", + "manual_breaks": "50,90", + } + ) + + area_ids = [self.area1.id, self.area2.id, self.area3.id, self.area4.id] + result = layer.get_feature_colors(area_ids) + self.assertEqual(len(result), 4) + + # area1 (10) is < 50 => class 0 => first color + # area2 (50) is >= 50 but < 90 => class 1 + # area3 (90) is >= 90 => class 2 + # area4 (100) is >= 90 => class 2 + # Verify different classes get different colors (at least area1 vs area3/area4) + self.assertNotEqual(result[self.area1.id], result[self.area4.id]) + # area3 and area4 should be same class => same color + self.assertEqual(result[self.area3.id], result[self.area4.id]) + + +@tagged("post_install", "-at_install") +class TestGetIndicatorValuesEdgeCases(TransactionCase): + """Test _get_indicator_values edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.color_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Test Blues", + "scale_type": "sequential", + "colors_json": json.dumps(["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"]), + } + ) + + cls.variable = cls.env["spp.cel.variable"].create( + { + "name": "test_indicator_vals", + "cel_accessor": "test_indicator_vals", + "label": "Test Indicator Vals", + "value_type": "number", + "source_type": "constant", + } + ) + + cls.area1 = cls.env["spp.area"].create( + { + "draft_name": "Indicator Val Area 1", + "code": "IVA1", + } + ) + + def test_get_indicator_values_with_incident_filter(self): + """Test _get_indicator_values with incident_id filter.""" + # Create a hazard incident + hazard_cat = self.env["spp.hazard.category"].create( + { + "name": "Test Hazard Category", + "code": "THC", + } + ) + incident = self.env["spp.hazard.incident"].create( + { + "name": "Test Incident", + "code": "TI001", + "category_id": hazard_cat.id, + "start_date": "2024-01-01", + } + ) + + # Create indicator with incident + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area1.id, + "variable_id": self.variable.id, + "period_key": "2024-12", + "value": 999.0, + "incident_id": incident.id, + } + ) + + # Create indicator without incident + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area1.id, + "variable_id": self.variable.id, + "period_key": "2024-12", + "value": 111.0, + } + ) + + # Layer WITH incident filter + layer_with_incident = self.env["spp.gis.indicator.layer"].create( + { + "name": "Incident Layer", + "variable_id": self.variable.id, + "period_key": "2024-12", + "color_scale_id": self.color_scale.id, + "incident_id": incident.id, + } + ) + values = layer_with_incident._get_indicator_values() + self.assertEqual(len(values), 1) + self.assertIn(999.0, values) + self.assertNotIn(111.0, values) + + def test_get_indicator_values_empty_period_key(self): + """Test _get_indicator_values with empty period_key matches all periods.""" + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area1.id, + "variable_id": self.variable.id, + "period_key": "2024-01", + "value": 10.0, + } + ) + + # Layer with empty period_key: the domain won't include period_key filter + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "No Period Layer", + "variable_id": self.variable.id, + "period_key": "", + "color_scale_id": self.color_scale.id, + } + ) + values = layer._get_indicator_values() + # Should find the indicator since no period_key filter + self.assertIn(10.0, values) + + +@tagged("post_install", "-at_install") +class TestComputeBreakValuesEdgeCases(TransactionCase): + """Test _compute_break_values edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.color_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Test Blues", + "scale_type": "sequential", + "colors_json": json.dumps(["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"]), + } + ) + + cls.variable = cls.env["spp.cel.variable"].create( + { + "name": "test_break_edge", + "cel_accessor": "test_break_edge", + "label": "Test Break Edge", + "value_type": "number", + "source_type": "constant", + } + ) + + def test_break_values_no_indicator_data(self): + """Test _compute_break_values with no indicator data results in empty break_values.""" + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "No Data Layer", + "variable_id": self.variable.id, + "period_key": "2099-12", + "color_scale_id": self.color_scale.id, + "classification_method": "quantile", + } + ) + self.assertEqual(layer.break_values, "") + + def test_break_values_no_variable(self): + """Test _compute_break_values when variable_id triggers empty.""" + # After creation, we check the logic. variable_id is required, + # but the compute explicitly checks for it. + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Break No Var Layer", + "variable_id": self.variable.id, + "period_key": "2099-12", + "color_scale_id": self.color_scale.id, + } + ) + # Layer with no data -> empty break_values + self.assertEqual(layer.break_values, "") + + def test_break_values_unknown_classification_fallback(self): + """Test _compute_break_values with data falls back to empty breaks for unknown method.""" + # Create indicators so values list is non-empty (use different areas to avoid unique constraint) + area1 = self.env["spp.area"].create( + { + "draft_name": "Break Fallback Area 1", + "code": "BFA1", + } + ) + area2 = self.env["spp.area"].create( + { + "draft_name": "Break Fallback Area 2", + "code": "BFA2", + } + ) + self.env["spp.hxl.area.indicator"].create( + { + "area_id": area1.id, + "variable_id": self.variable.id, + "period_key": "2024-12", + "value": 50.0, + } + ) + self.env["spp.hxl.area.indicator"].create( + { + "area_id": area2.id, + "variable_id": self.variable.id, + "period_key": "2024-12", + "value": 100.0, + } + ) + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Fallback Layer", + "variable_id": self.variable.id, + "period_key": "2024-12", + "color_scale_id": self.color_scale.id, + "classification_method": "quantile", + "num_classes": 2, + } + ) + # Forcefully set classification_method to an unsupported value via SQL + # to test the else branch in _compute_break_values + self.env.cr.execute( + "UPDATE spp_gis_indicator_layer SET classification_method = %s WHERE id = %s", + ("unknown_method", layer.id), + ) + layer.invalidate_recordset() + # Re-trigger compute + layer._compute_break_values() + self.assertEqual(layer.break_values, "[]") + + +@tagged("post_install", "-at_install") +class TestGetColorForValueEdgeCases(TransactionCase): + """Test GisColorScale.get_color_for_value edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.diverging_colors = ["#d73027", "#fc8d59", "#ffffbf", "#91bfdb", "#4575b4"] + cls.diverging_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Diverging Test", + "scale_type": "diverging", + "colors_json": json.dumps(cls.diverging_colors), + } + ) + + cls.sequential_colors = ["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"] + cls.sequential_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Sequential Test", + "scale_type": "sequential", + "colors_json": json.dumps(cls.sequential_colors), + } + ) + + def test_diverging_min_equals_max(self): + """Test diverging scale when min == max returns center color.""" + color = self.diverging_scale.get_color_for_value(50, 50, 50) + center_idx = len(self.diverging_colors) // 2 + self.assertEqual(color, self.diverging_colors[center_idx]) + + def test_diverging_center_equals_min(self): + """Test diverging scale when center == min_val.""" + # center=0, min_val=0, max_val=100, value below center is clamped + # value < center but center == min_val => normalized = 0 + color = self.diverging_scale.get_color_for_value(0, 0, 100, center=0) + # value == min_val => colors[0] + self.assertEqual(color, self.diverging_colors[0]) + + def test_diverging_center_equals_max(self): + """Test diverging scale when max_val == center.""" + # center=100, min_val=0, max_val=100 + # value > center is impossible since max_val == center + # value at center should map to upper half with normalized=1 + color = self.diverging_scale.get_color_for_value(50, 0, 100, center=100) + # 50 < center (100), so lower half: normalized = (50 - 0) / (100 - 0) = 0.5 + self.assertIn(color, self.diverging_colors) + + def test_sequential_exact_boundary(self): + """Test sequential scale at exact boundary values.""" + # Test at 0 (min) + color_min = self.sequential_scale.get_color_for_value(0, 0, 100) + self.assertEqual(color_min, self.sequential_colors[0]) + + # Test at 100 (max) + color_max = self.sequential_scale.get_color_for_value(100, 0, 100) + self.assertEqual(color_max, self.sequential_colors[-1]) + + # Test at exactly 25% (first quarter) + color_quarter = self.sequential_scale.get_color_for_value(25, 0, 100) + self.assertIn(color_quarter, self.sequential_colors) + + def test_sequential_value_slightly_above_min(self): + """Test sequential scale with value just above min.""" + color = self.sequential_scale.get_color_for_value(1, 0, 100) + # Should be first or second color + self.assertIn(color, self.sequential_colors[:2]) + + +@tagged("post_install", "-at_install") +class TestGetChoroplethConfigEdgeCases(TransactionCase): + """Test GisDataLayerIndicator._get_choropleth_config edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.DataLayer = cls.env["spp.gis.data.layer"] + + cls.color_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Test Blues", + "scale_type": "sequential", + "colors_json": json.dumps(["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"]), + } + ) + + cls.variable = cls.env["spp.cel.variable"].create( + { + "name": "test_choropleth_cfg", + "cel_accessor": "test_choropleth_cfg", + "label": "Test Choropleth Config", + "value_type": "number", + "source_type": "constant", + } + ) + + cls.indicator_layer = cls.env["spp.gis.indicator.layer"].create( + { + "name": "Test Indicator Config", + "variable_id": cls.variable.id, + "color_scale_id": cls.color_scale.id, + "classification_method": "quantile", + "num_classes": 5, + } + ) + + # Find a geo field for testing + geo_field = cls.env["ir.model.fields"].search( + [ + ("model", "=", "spp.area"), + ("ttype", "=", "geo_polygon"), + ], + limit=1, + ) + cls.geo_field_id = geo_field.id if geo_field else False + + cls.gis_view = cls.env["ir.ui.view"].search( + [("type", "=", "spp_gis")], + limit=1, + ) + + def _skip_if_no_gis_prereqs(self): + """Skip test if GIS prerequisites are missing.""" + if not self.gis_view: + self.skipTest("No GIS view available for testing") + if not self.geo_field_id: + self.skipTest("No geo_polygon field available for testing") + + def test_choropleth_config_indicator_no_color_scale_colors(self): + """Test _get_choropleth_config when indicator has color_scale but empty colors.""" + self._skip_if_no_gis_prereqs() + + # Create a color scale and then clear its colors + empty_color_scale = self.env["spp.gis.color.scale"].create( + { + "name": "Temp Scale", + "scale_type": "sequential", + "colors_json": json.dumps(["#000", "#fff"]), + } + ) + indicator_layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "No Colors Indicator", + "variable_id": self.variable.id, + "color_scale_id": empty_color_scale.id, + } + ) + + layer = self.DataLayer.create( + { + "name": "Config Test Layer", + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, + "geo_repr": "choropleth", + "indicator_layer_id": indicator_layer.id, + } + ) + + config = layer._get_choropleth_config() + self.assertIsNotNone(config) + self.assertEqual(config["type"], "indicator") + # color_ramp should have colors from the scale + self.assertEqual(config["color_ramp"], ["#000", "#fff"]) + + def test_choropleth_config_with_empty_break_values(self): + """Test _get_choropleth_config when break_values is empty.""" + self._skip_if_no_gis_prereqs() + + # indicator_layer has no data, so break_values is "" + layer = self.DataLayer.create( + { + "name": "Empty Breaks Config", + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, + "geo_repr": "choropleth", + "indicator_layer_id": self.indicator_layer.id, + } + ) + + config = layer._get_choropleth_config() + self.assertIsNotNone(config) + self.assertEqual(config["type"], "indicator") + # break_values is empty (""), json.loads("" or "[]") => [] + self.assertEqual(config["break_values"], []) + + def test_choropleth_config_non_choropleth_returns_none(self): + """Test that _get_choropleth_config returns None for non-choropleth layers.""" + self._skip_if_no_gis_prereqs() + + layer = self.DataLayer.create( + { + "name": "Basic Layer Config", + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, + "geo_repr": "basic", + } + ) + + config = layer._get_choropleth_config() + self.assertIsNone(config) + + +@tagged("post_install", "-at_install") +class TestCheckColorsJsonEdgeCases(TransactionCase): + """Test GisColorScale._check_colors_json edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.ColorScale = cls.env["spp.gis.color.scale"] + + def test_empty_string_colors_json(self): + """Test _check_colors_json with empty string passes (skipped by constraint).""" + scale = self.ColorScale.create( + { + "name": "Empty Test", + "scale_type": "sequential", + "colors_json": json.dumps(["#000", "#fff"]), + } + ) + # Setting colors_json to empty string should pass the constraint + # (the constraint has `if not rec.colors_json: continue`) + scale.colors_json = "" + # No error raised means constraint passes for empty + + def test_short_hex_format_accepted(self): + """Test that short hex format (#fff) is accepted by constraint.""" + scale = self.ColorScale.create( + { + "name": "Short Hex Test", + "scale_type": "sequential", + "colors_json": json.dumps(["#fff", "#000", "#abc"]), + } + ) + colors = scale.get_colors() + self.assertEqual(colors, ["#fff", "#000", "#abc"]) + + def test_mixed_short_long_hex(self): + """Test that mix of short and long hex formats is accepted.""" + scale = self.ColorScale.create( + { + "name": "Mixed Hex Test", + "scale_type": "sequential", + "colors_json": json.dumps(["#fff", "#ff0000"]), + } + ) + colors = scale.get_colors() + self.assertEqual(len(colors), 2) + + def test_invalid_length_hex_rejected(self): + """Test that hex colors with wrong length are rejected.""" + with self.assertRaises(ValidationError) as ctx: + self.ColorScale.create( + { + "name": "Bad Length", + "scale_type": "sequential", + "colors_json": json.dumps(["#ff00", "#ffffff"]), + } + ) + self.assertIn("Invalid hex color format", str(ctx.exception)) + + +@tagged("post_install", "-at_install") +class TestDataLayerMethodsDirect(TransactionCase): + """Test data_layer.py methods using new() to avoid GIS prereq dependencies.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.DataLayer = cls.env["spp.gis.data.layer"] + cls.ColorScale = cls.env["spp.gis.color.scale"] + cls.IndicatorLayer = cls.env["spp.gis.indicator.layer"] + + cls.color_scale = cls.ColorScale.create( + { + "name": "Direct Test Blues", + "scale_type": "sequential", + "colors_json": json.dumps(["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"]), + } + ) + + cls.variable = cls.env["spp.cel.variable"].create( + { + "name": "test_direct_dl", + "cel_accessor": "test_direct_dl", + "label": "Test Direct DL", + "value_type": "number", + "source_type": "constant", + } + ) + + cls.indicator_layer = cls.IndicatorLayer.create( + { + "name": "Direct Test Indicator", + "variable_id": cls.variable.id, + "color_scale_id": cls.color_scale.id, + "classification_method": "quantile", + "num_classes": 5, + } + ) + + def test_get_choropleth_config_non_choropleth_new(self): + """Test _get_choropleth_config returns None for non-choropleth using new().""" + rec = self.DataLayer.new({"geo_repr": "basic"}) + config = rec._get_choropleth_config() + self.assertIsNone(config) + + def test_get_choropleth_config_indicator_new(self): + """Test _get_choropleth_config with indicator_layer_id using new().""" + rec = self.DataLayer.new( + { + "geo_repr": "choropleth", + "indicator_layer_id": self.indicator_layer.id, + } + ) + config = rec._get_choropleth_config() + self.assertIsNotNone(config) + self.assertEqual(config["type"], "indicator") + self.assertEqual(config["classification"], "quantile") + self.assertEqual(config["class_count"], 5) + self.assertTrue(config["show_legend"]) + self.assertEqual(config["legend_title"], "Direct Test Indicator") + self.assertIsInstance(config["color_ramp"], list) + self.assertIsInstance(config["break_values"], list) + self.assertIn("legend_html", config) + + def test_get_choropleth_config_super_fallback_new(self): + """Test _get_choropleth_config falls back to super() when no indicator.""" + rec = self.DataLayer.new({"geo_repr": "choropleth"}) + # No indicator_layer_id → falls through to super()._get_choropleth_config() + # super() returns None since no choropleth_field_id either + config = rec._get_choropleth_config() + self.assertIsNone(config) + + def test_check_choropleth_config_valid_indicator(self): + """Test _check_choropleth_config passes with indicator_layer_id.""" + rec = self.DataLayer.new( + { + "geo_repr": "choropleth", + "indicator_layer_id": self.indicator_layer.id, + } + ) + # Should not raise + rec._check_choropleth_config() + + def test_check_choropleth_config_valid_basic(self): + """Test _check_choropleth_config passes for non-choropleth.""" + rec = self.DataLayer.new({"geo_repr": "basic"}) + # Should not raise + rec._check_choropleth_config() + + def test_check_choropleth_config_invalid(self): + """Test _check_choropleth_config raises for choropleth without config.""" + rec = self.DataLayer.new({"geo_repr": "choropleth"}) + with self.assertRaises(ValidationError): + rec._check_choropleth_config() + + +@tagged("post_install", "-at_install") +class TestGetFeatureColorsNoneValue(TransactionCase): + """Test get_feature_colors with None/False indicator values.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.color_scale = cls.env["spp.gis.color.scale"].create( + { + "name": "Test None Val", + "scale_type": "sequential", + "colors_json": json.dumps(["#f7fbff", "#6baed6", "#08306b"]), + } + ) + + cls.variable = cls.env["spp.cel.variable"].create( + { + "name": "test_none_val", + "cel_accessor": "test_none_val", + "label": "Test None Val", + "value_type": "number", + "source_type": "constant", + } + ) + + cls.area1 = cls.env["spp.area"].create({"draft_name": "None Val Area 1", "code": "NVA1"}) + cls.area2 = cls.env["spp.area"].create({"draft_name": "None Val Area 2", "code": "NVA2"}) + cls.area3 = cls.env["spp.area"].create({"draft_name": "None Val Area 3", "code": "NVA3"}) + + def test_get_feature_colors_with_zero_and_positive(self): + """Test get_feature_colors includes both zero and positive values.""" + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area1.id, + "variable_id": self.variable.id, + "period_key": "2024-none", + "value": 50.0, + } + ) + self.env["spp.hxl.area.indicator"].create( + { + "area_id": self.area2.id, + "variable_id": self.variable.id, + "period_key": "2024-none", + "value": 0.0, + } + ) + + layer = self.env["spp.gis.indicator.layer"].create( + { + "name": "Zero Pos Test Layer", + "variable_id": self.variable.id, + "period_key": "2024-none", + "color_scale_id": self.color_scale.id, + "classification_method": "manual", + "manual_breaks": "25", + } + ) + + area_ids = [self.area1.id, self.area2.id, self.area3.id] + result = layer.get_feature_colors(area_ids) + # area1 (50.0) and area2 (0.0) should have colors + self.assertIn(self.area1.id, result) + self.assertIn(self.area2.id, result) + # area3 has no indicator data + self.assertNotIn(self.area3.id, result) diff --git a/spp_gis_indicators/tests/test_data_layer.py b/spp_gis_indicators/tests/test_data_layer.py index 760c8a4e..d4f18b7d 100644 --- a/spp_gis_indicators/tests/test_data_layer.py +++ b/spp_gis_indicators/tests/test_data_layer.py @@ -2,6 +2,7 @@ import json +from odoo.exceptions import ValidationError from odoo.tests import tagged from odoo.tests.common import TransactionCase @@ -51,7 +52,7 @@ def setUpClass(cls): } ) - # Find or create a geo field for testing + # Find a geo field for testing geo_field = cls.env["ir.model.fields"].search( [ ("model", "=", "spp.area"), @@ -60,11 +61,20 @@ def setUpClass(cls): limit=1, ) - if not geo_field: - # Create a mock data layer without geo_field - cls.geo_field_id = False - else: - cls.geo_field_id = geo_field.id + cls.geo_field_id = geo_field.id if geo_field else False + + # Find a GIS view for testing + cls.gis_view = cls.env["ir.ui.view"].search( + [("type", "=", "spp_gis")], + limit=1, + ) + + def _skip_if_no_gis_prereqs(self): + """Skip test if GIS prerequisites are missing.""" + if not self.gis_view: + self.skipTest("No GIS view available for testing") + if not self.geo_field_id: + self.skipTest("No geo_polygon field available for testing") def test_geo_repr_choropleth_option(self): """Test that choropleth is available as geo_repr option.""" @@ -78,22 +88,14 @@ def test_geo_repr_choropleth_option(self): def test_create_data_layer_with_choropleth(self): """Test creating a data layer with choropleth representation.""" - # Get any available GIS view - gis_view = self.env["ir.ui.view"].search( - [ - ("type", "=", "spp_gis"), - ], - limit=1, - ) - - if not gis_view: - self.skipTest("No GIS view available for testing") + self._skip_if_no_gis_prereqs() # Try to create data layer layer = self.DataLayer.create( { "name": "Test Choropleth Layer", - "view_id": gis_view.id, + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, "geo_repr": "choropleth", "indicator_layer_id": self.indicator_layer.id, } @@ -111,21 +113,14 @@ def test_indicator_layer_field_exists(self): def test_data_layer_without_indicator(self): """Test that data layer can be created without indicator.""" - gis_view = self.env["ir.ui.view"].search( - [ - ("type", "=", "spp_gis"), - ], - limit=1, - ) - - if not gis_view: - self.skipTest("No GIS view available for testing") + self._skip_if_no_gis_prereqs() # Create without indicator_layer_id layer = self.DataLayer.create( { "name": "Non-Choropleth Layer", - "view_id": gis_view.id, + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, "geo_repr": "basic", } ) @@ -135,20 +130,13 @@ def test_data_layer_without_indicator(self): def test_change_geo_repr_to_choropleth(self): """Test changing geo_repr to choropleth after creation.""" - gis_view = self.env["ir.ui.view"].search( - [ - ("type", "=", "spp_gis"), - ], - limit=1, - ) - - if not gis_view: - self.skipTest("No GIS view available for testing") + self._skip_if_no_gis_prereqs() layer = self.DataLayer.create( { "name": "Changeable Layer", - "view_id": gis_view.id, + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, } ) @@ -162,3 +150,53 @@ def test_change_geo_repr_to_choropleth(self): self.assertEqual(layer.geo_repr, "choropleth") self.assertEqual(layer.indicator_layer_id, self.indicator_layer) + + def test_choropleth_requires_config(self): + """Test that choropleth without any config raises ValidationError.""" + self._skip_if_no_gis_prereqs() + + with self.assertRaises(ValidationError): + self.DataLayer.create( + { + "name": "Invalid Choropleth", + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, + "geo_repr": "choropleth", + } + ) + + def test_get_choropleth_config_indicator(self): + """Test _get_choropleth_config returns indicator config.""" + self._skip_if_no_gis_prereqs() + + layer = self.DataLayer.create( + { + "name": "Indicator Choropleth", + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, + "geo_repr": "choropleth", + "indicator_layer_id": self.indicator_layer.id, + } + ) + + config = layer._get_choropleth_config() + self.assertIsNotNone(config) + self.assertEqual(config["type"], "indicator") + self.assertEqual(config["classification"], "quantile") + self.assertEqual(config["class_count"], 5) + + def test_get_choropleth_config_basic(self): + """Test _get_choropleth_config returns None for basic layers.""" + self._skip_if_no_gis_prereqs() + + layer = self.DataLayer.create( + { + "name": "Basic Layer", + "view_id": self.gis_view.id, + "geo_field_id": self.geo_field_id, + "geo_repr": "basic", + } + ) + + config = layer._get_choropleth_config() + self.assertIsNone(config) diff --git a/spp_gis_indicators/views/color_scale_views.xml b/spp_gis_indicators/views/color_scale_views.xml index 0459f03b..07bc3f08 100644 --- a/spp_gis_indicators/views/color_scale_views.xml +++ b/spp_gis_indicators/views/color_scale_views.xml @@ -94,7 +94,7 @@ Color Scales gis-color-scales spp.gis.color.scale - tree,form + list,form {'search_default_active': 1}

    diff --git a/spp_gis_indicators/views/indicator_layer_views.xml b/spp_gis_indicators/views/indicator_layer_views.xml index 107e7dab..cbb66749 100644 --- a/spp_gis_indicators/views/indicator_layer_views.xml +++ b/spp_gis_indicators/views/indicator_layer_views.xml @@ -141,7 +141,7 @@ Indicator Layers gis-indicator-layers spp.gis.indicator.layer - tree,form + list,form {'search_default_active': 1}