Clock_out_Time_Calculator/tests/test_time_calculator.py
KINDNICK d41e5cb921 fix(holiday): live hot-path now banks overtime from minute 1 on holidays
3 bugs found and fixed:

A. update_display() 1Hz 루프에 is_non_working_day 분기 누락
   - 휴일/주말 출근 시 '남은 시간 8h'부터 카운트다운 → 실제 휴일 추가근무 시작 안 됨
   - 수정: is_non_working_day=True면 calculate_holiday_overtime로 즉시 음수 remaining 표시
   - 그룹 타이틀: '주말 근무 (전체 적립)' / '공휴일 근무 (전체 적립)'
   - 진행바: 휴일은 100% 고정 (의미 없음)
   - 예상 퇴근: '휴일 근무 (정해진 퇴근시각 없음)'

B. check_clock_out_soon 알림이 휴일에도 fire
   - 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림
   - 수정: orchestrator.tick(is_holiday=True)면 스킵
   - 점심/저녁/장시간 휴식 알림은 휴일에도 유지 (식사·건강은 휴일에도 챙김)

C. 자동복구 퇴근 3곳이 (work_minutes // 30) * 30 하드코딩
   - main_window.py:1385, 1512, 1581 — 사용자 overtime_unit (15/30/60) 무시
   - 수정: 모두 settings에서 unit_minutes 읽어 calculate_holiday_overtime/calculate_overtime에 전달

리팩터링: 4곳에 중복되던 휴일 연장 계산 로직을 TimeCalculator.calculate_holiday_overtime로 추출.

Tests:
- tests/test_time_calculator.py: 9개 신규 (TestHolidayOvertime)
- _integration_test.py: S52A 휴일 hot-path 회귀 시나리오
2026-05-01 12:54:24 +09:00

175 lines
6.0 KiB
Python

"""
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