""" TimeCalculator 단위 테스트. """ import os import sys from datetime import datetime, timedelta import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.time_calculator import TimeCalculator @pytest.fixture def calc_8h(): return TimeCalculator(work_hours=8, lunch_duration_minutes=60) @pytest.fixture def calc_short(): """단축근무 7h30m + 점심 30m""" return TimeCalculator(work_minutes=450, lunch_duration_minutes=30) @pytest.fixture def clock_in_9am(): return datetime(2026, 4, 29, 9, 0, 0) class TestClockOutTime: def test_standard_8h_with_lunch(self, calc_8h, clock_in_9am): co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True) assert co == datetime(2026, 4, 29, 18, 0, 0) def test_short_7h30m_with_lunch(self, calc_short, clock_in_9am): co = calc_short.calculate_clock_out_time(clock_in_9am, include_lunch=True) assert co == datetime(2026, 4, 29, 17, 0, 0) def test_no_lunch(self, calc_8h, clock_in_9am): co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=False) assert co == datetime(2026, 4, 29, 17, 0, 0) def test_with_dinner(self, calc_8h, clock_in_9am): co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True, include_dinner=True) assert co == datetime(2026, 4, 29, 19, 0, 0) def test_with_break_minutes(self, calc_8h, clock_in_9am): co_no = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True) co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True, break_minutes=30) assert (co - co_no) == timedelta(minutes=30) @pytest.mark.parametrize("actual_min,expected_earned", [ (29, 0), # 30분 미만 절삭 (30, 30), (35, 30), (60, 60), (89, 60), (90, 90), (120, 120), ]) def test_overtime_30min_truncation(calc_8h, clock_in_9am, actual_min, expected_earned): base_co = clock_in_9am + timedelta(hours=8) actual_co = base_co + timedelta(minutes=actual_min) _, earned = calc_8h.calculate_overtime(clock_in_9am, actual_co, include_lunch=False) assert earned == expected_earned class TestCompatibility: def test_work_hours_property_returns_float(self): c = TimeCalculator(work_minutes=450) assert c.work_hours == 7.5 def test_work_hours_constructor_accepts_float(self): c = TimeCalculator(work_hours=7.5, lunch_duration_minutes=30) assert c.work_minutes == 450 def test_work_minutes_takes_precedence(self): # 둘 다 주면 work_minutes 우선 c = TimeCalculator(work_hours=8, work_minutes=450) assert c.work_minutes == 450 def test_default_8_hours(self): c = TimeCalculator() assert c.work_minutes == 480 class TestDayType: def test_weekend(self): calc = TimeCalculator() sat = datetime(2026, 5, 2) assert calc.is_weekend(sat) assert calc.get_day_type(sat) == 'weekend' def test_weekday(self): calc = TimeCalculator() mon = datetime(2026, 5, 4) assert not calc.is_weekend(mon) assert calc.get_day_type(mon) == 'normal' class TestHolidayOvertime: """휴일/주말 근무 적립 — 출근 직후부터 모든 시간이 연장으로.""" def test_zero_elapsed_returns_zero(self, calc_8h): ci = datetime(2026, 5, 1, 9, 0) actual, earned = calc_8h.calculate_holiday_overtime(ci, ci) assert actual == 0 and earned == 0 def test_one_minute_elapsed_no_lunch(self, calc_8h): ci = datetime(2026, 5, 1, 9, 0) now = ci + timedelta(minutes=1) actual, earned = calc_8h.calculate_holiday_overtime(ci, now) assert actual == 1 assert earned == 0 # 30분 단위 절삭 def test_30min_elapsed_truncates_to_30(self, calc_8h): ci = datetime(2026, 5, 1, 9, 0) now = ci + timedelta(minutes=30) actual, earned = calc_8h.calculate_holiday_overtime(ci, now) assert actual == 30 and earned == 30 def test_29min_elapsed_truncates_to_zero(self, calc_8h): ci = datetime(2026, 5, 1, 9, 0) now = ci + timedelta(minutes=29) actual, earned = calc_8h.calculate_holiday_overtime(ci, now) assert actual == 29 and earned == 0 def test_lunch_subtracted(self, calc_8h): # 8h 근무 + 점심 60m → 9h 일했지만 점심 차감 = 8h 적립 ci = datetime(2026, 5, 1, 9, 0) now = ci + timedelta(hours=9) actual, earned = calc_8h.calculate_holiday_overtime( ci, now, include_lunch=True ) assert actual == 8 * 60 assert earned == 8 * 60 def test_break_minutes_subtracted(self, calc_8h): ci = datetime(2026, 5, 1, 9, 0) now = ci + timedelta(hours=2) # 외출 30분 → 90분 적립 actual, earned = calc_8h.calculate_holiday_overtime( ci, now, break_minutes=30 ) assert actual == 90 and earned == 90 def test_unit_minutes_15(self, calc_8h): ci = datetime(2026, 5, 1, 9, 0) now = ci + timedelta(minutes=44) # 44분 → 30분 적립 (15분 단위) actual, earned = calc_8h.calculate_holiday_overtime( ci, now, unit_minutes=15 ) assert actual == 44 and earned == 30 def test_unit_minutes_60(self, calc_8h): ci = datetime(2026, 5, 1, 9, 0) now = ci + timedelta(minutes=119) # 119분 → 60분 적립 (60분 단위) actual, earned = calc_8h.calculate_holiday_overtime( ci, now, unit_minutes=60 ) assert actual == 119 and earned == 60 def test_negative_clamped_to_zero(self, calc_8h): # 점심 60m + 저녁 60m = 120m 차감되는데 1시간만 일하면 음수 ci = datetime(2026, 5, 1, 9, 0) now = ci + timedelta(hours=1) actual, earned = calc_8h.calculate_holiday_overtime( ci, now, include_lunch=True, include_dinner=True ) assert actual == 0 and earned == 0