Fixed: - clock_out()이 datetime.now().date() 행에 퇴근을 써서 자정 넘긴 퇴근이 UPDATE 0건으로 유실되던 버그. 출근일(근무일) 기준으로 수정. cancel_clock_out() 및 주말/공휴일 판정·외출 집계도 동일 기준으로 통일. - 캘린더 시간 수정 / 과거기록 추가가 퇴근<=출근을 반려해 익일 퇴근을 저장할 수 없던 문제. 익일로 해석하고 동시각만 반려. 총 근무시간·연장근무 음수 계산도 수정. Added: - generate_daily_report(target_date) 날짜 파라미터화 + 캘린더 '보고서 복사' 버튼. 기본값은 진행 중인 근무일(경계시간 전이면 출근일). - 연차 직접입력 버튼(메인 창) — 미연결 상태였던 use_custom_leave()를 기존 AddLeaveDialog 재사용으로 연결. - resolve_work_datetimes() / is_overnight() 공용 헬퍼 + 유닛 테스트 6개. Changed: - 연차 빠른 사용에서 날짜·메모 팝업 제거 (연장근무와 동일하게 확인 1회). - get_today_overtime_usage() / get_total_break_minutes_today()에 선택적 date_str 파라미터 추가. 테스트: 유닛 200 / 통합 53 / GUI 8 / i18n 5 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
211 lines
7.6 KiB
Python
211 lines
7.6 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, resolve_work_datetimes, is_overnight
|
|
|
|
|
|
@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
|
|
|
|
|
|
class TestResolveWorkDatetimes:
|
|
"""익일(자정 넘김) 퇴근 해석 — 야근 기록 저장/수정의 기준."""
|
|
|
|
def test_same_day(self):
|
|
ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '18:00:00')
|
|
assert ci == datetime(2026, 5, 1, 9, 0)
|
|
assert co == datetime(2026, 5, 1, 18, 0)
|
|
|
|
def test_overnight_clock_out_rolls_to_next_day(self):
|
|
ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '01:30:00')
|
|
assert co == datetime(2026, 5, 2, 1, 30)
|
|
assert (co - ci) == timedelta(hours=16, minutes=30)
|
|
|
|
def test_equal_times_stay_same_day(self):
|
|
# 동시각은 익일로 밀지 않음 (UI에서 반려하는 케이스)
|
|
ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '09:00:00')
|
|
assert ci == co
|
|
|
|
def test_accepts_hh_mm(self):
|
|
ci, co = resolve_work_datetimes('2026-05-01', '09:00', '01:30')
|
|
assert (co - ci) == timedelta(hours=16, minutes=30)
|
|
|
|
def test_is_overnight(self):
|
|
assert is_overnight('09:00:00', '01:30:00') is True
|
|
assert is_overnight('09:00:00', '18:00:00') is False
|
|
assert is_overnight('09:00:00', '') is False
|
|
|
|
def test_overtime_uses_resolved_datetimes(self, calc_8h):
|
|
"""익일 퇴근도 정상 연장근무로 계산되는지 (음수 방지)."""
|
|
ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '01:30:00')
|
|
actual, earned = calc_8h.calculate_overtime(ci, co, include_lunch=True)
|
|
# 09:00 출근 + 8h + 점심 1h → 정시 18:00, 익일 01:30 퇴근 = 450분 초과
|
|
assert actual == 450
|
|
assert earned == 450
|