Clock_out_Time_Calculator/_integration_test.py
KINDNICK bedbb1e9ec
Some checks failed
CI / test (push) Has been cancelled
Initial release v2.2.0
핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의)
- Windows 이벤트 뷰어 자동 출퇴근 감지
- 30분 단위 연장근무 적립/사용 시스템
- 1.0/0.5/0.25일 연차·반차·반반차
- 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출
- 한국 공휴일 자동 등록 (음력 포함, holidays 패키지)
- matplotlib 차트 기반 주간/월간/패턴 통계
- 미니 위젯 + 시스템 트레이 통합
- 한국어/English i18n
- 자가 업데이트 (updater.exe + Gitea Releases)

아키텍처:
- core/ (db, time_calculator, notifier, i18n, version, settings_keys)
- ui/ (main_window + 9 dialogs + 3 controllers)
- utils/ (backup, lock_detector, debug_log, updater_client, time_format)
- tests/ (66 pytest 단위) + 통합/i18n GUI 검증

CI/CD:
- .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트
- .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:54:40 +09:00

542 lines
20 KiB
Python

"""
실사용 시나리오 통합 검증.
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 잠금 안된 상태에서 테스트)
# ============================================================
# 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()
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())