# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) from datetime import date, timedelta from odoo import fields from odoo.tests import tagged from odoo.tests.common import TransactionCase try: from freezegun import freeze_time except ImportError: # freezegun may not be present on the runtime image freeze_time = None # 2026-06-01 is a Monday, 2026-06-02 a Tuesday, 2026-06-06 a Saturday. MON = date(2026, 6, 1) TUE = date(2026, 6, 2) SAT = date(2026, 6, 6) @tagged('-at_install', 'post_install', 'fusion_clock') class TestScheduleDriven(TransactionCase): """Attendance automation must be driven by the employee's real schedule (posted planner entry -> recurring shift), never the global default.""" @classmethod def setUpClass(cls): super().setUpClass() cls.Employee = cls.env['hr.employee'] cls.Schedule = cls.env['fusion.clock.schedule'] cls.Shift = cls.env['fusion.clock.shift'] cls.Attendance = cls.env['hr.attendance'] cls.Log = cls.env['fusion.clock.activity.log'] cls.ICP = cls.env['ir.config_parameter'].sudo() cls.emp = cls.Employee.create({ 'name': 'Schedule Test', 'x_fclk_enable_clock': True, 'work_email': 'sched.test@example.com', 'tz': 'UTC', }) # Mon-Fri 09:00-17:00 recurring baseline (assigned per-test where needed). cls.shift = cls.Shift.create({ 'name': 'Test Day Shift', 'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0, 'day_mon': True, 'day_tue': True, 'day_wed': True, 'day_thu': True, 'day_fri': True, 'day_sat': False, 'day_sun': False, }) cls.ICP.set_param('fusion_clock.enable_employee_notifications', 'True') cls.ICP.set_param('fusion_clock.max_shift_hours', '16') def _post(self, day, **vals): v = { 'employee_id': self.emp.id, 'schedule_date': day, 'state': 'posted', 'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0, } v.update(vals) return self.Schedule.create(v) # ----- resolver matrix (time-independent) ----- def test_posted_working_is_scheduled(self): self._post(MON) plan = self.emp._get_fclk_day_plan(MON) self.assertTrue(plan['scheduled']) self.assertEqual(plan['source'], 'schedule') def test_posted_off_is_not_scheduled(self): self._post(MON, is_off=True) plan = self.emp._get_fclk_day_plan(MON) self.assertFalse(plan['scheduled']) self.assertTrue(plan['is_off']) self.assertEqual(plan['source'], 'schedule') def test_draft_entry_is_ignored(self): self.emp.x_fclk_shift_id = self.shift self._post(MON, state='draft') # draft on a Monday the shift covers plan = self.emp._get_fclk_day_plan(MON) # Draft ignored -> falls through to the recurring baseline. self.assertTrue(plan['scheduled']) self.assertEqual(plan['source'], 'shift') def test_recurring_shift_covers_weekday(self): self.emp.x_fclk_shift_id = self.shift plan = self.emp._get_fclk_day_plan(MON) self.assertTrue(plan['scheduled']) self.assertEqual(plan['source'], 'shift') def test_recurring_shift_skips_uncovered_weekday(self): self.emp.x_fclk_shift_id = self.shift plan = self.emp._get_fclk_day_plan(SAT) # Saturday not in the pattern self.assertFalse(plan['scheduled']) self.assertEqual(plan['source'], 'none') def test_nothing_scheduled(self): plan = self.emp._get_fclk_day_plan(MON) # no posted entry, no shift self.assertFalse(plan['scheduled']) self.assertEqual(plan['source'], 'none') self.assertEqual(plan['label'], '') # portal card -> "Not scheduled" def test_planner_edit_resets_to_draft(self): posted = self._post(MON) self.assertEqual(posted.state, 'posted') # Re-applying the cell via the planner path must drop it back to draft. self.Schedule.fclk_apply_planner_cell(self.emp, MON, {'input': '8:00 - 16:00'}) self.assertEqual(posted.state, 'draft') # ----- reminder cron ----- def test_no_reminder_when_not_scheduled(self): # Not scheduled today -> the cron must stay completely silent. self.Attendance._cron_fusion_employee_reminders() self.assertNotEqual(self.emp.x_fclk_last_reminder_date, fields.Date.context_today(self.emp)) def test_reminder_fires_for_scheduled_late(self): if freeze_time is None: self.skipTest("freezegun not available") with freeze_time("2026-06-01 12:00:00"): # Monday noon, shift started 09:00 self._post(MON, start_time=9.0) self.Attendance._cron_fusion_employee_reminders() self.assertEqual(self.emp.x_fclk_last_reminder_date, MON) def test_no_early_reminder_for_late_shift(self): if freeze_time is None: self.skipTest("freezegun not available") with freeze_time("2026-06-01 12:00:00"): # noon, but shift starts 14:00 self._post(MON, start_time=14.0, end_time=22.0) self.Attendance._cron_fusion_employee_reminders() self.assertFalse(self.emp.x_fclk_last_reminder_date) # ----- absence cron ----- def test_absence_for_scheduled_noshow(self): if freeze_time is None: self.skipTest("freezegun not available") with freeze_time("2026-06-02 09:00:00"): # Tuesday -> yesterday = Monday self._post(MON) # scheduled Monday, no attendance self.Attendance._cron_fusion_check_absences() self.assertEqual(self.Log.search_count([ ('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'), ]), 1) def test_no_absence_when_not_scheduled(self): if freeze_time is None: self.skipTest("freezegun not available") with freeze_time("2026-06-02 09:00:00"): # yesterday Monday, nothing scheduled self.Attendance._cron_fusion_check_absences() self.assertEqual(self.Log.search_count([ ('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'), ]), 0) # ----- auto clock-out (OT-aware safety cap) ----- def test_auto_clockout_only_past_cap(self): now = fields.Datetime.now() recent = self.Attendance.create({ 'employee_id': self.emp.id, 'check_in': now - timedelta(hours=2), }) emp2 = self.Employee.create({ 'name': 'Schedule Test 2', 'x_fclk_enable_clock': True, 'tz': 'UTC', }) stale = self.Attendance.create({ 'employee_id': emp2.id, 'check_in': now - timedelta(hours=17), }) self.Attendance._cron_fusion_auto_clock_out() self.assertFalse(recent.check_out, "Under-cap shift must stay open (overtime).") self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")