# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) from datetime import datetime, timedelta from odoo.tests import tagged, TransactionCase from odoo.exceptions import ValidationError @tagged('-at_install', 'post_install', 'fusion_clock') class TestBreakRules(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True') cls.Rule = cls.env['fusion.clock.break.rule'] cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1) # Flexible calendar -> hr.attendance.worked_hours is the raw check_in..check_out # span (no lunch interval subtracted), so the tier math below is deterministic. # This also mirrors real clock-in/out employees, who are effectively flexible. flex = cls.env['resource.calendar'].create({ 'name': 'FCLK Flexible', 'flexible_hours': True, }) cls.employee = cls.env['hr.employee'].create({ 'name': 'FCLK Break Test', 'resource_calendar_id': flex.id, }) def _mk_att(self, hours): check_in = datetime(2026, 1, 5, 9, 0, 0) return self.env['hr.attendance'].create({ 'employee_id': self.employee.id, 'check_in': check_in, 'check_out': check_in + timedelta(hours=hours), }) # ---- Task 1: tier engine + constraints ---- def test_break_minutes_for_tiers(self): rule = self.Rule.create({ 'name': 'Tier Test', 'is_default': False, 'break1_after_hours': 5.0, 'break1_minutes': 30.0, 'break2_after_hours': 10.0, 'break2_minutes': 30.0, }) self.assertEqual(rule.break_minutes_for(4.99), 0.0) self.assertEqual(rule.break_minutes_for(5.0), 30.0) self.assertEqual(rule.break_minutes_for(9.99), 30.0) self.assertEqual(rule.break_minutes_for(10.0), 60.0) self.assertEqual(rule.break_minutes_for(12.0), 60.0) def test_second_tier_must_exceed_first(self): with self.assertRaises(ValidationError): self.Rule.create({ 'name': 'Bad', 'is_default': False, 'break1_after_hours': 5.0, 'break1_minutes': 30.0, 'break2_after_hours': 5.0, 'break2_minutes': 30.0, }) def test_single_default_enforced(self): self.assertTrue(self.default_rule, "seed default rule must exist") with self.assertRaises(ValidationError): self.Rule.create({ 'name': 'Another Default', 'is_default': True, 'active': True, 'break1_after_hours': 5.0, 'break1_minutes': 30.0, 'break2_after_hours': 10.0, 'break2_minutes': 30.0, }) # ---- Task 2: jurisdiction resolver ---- def test_resolver_matches_company_province(self): bc = self.env.ref('base.state_ca_bc') bc_rule = self.Rule.create({ 'name': 'British Columbia', 'state_id': bc.id, 'is_default': False, 'break1_after_hours': 5.0, 'break1_minutes': 30.0, 'break2_after_hours': 10.0, 'break2_minutes': 30.0, }) self.employee.company_id.state_id = bc.id self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule) def test_resolver_falls_back_to_default(self): self.assertTrue(self.default_rule, "seed default rule must exist") alberta = self.env.ref('base.state_ca_ab') # no rule for AB self.employee.company_id.state_id = alberta.id self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule) # ---- Task 3: automatic deduction on every path ---- def test_manual_attendance_applies_statutory_break(self): att = self._mk_att(6) # 6h >= 5 -> first break self.assertEqual(att.x_fclk_break_minutes, 30.0) self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2) def test_manual_edit_extends_break(self): att = self._mk_att(6) self.assertEqual(att.x_fclk_break_minutes, 30.0) att.check_out = att.check_in + timedelta(hours=10) # now >= 10 self.assertEqual(att.x_fclk_break_minutes, 60.0) self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2) def test_under_first_threshold_no_break(self): att = self._mk_att(4) # 4h < 5 -> nothing self.assertEqual(att.x_fclk_break_minutes, 0.0) self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2) def test_penalty_minutes_are_additive(self): att = self._mk_att(6) # statutory 30 self.env['fusion.clock.penalty'].create({ 'attendance_id': att.id, 'employee_id': self.employee.id, 'penalty_type': 'early_out', 'penalty_minutes': 15.0, 'date': att.check_in.date(), }) self.assertEqual(att.x_fclk_break_minutes, 45.0) def test_master_toggle_off_zero_statutory(self): self.ICP.set_param('fusion_clock.auto_deduct_break', 'False') att = self._mk_att(6) self.assertEqual(att.x_fclk_break_minutes, 0.0) def test_open_attendance_zero_break(self): att = self.env['hr.attendance'].create({ 'employee_id': self.employee.id, 'check_in': datetime(2026, 1, 5, 9, 0, 0), }) self.assertEqual(att.x_fclk_break_minutes, 0.0)