Clock_out_Time_Calculator/_integration_test.py
KINDNICK c98ca361cd feat(leave): \uc5f0\ucc28 \ubbf8\ub9ac \ub4f1\ub85d + \uc218\uc544\ud55c \uc790\ub3d9 \uc801\uc6a9 + \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
Phase 1 \u2014 \ubbf8\ub9ac \uc5f0\ucc28 \ub4f1\ub85d
- DB: get_leave_minutes_for(date) / has_full_day_leave(date) /
  get_leave_records_by_date(date) / get_leave_records_by_range(start, end)
- TimeCalculator.effective_work_minutes(date_obj, db): \uc5f0\ucc28 \ubd84\ub9cc\ud07c \uc815\uaddc \uadfc\ubb34 \ucc28\uac10
- update_display() 1Hz hot-path:
    \u2022 \uc885\uc77c \uc5f0\ucc28 \ub4f1\ub85d\uc77c + \ucd9c\uadfc \uc548 \ud55c \uc0c1\ud0dc \u2192 "\ud83c\udf34 \uc624\ub298\uc740 \ud734\uac00" \uce74\ub4dc \ud45c\uc2dc, \uce74\uc6b4\ud2b8\ub2e4\uc6b4 \uc81c\uac70
    \u2022 \uc885\uc77c \uc5f0\ucc28 + \ucd9c\uadfc override \u2192 \ud734\uc77c\ucc98\ub7fc \uc804\uccb4 \uc801\ub9bd
    \u2022 \ubd80\ubd84 \uc5f0\ucc28(\ubc18\ucc28/\uc2dc\uac04) \u2192 leave_used_today \uacbd\ub85c\ub85c \uae30\uc874 \ub2e8\ucd95 \uacc4\uc0b0 \uc720\uc9c0
- \uc790\ub3d9 \ucd9c\uadfc\uac10\uc9c0 \uac00\ub4dc: load_today_data\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c\uc774\uba74 event_monitor \ud638\ucd9c \uc790\uccb4 \uc2a4\ud0b5
- \uc218\ub3d9 \ucd9c\uadfc \uac00\ub4dc: manual_clock_in\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c \ud655\uc778 \ud504\ub86c\ud504\ud2b8
- AddLeaveDialog \uac80\uc99d \uac15\ud654:
    \u2022 \ubbf8\ub798 1\ub144\uae4c\uc9c0 setMaximumDate
    \u2022 \uc8fc\ub9d0/\uacf5\ud734\uc77c \ub4f1\ub85d \ucc28\ub2e8 (\uc774\ubbf8 \ube44\uadfc\ubb34\uc77c)
    \u2022 \uac19\uc740 \ub0a0 1\uc77c \ucd08\uacfc \ub204\uc801 \ucc28\ub2e8
- leave_calendar_view: \uc608\uc815(\ud30c\ub791) / \uc0ac\uc6a9\uc644\ub8cc(\ub179/\ub178/\ubcf4) \uc0c9\uc0c1 \ubd84\ub9ac

Phase 2 \u2014 \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
- recurring_leaves \ud14c\uc774\ube14 (pattern/leave_type/days/start/end/memo)
- core/recurring_leaves.py: weekly / biweekly / monthly \ud328\ud134 \ud30c\uc11c + expand_for_range/date
- get_leave_minutes_for() / has_full_day_leave()\uac00 \ubc18\ubcf5 \ud328\ud134\ub3c4 \ud568\uaed8 \ud569\uc0b0
- ui/recurring_leave_dialog.py: \ub9e4\uc8fc/\uaca9\uc8fc/\ub9e4\uc6d4 \uc785\ub825 + \uc785\ub825 \ub9ac\uc2a4\ud2b8 \uad00\ub9ac
- ui/schedule_view.py: \uc6d4\uac04 \uc2a4\ud50c\ub9ac\ud130 \ub808\uc774\uc544\uc6c3 (\uce98\ub9b0\ub354 + \uc0c1\uc138)
    \u2022 \ud734\uc77c(\ube68\uac15) / \uc5f0\ucc28 \uc0ac\uc6a9(\ub179\u30fb\ub178\u30fb\ubcf4) / \uc608\uc815(\ud30c\ub791) / \ubc18\ubcf5(\ud68c\uc0c9) \uc0c9 \ucf54\ub4dc
    \u2022 \ub0a0\uc9dc \ud074\ub9ad \u2192 \uc0c1\uc138 \ud328\ub110 (\ub3d9\uc77c\uc77c\uc790 \uad6c\uccb4 \uc5f0\ucc28 + \ubc18\ubcf5 \ub9e4\uce58)
    \u2022 \ub9ac\uc2a4\ud2b8 \uc6b0\ud074\ub9ad \uc0ad\uc81c (\uad6c\uccb4 / \ubc18\ubcf5 \uad6c\ubd84)
    \u2022 \uc6d4 \ubcc0\uacbd \uc2dc \uc790\ub3d9 reload
