Maintainers
+Maintainers
Current maintainers:
This module is part of the OpenSPP/OpenSPP2 project on GitHub.
diff --git a/spp_graduation/tests/test_assessment.py b/spp_graduation/tests/test_assessment.py index a2973975..5fa71fbb 100644 --- a/spp_graduation/tests/test_assessment.py +++ b/spp_graduation/tests/test_assessment.py @@ -1,6 +1,11 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from datetime import date + +from dateutil.relativedelta import relativedelta + from odoo import fields +from odoo.exceptions import UserError, ValidationError from odoo.tests.common import TransactionCase @@ -19,6 +24,7 @@ def setUpClass(cls): cls.beneficiary = cls.Partner.create( { "name": "Test Beneficiary", + "is_registrant": True, } ) @@ -26,6 +32,7 @@ def setUpClass(cls): { "name": "Test Pathway", "code": "TEST", + "post_graduation_monitoring_months": 12, } ) @@ -68,8 +75,10 @@ def test_assessment_name_computation(self): self.assertIn(self.beneficiary.name, assessment.name) self.assertIn(self.pathway.name, assessment.name) + # --- State transition tests --- + def test_assessment_workflow(self): - """Test assessment state workflow.""" + """Test assessment state workflow: draft -> submitted -> approved.""" assessment = self.Assessment.create( { "partner_id": self.beneficiary.id, @@ -100,8 +109,8 @@ def test_assessment_rejection(self): assessment.action_reject() self.assertEqual(assessment.state, "rejected") - def test_assessment_reset(self): - """Test assessment reset to draft.""" + def test_assessment_reset_from_submitted(self): + """Test assessment reset to draft from submitted.""" assessment = self.Assessment.create( { "partner_id": self.beneficiary.id, @@ -113,6 +122,104 @@ def test_assessment_reset(self): assessment.action_reset_draft() self.assertEqual(assessment.state, "draft") + def test_assessment_reset_from_rejected(self): + """Test assessment reset to draft from rejected.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + + assessment.action_submit() + assessment.action_reject() + assessment.action_reset_draft() + self.assertEqual(assessment.state, "draft") + + # --- State transition guard tests --- + + def test_submit_only_from_draft(self): + """Test submit raises UserError if not in draft state.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + assessment.action_submit() + # Already submitted, cannot submit again + with self.assertRaises(UserError): + assessment.action_submit() + + def test_approve_only_from_submitted(self): + """Test approve raises UserError if not in submitted state.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + # Cannot approve from draft + with self.assertRaises(UserError): + assessment.action_approve() + + def test_reject_only_from_submitted(self): + """Test reject raises UserError if not in submitted state.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + # Cannot reject from draft + with self.assertRaises(UserError): + assessment.action_reject() + + def test_reset_not_from_draft(self): + """Test reset raises UserError if in draft state.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + with self.assertRaises(UserError): + assessment.action_reset_draft() + + def test_reset_not_from_approved(self): + """Test reset raises UserError if in approved state.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + assessment.action_submit() + assessment.action_approve() + with self.assertRaises(UserError): + assessment.action_reset_draft() + + # --- Multi-record action tests --- + + def test_multi_record_submit(self): + """Test submit works on multiple records.""" + a1 = self.Assessment.create({"partner_id": self.beneficiary.id, "pathway_id": self.pathway.id}) + a2 = self.Assessment.create({"partner_id": self.beneficiary.id, "pathway_id": self.pathway.id}) + (a1 | a2).action_submit() + self.assertEqual(a1.state, "submitted") + self.assertEqual(a2.state, "submitted") + + def test_multi_record_approve(self): + """Test approve works on multiple records.""" + a1 = self.Assessment.create({"partner_id": self.beneficiary.id, "pathway_id": self.pathway.id}) + a2 = self.Assessment.create({"partner_id": self.beneficiary.id, "pathway_id": self.pathway.id}) + (a1 | a2).action_submit() + (a1 | a2).action_approve() + self.assertEqual(a1.state, "approved") + self.assertEqual(a2.state, "approved") + + # --- Score computation tests --- + def test_readiness_score_computation(self): """Test readiness score computation.""" assessment = self.Assessment.create( @@ -126,7 +233,7 @@ def test_readiness_score_computation(self): { "assessment_id": assessment.id, "criteria_id": self.criteria1.id, - "score": 0.8, # 80% + "score": 0.8, "is_met": True, } ) @@ -134,7 +241,7 @@ def test_readiness_score_computation(self): { "assessment_id": assessment.id, "criteria_id": self.criteria2.id, - "score": 0.6, # 60% + "score": 0.6, "is_met": False, } ) @@ -154,7 +261,6 @@ def test_required_criteria_check(self): } ) - # Only required criterion met self.Response.create( { "assessment_id": assessment.id, @@ -181,13 +287,88 @@ def test_required_criteria_not_met(self): "assessment_id": assessment.id, "criteria_id": self.criteria1.id, "score": 0.4, - "is_met": False, # Required but not met + "is_met": False, } ) assessment.invalidate_recordset(["is_required_criteria_met"]) self.assertFalse(assessment.is_required_criteria_met) + def test_no_responses_scores_zero(self): + """Test assessment with no responses has zero score and required not met.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + self.assertEqual(assessment.readiness_score, 0) + self.assertFalse(assessment.is_required_criteria_met) + + # --- Score constraint tests --- + + def test_score_constraint_above_one(self): + """Test score above 1 raises ValidationError.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + with self.assertRaises(ValidationError): + self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria1.id, + "score": 1.5, + } + ) + + def test_score_constraint_negative(self): + """Test negative score raises ValidationError.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + with self.assertRaises(ValidationError): + self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria1.id, + "score": -0.1, + } + ) + + def test_score_boundary_values(self): + """Test score at boundaries (0 and 1) is accepted.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + r1 = self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria1.id, + "score": 0.0, + } + ) + self.assertEqual(r1.score, 0.0) + + r2 = self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria2.id, + "score": 1.0, + } + ) + self.assertEqual(r2.score, 1.0) + + # --- Graduation and monitoring tests --- + def test_graduation_date_on_approval(self): """Test graduation date is set on approval with graduate recommendation.""" assessment = self.Assessment.create( @@ -202,3 +383,96 @@ def test_graduation_date_on_approval(self): assessment.action_approve() self.assertEqual(assessment.graduation_date, fields.Date.today()) + + def test_no_graduation_date_without_graduate_recommendation(self): + """Test graduation date is NOT set when recommendation is not graduate.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + "recommendation": "extend", + } + ) + + assessment.action_submit() + assessment.action_approve() + + self.assertFalse(assessment.graduation_date) + + def test_monitoring_end_date_computation(self): + """Test monitoring end date is computed from graduation date + pathway months.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + "recommendation": "graduate", + } + ) + + assessment.action_submit() + assessment.action_approve() + + expected_end = date.today() + relativedelta(months=12) + self.assertEqual(assessment.monitoring_end_date, expected_end) + + def test_monitoring_end_date_no_graduation(self): + """Test monitoring end date is False when no graduation date.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + self.assertFalse(assessment.monitoring_end_date) + + # --- Name computation edge case --- + + def test_assessment_name_without_pathway(self): + """Test assessment name defaults when pathway is missing.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + # Remove pathway to trigger else branch + assessment.pathway_id = False + self.assertEqual(assessment.name, "New Assessment") + + # --- Response field tests --- + + def test_response_value_and_notes(self): + """Test response value and notes fields are stored correctly.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + } + ) + response = self.Response.create( + { + "assessment_id": assessment.id, + "criteria_id": self.criteria1.id, + "score": 0.7, + "is_met": True, + "value": "Above threshold", + "notes": "Verified via documentation", + } + ) + self.assertEqual(response.value, "Above threshold") + self.assertEqual(response.notes, "Verified via documentation") + + def test_recommendation_notes(self): + """Test recommendation notes field is stored correctly.""" + assessment = self.Assessment.create( + { + "partner_id": self.beneficiary.id, + "pathway_id": self.pathway.id, + "recommendation": "graduate", + "recommendation_notes": "Beneficiary meets all criteria for graduation.", + } + ) + self.assertEqual( + assessment.recommendation_notes, + "Beneficiary meets all criteria for graduation.", + ) diff --git a/spp_graduation/tests/test_graduation_security.py b/spp_graduation/tests/test_graduation_security.py index 87d213f1..97308b6d 100644 --- a/spp_graduation/tests/test_graduation_security.py +++ b/spp_graduation/tests/test_graduation_security.py @@ -36,6 +36,7 @@ def setUpClass(cls): cls.beneficiary = cls.env["res.partner"].create( { "name": "Test Beneficiary", + "is_registrant": True, } ) @@ -70,7 +71,6 @@ def test_user_sees_own_assessments(self): ) assessment = self.env["spp.graduation.assessment"].create( { - "name": "Test Assessment", "pathway_id": pathway.id, "partner_id": self.beneficiary.id, "assessor_id": self.user.id, @@ -88,7 +88,6 @@ def test_manager_sees_all_assessments(self): ) assessment1 = self.env["spp.graduation.assessment"].create( { - "name": "Assessment 1", "pathway_id": pathway.id, "partner_id": self.beneficiary.id, "assessor_id": self.user.id, @@ -96,7 +95,6 @@ def test_manager_sees_all_assessments(self): ) assessment2 = self.env["spp.graduation.assessment"].create( { - "name": "Assessment 2", "pathway_id": pathway.id, "partner_id": self.beneficiary.id, "assessor_id": self.manager.id, @@ -135,3 +133,60 @@ def test_manager_can_write_pathways(self): ) pathway.with_user(self.manager).write({"name": "Modified by Manager"}) self.assertEqual(pathway.name, "Modified by Manager") + + def test_user_workflow_as_user(self): + """Test user can submit own assessments.""" + pathway = self.env["spp.graduation.pathway"].create( + { + "name": "Test Pathway", + } + ) + assessment = ( + self.env["spp.graduation.assessment"] + .with_user(self.user) + .create( + { + "pathway_id": pathway.id, + "partner_id": self.beneficiary.id, + } + ) + ) + assessment.action_submit() + self.assertEqual(assessment.state, "submitted") + + def test_manager_can_approve(self): + """Test manager can approve submitted assessments.""" + pathway = self.env["spp.graduation.pathway"].create( + { + "name": "Test Pathway", + } + ) + assessment = self.env["spp.graduation.assessment"].create( + { + "pathway_id": pathway.id, + "partner_id": self.beneficiary.id, + "assessor_id": self.user.id, + } + ) + assessment.action_submit() + assessment.with_user(self.manager).action_approve() + self.assertEqual(assessment.state, "approved") + self.assertEqual(assessment.approved_by_id, self.manager) + + def test_manager_can_reject(self): + """Test manager can reject submitted assessments.""" + pathway = self.env["spp.graduation.pathway"].create( + { + "name": "Test Pathway", + } + ) + assessment = self.env["spp.graduation.assessment"].create( + { + "pathway_id": pathway.id, + "partner_id": self.beneficiary.id, + "assessor_id": self.user.id, + } + ) + assessment.action_submit() + assessment.with_user(self.manager).action_reject() + self.assertEqual(assessment.state, "rejected") diff --git a/spp_graduation/tests/test_pathway.py b/spp_graduation/tests/test_pathway.py index 93db72ff..e79d902a 100644 --- a/spp_graduation/tests/test_pathway.py +++ b/spp_graduation/tests/test_pathway.py @@ -1,5 +1,6 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from odoo.exceptions import ValidationError from odoo.tests.common import TransactionCase @@ -89,3 +90,107 @@ def test_criteria_weight_total(self): total = sum(c.weight for c in pathway.criteria_ids) self.assertEqual(total, 100) + + def test_criteria_weight_must_be_positive(self): + """Test criteria weight constraint rejects zero and negative values.""" + pathway = self.Pathway.create( + { + "name": "Test Pathway", + "code": "TST", + } + ) + + with self.assertRaises(ValidationError): + self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Zero Weight", + "weight": 0, + } + ) + + with self.assertRaises(ValidationError): + self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Negative Weight", + "weight": -5, + } + ) + + def test_pathway_boolean_field_defaults(self): + """Test renamed boolean fields have correct defaults.""" + pathway = self.Pathway.create( + { + "name": "Default Pathway", + } + ) + self.assertTrue(pathway.is_positive_exit) + self.assertTrue(pathway.is_assessment_required) + self.assertTrue(pathway.is_approval_required) + + def test_criteria_assessment_method_values(self): + """Test all assessment method selection values are accepted.""" + pathway = self.Pathway.create({"name": "Method Test Pathway"}) + methods = ["self_report", "verification", "computed", "observation"] + for method in methods: + criteria = self.Criteria.create( + { + "pathway_id": pathway.id, + "name": f"Criterion {method}", + "weight": 1.0, + "assessment_method": method, + } + ) + self.assertEqual(criteria.assessment_method, method) + + def test_criteria_assessment_method_default(self): + """Test assessment method defaults to verification.""" + pathway = self.Pathway.create({"name": "Default Method Pathway"}) + criteria = self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Default Method", + "weight": 1.0, + } + ) + self.assertEqual(criteria.assessment_method, "verification") + + def test_criteria_active_field(self): + """Test criteria active field defaults to True and can be archived.""" + pathway = self.Pathway.create({"name": "Active Test Pathway"}) + criteria = self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Active Criterion", + "weight": 1.0, + } + ) + self.assertTrue(criteria.active) + criteria.active = False + self.assertFalse(criteria.active) + + def test_pathway_description(self): + """Test pathway description field is stored correctly.""" + pathway = self.Pathway.create( + { + "name": "Described Pathway", + "description": "A pathway for testing description storage.", + } + ) + self.assertEqual(pathway.description, "A pathway for testing description storage.") + + def test_criteria_code_and_description(self): + """Test criteria code and description fields are stored correctly.""" + pathway = self.Pathway.create({"name": "Code Test Pathway"}) + criteria = self.Criteria.create( + { + "pathway_id": pathway.id, + "name": "Coded Criterion", + "code": "ECON_01", + "weight": 1.0, + "description": "Measures economic stability.", + } + ) + self.assertEqual(criteria.code, "ECON_01") + self.assertEqual(criteria.description, "Measures economic stability.") diff --git a/spp_graduation/views/graduation_assessment_views.xml b/spp_graduation/views/graduation_assessment_views.xml index e16ceacb..4f5afa6d 100644 --- a/spp_graduation/views/graduation_assessment_views.xml +++ b/spp_graduation/views/graduation_assessment_views.xml @@ -1,26 +1,65 @@-
+





