Some checks failed
CI / test (push) Has been cancelled
핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (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>
542 lines
20 KiB
Python
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())
|