Database.__init__의 공휴일 동기화 백그라운드 스레드가 SQLite 연결을 잡고 있어 임시 DB os.remove가 실패하던 문제. 문서화된 CLOCKOUT_DISABLE_HOLIDAY_SYNC 플래그를 테스트 시작 시 설정. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
865 lines
32 KiB
Python
865 lines
32 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__)))
|
||
|
||
# 테스트 중에는 공휴일 자동 동기화(백그라운드 네트워크 스레드)를 비활성화.
|
||
# 이 스레드가 SQLite 연결을 잡고 있으면 임시 DB의 os.remove가 WinError 32(파일 사용 중)로
|
||
# 실패함 (S2/S31 등). DB 인스턴스 생성 전에 설정해야 효과 있음.
|
||
os.environ.setdefault('CLOCKOUT_DISABLE_HOLIDAY_SYNC', '1')
|
||
|
||
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())
|