""" 실사용 시나리오 통합 검증. 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("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() 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())