- \uc9c4\uc785\uc810: main_window.show_schedule(), tray menu '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904', LeaveView '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904' \ubc84\ud2bc

Tests
- tests/test_recurring_leaves.py 32\uac1c (\ud328\ud134 \ud30c\uc2f1 / \ub9e4\uce6d / expand / describe)
- tests/test_database.py +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- _integration_test.py +4 (S52B-S52E)
- pytest: 122 \u2192 175 \uc804\ubd80 green
- \ud1b5\ud569: 49 \u2192 53 \uc804\ubd80 green
- UI-5/UI-7 \uae30\uc874 \uace0\uc7a5 (v2.8.0 \ub514\uc790\uc778 \ub9ac\ub274\uc5bc \ub9c8\ub108)
2026-05-01 13:07:52 +09:00

860 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
실사용 시나리오 통합 검증.
GUI 없이 비즈니스 로직 + DB + 알림 + API + 백업 + 마이그레이션을 시나리오별로 검증.
실패 시 어떤 시나리오가 깨졌는지 명확히 출력.
"""
from __future__ import annotations
import os
import sys
import shutil
import tempfile
import sqlite3
from datetime import date, datetime, timedelta
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
PASS = []
FAIL = []
WARN = []
def case(label):
"""테스트 케이스 데코레이터."""
def deco(fn):
def wrapper(*args, **kwargs):
try:
fn(*args, **kwargs)
PASS.append(label)
print(f"[PASS] {label}")
except AssertionError as e:
FAIL.append((label, str(e)))
print(f"[FAIL] {label}: {e}")
except Exception as e:
FAIL.append((label, f"{type(e).__name__}: {e}"))
print(f"[ERROR] {label}: {type(e).__name__}: {e}")
return wrapper
return deco
def fresh_db(name='_test') -> 'Database':
from core.database import Database
p = os.path.join(tempfile.gettempdir(), f'clockout_{name}.db')
if os.path.exists(p):
os.remove(p)
return Database(p)
# ============================================================
# 시나리오 1: 신규 사용자 첫 실행 — 마이그레이션 + 기본값
# ============================================================
@case("S1. 신규 DB: 모든 기본값 설정 키가 채워짐")
def s1_fresh_install():
db = fresh_db('s1')
expected_keys = ['work_hours', 'work_minutes', 'lunch_duration_minutes',
'dinner_duration_minutes', 'auto_lunch', 'theme',
'notification_clock_out', 'notification_lunch',
'notification_overtime', 'notification_health',
'annual_leave_total', 'annual_leave_days',
'workday_boundary_hour', 'overtime_unit', 'time_format']
for k in expected_keys:
v = db.get_setting(k)
assert v is not None, f"missing default: {k}"
assert db.get_work_minutes() == 480
assert db.get_setting('annual_leave_keys_migrated') == 'true'
@case("S2. 레거시 DB(work_hours만) → work_minutes 자동 마이그레이션")
def s2_migration():
p = os.path.join(tempfile.gettempdir(), 'clockout_legacy.db')
if os.path.exists(p): os.remove(p)
conn = sqlite3.connect(p)
conn.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT, updated_at TIMESTAMP)")
conn.execute("INSERT INTO settings VALUES ('work_hours', '8', CURRENT_TIMESTAMP)")
conn.execute("INSERT INTO settings VALUES ('lunch_duration', '1', CURRENT_TIMESTAMP)")
conn.execute("INSERT INTO settings VALUES ('annual_leave_total', '15', CURRENT_TIMESTAMP)")
conn.commit()
conn.close()
from core.database import Database
db = Database(p)
assert db.get_setting('work_minutes') == '480'
assert db.get_setting('lunch_duration_minutes') == '60'
# 양쪽 동기화
assert db.get_setting('annual_leave_days') == '15'
os.remove(p)
# ============================================================
# 시나리오 3-6: 다양한 근무 패턴
# ============================================================
@case("S3. 단축근무 7h30m + 점심30m → 09:00 출근 → 17:00 퇴근")
def s3_short_work():
from core.time_calculator import TimeCalculator
calc = TimeCalculator(work_minutes=450, lunch_duration_minutes=30)
ci = datetime(2026, 4, 29, 9, 0)
co = calc.calculate_clock_out_time(ci, include_lunch=True)
assert co == datetime(2026, 4, 29, 17, 0), f"got {co}"
@case("S4. 표준 8h + 점심60m → 09:00 → 18:00, 1h35m 연장 → 90분 적립")
def s4_standard():
from core.time_calculator import TimeCalculator
calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60)
ci = datetime(2026, 4, 29, 9, 0)
co = calc.calculate_clock_out_time(ci, include_lunch=True)
assert co == datetime(2026, 4, 29, 18, 0)
actual = co + timedelta(hours=1, minutes=35)
a, e = calc.calculate_overtime(ci, actual, include_lunch=True)
assert a == 95 and e == 90
@case("S5. 반일 4시간 + 점심없음 → 09:00 → 13:00")
def s5_half_day():
from core.time_calculator import TimeCalculator
calc = TimeCalculator(work_minutes=240, lunch_duration_minutes=0)
ci = datetime(2026, 4, 29, 9, 0)
co = calc.calculate_clock_out_time(ci, include_lunch=False)
assert co == datetime(2026, 4, 29, 13, 0)
@case("S6. 야근 (저녁 적용): 8h + 점심60m + 저녁60m → 09:00 → 19:00")
def s6_dinner():
from core.time_calculator import TimeCalculator
calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60, dinner_duration_minutes=60)
ci = datetime(2026, 4, 29, 9, 0)
co = calc.calculate_clock_out_time(ci, include_lunch=True, include_dinner=True)
assert co == datetime(2026, 4, 29, 19, 0)
@case("S7. 외출 30분 추가 시 퇴근 시각 30분 뒤로")
def s7_break():
from core.time_calculator import TimeCalculator
calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60)
ci = datetime(2026, 4, 29, 9, 0)
co_no = calc.calculate_clock_out_time(ci, include_lunch=True)
co_break = calc.calculate_clock_out_time(ci, include_lunch=True, break_minutes=30)
assert (co_break - co_no) == timedelta(minutes=30)
# ============================================================
# 시나리오 8-10: 연장근무 절삭/잔액
# ============================================================
@case("S8. 연장근무 30분 절삭: 29분→0, 30분→30, 35분→30, 60분→60")
def s8_truncation():
from core.time_calculator import TimeCalculator
calc = TimeCalculator(work_hours=8)
ci = datetime(2026, 4, 29, 9, 0)
base_co = ci + timedelta(hours=8)
cases = [(29, 0), (30, 30), (35, 30), (60, 60), (89, 60), (90, 90)]
for actual_min, expected_earned in cases:
co = base_co + timedelta(minutes=actual_min)
_, earned = calc.calculate_overtime(ci, co, include_lunch=False)
assert earned == expected_earned, f"{actual_min}min → {earned}, expected {expected_earned}"
@case("S9. 연장근무 잔액: bank - usage 합산")
def s9_balance():
db = fresh_db('s9')
today = date.today().isoformat()
db.add_initial_overtime_balance(180) # 3시간 적립
bal = db.get_total_overtime_balance()
assert bal == 180, f"after add 3h: {bal}"
db.add_overtime_usage(today, 60, '1h 사용')
bal = db.get_total_overtime_balance()
assert bal == 120, f"after use 1h: {bal}"
# ============================================================
# 시나리오 10-11: 연차
# ============================================================
@case("S10. 단축근무자 1일 연차 = 분 단위 정확 환산 (7h30m → 450분)")
def s10_leave_minutes():
db = fresh_db('s10')
db.save_settings({'work_minutes': 450})
today = date.today().isoformat()
db.add_leave_record(today, 'annual', 1.0)
assert db.get_today_leave_minutes() == 450
@case("S11. 반차/반반차: 0.5일 → 4시간, 0.25일 → 2시간 (8h 기준)")
def s11_half_leave():
db = fresh_db('s11')
today = date.today().isoformat()
# 8h 근무자 기본 (work_minutes=480)
db.add_leave_record(today, 'half_am', 0.5)
assert db.get_today_leave_minutes() == 240
# 0.25 추가하여 누적
db.add_leave_record(today, 'quarter', 0.25)
assert db.get_today_leave_minutes() == 360 # 0.75 * 480
# ============================================================
# 시나리오 12: 공휴일/주말
# ============================================================
@case("S12. 주말 근무: 모든 시간이 연장근무로 인정 (TimeCalculator.is_weekend)")
def s12_weekend():
from core.time_calculator import TimeCalculator
calc = TimeCalculator()
sat = datetime(2026, 5, 2, 9, 0) # Saturday
assert calc.is_weekend(sat)
mon = datetime(2026, 5, 4, 9, 0)
assert not calc.is_weekend(mon)
assert calc.get_day_type(sat) == 'weekend'
@case("S13. 공휴일 자동 등록 (holidays 패키지 사용 가능 시)")
def s13_holidays():
db = fresh_db('s13')
n = db.add_korean_holidays_auto(2026)
if n == -1:
WARN.append("S13: holidays 패키지 미설치 — fallback 동작 OK")
# fallback도 가능한지 확인
db.add_korean_holidays(2026)
hols = db.get_holidays_by_year(2026)
assert len(hols) >= 8, f"fixed holidays: {len(hols)}"
else:
hols = db.get_holidays_by_year(2026)
assert n >= 15, f"only {n} added (expected music holidays included)"
# 음력 명절 포함 확인
names = [h['name'] for h in hols]
has_lunar = any('설날' in nm or '추석' in nm for nm in names)
assert has_lunar, f"missing lunar holidays: {names[:5]}"
# ============================================================
# 시나리오 14-15: 알림 가드
# ============================================================
@case("S14. 알림 가드: notification_clock_out=false → 30분 전 알림 안 옴")
def s14_notif_off():
from core.notifier import Notifier
db = fresh_db('s14')
db.set_setting('notification_clock_out', 'false')
n = Notifier(db=db)
fired = []
n.notification_signal.connect(lambda t, m: fired.append((t, m)))
n.check_clock_out_soon(datetime.now() + timedelta(minutes=20), datetime.now())
assert len(fired) == 0, f"should be guarded: {fired}"
@case("S15. 알림 가드: notification_clock_out=true → 알림 발생")
def s15_notif_on():
from core.notifier import Notifier
db = fresh_db('s15')
db.set_setting('notification_clock_out', 'true')
n = Notifier(db=db)
fired = []
n.notification_signal.connect(lambda t, m: fired.append((t, m)))
n.check_clock_out_soon(datetime.now() + timedelta(minutes=20), datetime.now())
assert len(fired) == 1
assert '퇴근' in fired[0][0]
@case("S16. 건강 경고: 3일 연속 연장근무 시에만 fire (notified_health flag)")
def s16_health():
from core.notifier import Notifier
db = fresh_db('s16')
n = Notifier(db=db)
fired = []
n.notification_signal.connect(lambda t, m: fired.append((t, m)))
n.notify_health_warning(2) # 미달
assert len(fired) == 0
n.notify_health_warning(3) # 발화
assert len(fired) == 1
n.notify_health_warning(5) # 같은 날 중복 안 됨
assert len(fired) == 1
@case("S17. consecutive_overtime_days: 연속 연장근무 카운트")
def s17_consecutive():
db = fresh_db('s17')
today = date.today()
# 3일 연속 연장 (오늘부터 거꾸로)
for i in range(3):
d = (today - timedelta(days=i)).isoformat()
rid = db.add_work_record(d, '09:00:00')
db.update_clock_out(d, '20:00:00', total_hours=11.0,
overtime_minutes=120, overtime_earned=120)
# 4일 전엔 미연장
d4 = (today - timedelta(days=3)).isoformat()
db.add_work_record(d4, '09:00:00')
db.update_clock_out(d4, '18:00:00', total_hours=8.0,
overtime_minutes=0, overtime_earned=0)
assert db.get_consecutive_overtime_days() == 3
# ============================================================
# 시나리오 18-19: 백업
# ============================================================
@case("S18. 백업: 첫 호출 시 파일 생성, 같은 날 두번째 호출은 skip")
def s18_backup():
from utils.backup import backup_db_if_needed
db = fresh_db('s18')
bdir = Path(tempfile.gettempdir()) / 'clockout_test_backups'
if bdir.exists():
shutil.rmtree(bdir)
src = db.db_path
r1 = backup_db_if_needed(db, source_path=src, backup_dir=bdir, keep=3)
assert r1 is not None and r1.exists()
r2 = backup_db_if_needed(db, source_path=src, backup_dir=bdir, keep=3)
assert r2 is None # 같은 날
shutil.rmtree(bdir)
@case("S19. 백업 회전: keep=3 초과 시 오래된 파일 삭제")
def s19_backup_rotate():
from utils.backup import _rotate
bdir = Path(tempfile.gettempdir()) / 'clockout_rotate'
if bdir.exists():
shutil.rmtree(bdir)
bdir.mkdir(parents=True)
# 5개 백업 가짜 생성
for i in range(5):
f = bdir / f'database-2026-04-{20+i:02d}.db'
f.write_text('dummy')
# 다른 mtime 부여
ts = (datetime.now() - timedelta(days=5-i)).timestamp()
os.utime(f, (ts, ts))
_rotate(bdir, keep=3)
remaining = list(bdir.glob('database-*.db'))
assert len(remaining) == 3, f"after rotate: {len(remaining)}"
# 최신 3개만 남았는지
names = sorted(r.name for r in remaining)
assert '2026-04-22' in names[0] # 오래된 2개 삭제
shutil.rmtree(bdir)
# ============================================================
# 시나리오 23-24: 설정 동기화
# ============================================================
@case("S23. save_settings: work_minutes만 보내도 work_hours 자동 동기화 (floor)")
def s23_sync_work():
db = fresh_db('s23')
db.save_settings({'work_minutes': 450})
assert db.get_setting('work_hours') == '7' # floor(7.5)
assert db.get_setting('work_minutes') == '450'
@case("S24. save_settings: annual_leave_days ↔ annual_leave_total 양방향")
def s24_sync_leave():
db = fresh_db('s24')
db.save_settings({'annual_leave_days': 20})
assert db.get_setting('annual_leave_total') == '20'
db2 = fresh_db('s24b')
db2.save_settings({'annual_leave_total': 18})
assert db2.get_setting('annual_leave_days') == '18'
@case("S25. get_setting_int/float/bool 헬퍼: 잘못된 값 → default")
def s25_setting_helpers():
db = fresh_db('s25')
db.set_setting('valid_int', '42')
db.set_setting('invalid_int', 'abc')
db.set_setting('truthy', 'yes')
db.set_setting('falsy', 'no')
assert db.get_setting_int('valid_int') == 42
assert db.get_setting_int('invalid_int', 99) == 99
assert db.get_setting_int('missing', 7) == 7
assert db.get_setting_bool('truthy') == True
assert db.get_setting_bool('falsy') == False
# ============================================================
# 시나리오 26: i18n
# ============================================================
@case("S26. i18n: ko ↔ en 전환, 미번역 키 fallback")
def s26_i18n():
from core.i18n import tr, set_language
set_language('ko')
assert '저장' in tr('btn.save')
set_language('en')
assert 'Save' in tr('btn.save')
# 존재하지 않는 키
assert tr('missing.key') == 'missing.key'
# 포맷 인자
set_language('ko')
msg = tr('notif.clock_out_soon.body', minutes=15)
assert '15' in msg
# 영어로도 포맷 인자 동작
set_language('en')
msg_en = tr('notif.clock_out_soon.body', minutes=15)
assert '15' in msg_en and 'minutes' in msg_en
# 사전에 정의된 새 키들도 한국어/영어 둘 다 OK
set_language('en')
assert tr('menu.stats') == 'Stats'
set_language('ko')
assert tr('menu.stats') == '통계'
# ============================================================
# 시나리오 28-30: 에지 케이스
# ============================================================
@case("S28. work_minutes=0 입력 → settings_view가 거부 (UI 단계, 비즈니스 로직 직접 호출은 허용)")
def s28_zero_work():
from core.time_calculator import TimeCalculator
# TimeCalculator는 0도 허용 (UI에서 검증)
calc = TimeCalculator(work_minutes=0, lunch_duration_minutes=0)
ci = datetime(2026, 4, 29, 9, 0)
co = calc.calculate_clock_out_time(ci, include_lunch=False)
# 0 근무 → 출근 즉시 퇴근
assert co == ci
@case("S29. 자정 넘김 외출 시간 계산 (break_in이 다음 날)")
def s29_midnight_break():
# break 시간 계산은 main_window 내부 로직이라 직접 검증 어려움
# 대신 datetime 비교 로직만 빠르게 확인
break_out = datetime(2026, 4, 29, 23, 30)
break_in = datetime(2026, 4, 30, 0, 30)
# 같은 날짜로 잘못 만들면 음수가 나옴
same_day_in = datetime(2026, 4, 29, 0, 30)
if same_day_in < break_out:
same_day_in += timedelta(days=1)
assert (same_day_in - break_out) == timedelta(hours=1)
@case("S30. holidays 양력만 fallback: add_korean_holidays 8개 등록")
def s30_holidays_fallback():
db = fresh_db('s30')
db.add_korean_holidays(2026)
hols = db.get_holidays_by_year(2026)
assert len(hols) == 8
# ============================================================
# 시나리오 31-33: 마이그레이션 idempotency
# ============================================================
@case("S31. annual_leave_keys_migrated sentinel: 두번째 init은 skip")
def s31_migration_idempotent():
p = os.path.join(tempfile.gettempdir(), 'clockout_idem.db')
if os.path.exists(p): os.remove(p)
from core.database import Database
db1 = Database(p)
assert db1.get_setting('annual_leave_keys_migrated') == 'true'
# 다시 init — sentinel이 있으면 스킵
db2 = Database(p)
assert db2.get_setting('annual_leave_keys_migrated') == 'true'
os.remove(p)
@case("S32. UI imports: 모든 view 모듈 정상 import")
def s32_ui_imports():
# PyQt5 의존이라 헤드리스에선 import만 확인
from ui import main_window, settings_view, calendar_view, help_view
from ui import mini_widget, chart_widget, stats_view, break_view
from ui import overtime_view, leave_view, clock_in_dialog, styles
@case("S33. 단축근무 사용자 종합: 7h30m 근무 + 30분 연장 시 = 적립 0분 (절삭)")
def s33_short_overtime():
from core.time_calculator import TimeCalculator
calc = TimeCalculator(work_minutes=450, lunch_duration_minutes=30)
ci = datetime(2026, 4, 29, 9, 0)
base_co = calc.calculate_clock_out_time(ci, include_lunch=True) # 17:00
# 17:29 퇴근 → 29분 연장 → 0분 적립
actual_29 = base_co + timedelta(minutes=29)
_, e29 = calc.calculate_overtime(ci, actual_29, include_lunch=True)
assert e29 == 0
# 17:30 퇴근 → 30분 연장 → 30분 적립
actual_30 = base_co + timedelta(minutes=30)
_, e30 = calc.calculate_overtime(ci, actual_30, include_lunch=True)
assert e30 == 30
@case("S34. format_hours_minutes: 다양한 분 입력 변환")
def s34_format():
from utils.time_format import format_hours_minutes
assert format_hours_minutes(0) == '0시간 0분'
assert format_hours_minutes(0, omit_zero_minutes=True) == '0분'
assert format_hours_minutes(60, omit_zero_minutes=True) == '1시간'
assert format_hours_minutes(90) == '1시간 30분'
assert format_hours_minutes(-90) == '1시간 30분' # abs
assert format_hours_minutes(450) == '7시간 30분'
@case("S35. lock_detector: 정상 환경에선 False (현재 PC 잠겨있지 않음)")
def s35_lock():
from utils.lock_detector import is_screen_locked
# 헤드리스/non-Windows일 수도 있으니 결과만 확인
result = is_screen_locked()
assert result in (True, False)
# 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트)
# ============================================================
# 시나리오 36-50: v2.3+ 신규 기능 (온보딩, Discord, 급여, 목표, CSV, 알림 dedupe 등)
# ============================================================
@case("S36. 신규 DB는 onboarding_completed = 'false' (위저드 강제)")
def s36_onboarding_new():
db = fresh_db('s36')
assert db.get_setting('onboarding_completed') == 'false'
@case("S37. 기존 사용자 (work_records 있음) → 자동 완료")
def s37_onboarding_existing():
db = fresh_db('s37')
# work_record 1건 추가 후 마이그레이션 재실행
today = date.today().isoformat()
db.add_work_record(today, '09:00:00', is_manual=True)
# 다시 호출 (init_database에서 호출되는 헬퍼)
db.migrate_v23_onboarding_for_existing()
assert db.get_setting('onboarding_completed') == 'true'
@case("S38. salary.estimate_pay: 시급 0원 → 모두 0")
def s38_salary_zero_wage():
from core.salary import estimate_pay
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
assert out['base'] == 0 and out['overtime'] == 0 and out['total'] == 0
@case("S39. salary.estimate_pay: 8h 근무 + 30분 연장, 시급 10000 → base 75000 + ot 7500")
def s39_salary_basic():
from core.salary import estimate_pay
out = estimate_pay(
[{'total_hours': 8.0, 'overtime_minutes': 30}],
hourly_wage=10000,
overtime_rate=1.5,
)
# 정규 = 8 - 0.5 = 7.5h × 10000 = 75000
# 연장 = 0.5h × 10000 × 1.5 = 7500
assert abs(out['base'] - 75000) < 0.01, out['base']
assert abs(out['overtime'] - 7500) < 0.01, out['overtime']
assert abs(out['total'] - 82500) < 0.01
@case("S40. salary.format_won: 콤마 + '' 접미사")
def s40_format_won():
from core.salary import format_won
assert format_won(0) == '0원'
assert format_won(1234567) == '1,234,567원'
assert format_won(82500.4) == '82,500원'
@case("S41. CSV 가져오기: 표준 포맷 파싱")
def s41_csv_parse():
from utils.csv_importer import parse_csv
p = os.path.join(tempfile.gettempdir(), 'clockout_test.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,첫째날\n")
f.write("2026-04-02,09:30:00,17:30:00,30,단축\n")
rows = parse_csv(p)
os.remove(p)
assert len(rows) == 2
assert rows[0]['clock_in'] == '09:00:00' # 정규화 (HH:MM → HH:MM:SS)
assert rows[0]['lunch_minutes'] == 60
assert rows[1]['lunch_minutes'] == 30
assert rows[1]['memo'] == '단축'
@case("S42. CSV 검증 실패: 잘못된 날짜 형식 → ValueError")
def s42_csv_invalid():
from utils.csv_importer import parse_csv
p = os.path.join(tempfile.gettempdir(), 'clockout_bad.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("not-a-date,09:00,18:00,60,\n")
try:
parse_csv(p)
assert False, "should have raised"
except ValueError:
pass
finally:
os.remove(p)
@case("S43. CSV import on_conflict='skip': 기존 일자는 건너뜀")
def s43_csv_skip():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s43')
# 기존 레코드 1건
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
p = os.path.join(tempfile.gettempdir(), 'clockout_dup.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,중복\n")
f.write("2026-04-02,09:00,18:00,60,신규\n")
rows = parse_csv(p)
added, updated, skipped = import_records(db, rows, on_conflict='skip')
os.remove(p)
assert added == 1 and updated == 0 and skipped == 1, (added, updated, skipped)
# 기존 레코드 보존 확인 (08:30 그대로)
rec = db.get_work_record('2026-04-01')
assert rec['clock_in'] == '08:30:00'
@case("S44. CSV import on_conflict='overwrite': 기존 일자 덮어씀")
def s44_csv_overwrite():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s44')
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
p = os.path.join(tempfile.gettempdir(), 'clockout_ov.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,덮어쓰기\n")
rows = parse_csv(p)
added, updated, skipped = import_records(db, rows, on_conflict='overwrite')
os.remove(p)
assert updated == 1 and added == 0
rec = db.get_work_record('2026-04-01')
assert rec['clock_in'] == '09:00:00'
@case("S45. notification_log dedupe: 같은 (channel, event_type) 같은 날 1회")
def s45_notification_dedupe():
db = fresh_db('s45')
assert not db.has_notification_today('discord', 'weekly_report')
db.log_notification('discord', 'weekly_report', payload='test', success=True)
assert db.has_notification_today('discord', 'weekly_report')
# 다른 event_type은 별개
assert not db.has_notification_today('discord', 'clock_in')
@case("S46. add_meal_record: 12:00-13:00 → 60분 누적")
def s46_meal_record():
db = fresh_db('s46')
today = date.today().isoformat()
# 오늘이 아닌 날짜로 add (work_record 미존재 OK)
db.add_meal_record('2026-04-01', '12:00:00', '13:00:00', meal_type='lunch')
db.add_meal_record('2026-04-01', '18:30:00', '19:00:00', meal_type='dinner')
# get_meal_minutes_today은 오늘만 → 일반화된 검증은 SQL로
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='lunch'",
('2026-04-01',))
assert cur.fetchone()[0] == 60
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='dinner'",
('2026-04-01',))
assert cur.fetchone()[0] == 30
conn.close()
@case("S47. crash_log 자동 생성 + 기록")
def s47_crash_log():
from utils.crash_handler import _log_crash
db = fresh_db('s47')
_log_crash(db, 'TestException', 'sample message', 'Traceback ...', 'v2.6.0')
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM crash_log WHERE exception_type = 'TestException'")
assert cur.fetchone()[0] == 1
cur.execute("SELECT message, app_version FROM crash_log WHERE exception_type = 'TestException'")
msg, ver = cur.fetchone()
assert msg == 'sample message' and ver == 'v2.6.0'
conn.close()
@case("S48. updater_client.is_newer: 정확한 semver 비교")
def s48_updater_compare():
from utils.updater_client import is_newer
assert is_newer('v2.7.0', '2.6.0')
assert is_newer('2.6.1', 'v2.6.0')
assert not is_newer('v2.6.0', 'v2.6.0')
assert not is_newer('v2.5.0', 'v2.6.0')
assert is_newer('v2.10.0', 'v2.9.99') # 자릿수 비교가 아니라 정수 비교
@case("S49. Discord webhook URL 비어있으면 silent False (네트워크 안 탐)")
def s49_discord_empty():
from utils.discord_webhook import send, send_test
assert send('', 't', 'd') is False
assert send('http://invalid', 't', 'd') is False # https:// 아님
assert send_test('') is False
@case("S50. Goal 설정: 0 = 비활성 / 양수 = 활성")
def s50_goals():
db = fresh_db('s50')
# 기본값 확인 (0)
assert db.get_setting_int('goal_overtime_max_monthly', 0) == 0
assert db.get_setting_int('goal_avg_hours_daily', 0) == 0
# 활성화
db.save_settings({'goal_overtime_max_monthly': 1200, 'goal_avg_hours_daily': 8})
assert db.get_setting_int('goal_overtime_max_monthly') == 1200
assert db.get_setting_int('goal_avg_hours_daily') == 8
@case("S51. 글꼴 크기 / 고대비 설정 키")
def s51_accessibility_keys():
db = fresh_db('s51')
# 기본값
assert db.get_setting_float('font_scale', 1.0) == 1.0
assert db.get_setting_bool('high_contrast', False) is False
# 변경
db.save_settings({'font_scale': 1.5, 'high_contrast': True})
assert db.get_setting_float('font_scale') == 1.5
assert db.get_setting_bool('high_contrast') is True
@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
def s52b_planned_leave():
db = fresh_db('s52b')
db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
assert db.has_full_day_leave('2026-05-15')
assert db.get_leave_minutes_for('2026-05-15') == 480
# 다른 날엔 영향 없음
assert not db.has_full_day_leave('2026-05-16')
@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
def s52c_recurring_leave():
db = fresh_db('s52c')
db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
# 2026-05-01 = Friday
assert db.get_leave_minutes_for('2026-05-01') == 240
# Monday
assert db.get_leave_minutes_for('2026-05-04') == 0
# 종일 아님
assert not db.has_full_day_leave('2026-05-01')
@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
def s52d_effective():
from core.time_calculator import TimeCalculator
db = fresh_db('s52d')
db.add_leave_record('2026-05-15', '오전반차', 0.5)
calc = TimeCalculator(work_minutes=480)
target = datetime(2026, 5, 15)
assert calc.effective_work_minutes(target, db) == 240
# 다른 날엔 변화 없음
other = datetime(2026, 5, 16)
assert calc.effective_work_minutes(other, db) == 480
@case("S52E. effective_work_minutes: 종일 연차 시 0")
def s52e_full_day():
from core.time_calculator import TimeCalculator
db = fresh_db('s52e')
db.add_leave_record('2026-05-15', '연차', 1.0)
calc = TimeCalculator(work_minutes=480)
assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0
@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
def s52a_holiday_hotpath():
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
from core.time_calculator import TimeCalculator
db = fresh_db('s52a')
holiday_date = '2026-05-01' # 근로자의 날
db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)
calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
ci = datetime(2026, 5, 1, 9, 0)
# 휴일 인식
assert calc.is_non_working_day(ci, db)
assert calc.get_day_type(ci, db) == 'holiday'
# 출근 1분 후: 적립 0 (30분 단위 절삭)
now1 = ci + timedelta(minutes=1)
actual, earned = calc.calculate_holiday_overtime(ci, now1)
assert actual == 1 and earned == 0
# 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
now30 = ci + timedelta(minutes=30)
actual, earned = calc.calculate_holiday_overtime(ci, now30)
assert actual == 30 and earned == 30
@case("S52. CSV import + overtime 적립까지 정상 동작")
def s52_csv_overtime():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s52')
p = os.path.join(tempfile.gettempdir(), 'clockout_ot.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
# 8h 근무 + 점심 60m + 90분 연장 → 90분 적립 예상
f.write("2026-04-01,09:00,19:30,60,연장근무\n")
rows = parse_csv(p)
added, _, _ = import_records(db, rows, on_conflict='skip')
os.remove(p)
assert added == 1
bal = db.get_total_overtime_balance()
assert bal == 90, f"overtime balance: {bal}"
# ============================================================
# Run all
# ============================================================
def main():
s1_fresh_install()
s2_migration()
s3_short_work()
s4_standard()
s5_half_day()
s6_dinner()
s7_break()
s8_truncation()
s9_balance()
s10_leave_minutes()
s11_half_leave()
s12_weekend()
s13_holidays()
s14_notif_off()
s15_notif_on()
s16_health()
s17_consecutive()
s18_backup()
s19_backup_rotate()
s23_sync_work()
s24_sync_leave()
s25_setting_helpers()
s26_i18n()
s28_zero_work()
s29_midnight_break()
s30_holidays_fallback()
s31_migration_idempotent()
s32_ui_imports()
s33_short_overtime()
s34_format()
s35_lock()
s36_onboarding_new()
s37_onboarding_existing()
s38_salary_zero_wage()
s39_salary_basic()
s40_format_won()
s41_csv_parse()
s42_csv_invalid()
s43_csv_skip()
s44_csv_overwrite()
s45_notification_dedupe()
s46_meal_record()
s47_crash_log()
s48_updater_compare()
s49_discord_empty()
s50_goals()
s51_accessibility_keys()
s52a_holiday_hotpath()
s52b_planned_leave()
s52c_recurring_leave()
s52d_effective()
s52e_full_day()
s52_csv_overtime()
print()
print("=" * 60)
print(f"PASS: {len(PASS)} FAIL: {len(FAIL)} WARN: {len(WARN)}")
if FAIL:
print()
print("FAILED:")
for label, err in FAIL:
print(f" - {label}: {err}")
if WARN:
print()
print("WARNINGS:")
for w in WARN:
print(f" - {w}")
return 0 if not FAIL else 1
if __name__ == '__main__':
sys.exit(main())