Clock_out_Time_Calculator/_gui_smoke_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

165 lines
5.2 KiB
Python

"""
GUI smoke 테스트 — QApplication을 띄우지 않고 UI 다이얼로그 instantiation만 확인.
실제 .show()는 헤드리스 환경에서 위험하므로 생성 단계까지만 검증.
"""
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Qt platform plugin: offscreen으로 실제 창 안 뜨게
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
from PyQt5.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv)
PASS, FAIL = [], []
def case(label, fn):
try:
fn()
PASS.append(label)
print(f"[PASS] {label}")
except Exception as e:
FAIL.append((label, f"{type(e).__name__}: {e}"))
print(f"[FAIL] {label}: {type(e).__name__}: {e}")
# Fresh DB
from core.database import Database
db_path = os.path.join(tempfile.gettempdir(), 'clockout_gui_test.db')
if os.path.exists(db_path):
os.remove(db_path)
db = Database(db_path)
def test_settings_view():
from ui.settings_view import SettingsView
dlg = SettingsView(db=db)
# 단축근무 7h30m 프리셋 적용
dlg.work_preset_combo.setCurrentIndex(1) # "단축근무 7시간 30분"
assert dlg.work_hours_spin.value() == 7
assert dlg.work_minutes_spin.value() == 30
assert dlg.lunch_spin.value() == 30
dlg.deleteLater()
def test_help_view():
from ui.help_view import HelpView
dlg = HelpView()
# 6개 탭 존재
tabs = dlg.findChild(type(dlg).__mro__[0])
from PyQt5.QtWidgets import QTabWidget
tab_widget = dlg.findChild(QTabWidget)
assert tab_widget is not None
assert tab_widget.count() == 6
dlg.deleteLater()
def test_mini_widget():
from ui.mini_widget import MiniWidget
w = MiniWidget()
w.update_remaining("01:30:00")
assert w.time_label.text() == "01:30:00"
w.update_remaining("+00:30:00")
assert "추가" in w.title_label.text()
w.deleteLater()
def test_chart_widget():
from ui.chart_widget import make_chart_widget, draw_daily_hours, draw_weekday_avg
w = make_chart_widget()
# 빈 records로도 안전하게 그려져야
draw_daily_hours(w, [])
# 데이터가 있을 때
records = [
{'date': '2026-04-25', 'total_hours': 8.5, 'overtime_minutes': 30},
{'date': '2026-04-26', 'total_hours': 9.0, 'overtime_minutes': 60},
]
draw_daily_hours(w, records)
draw_weekday_avg(w, records)
w.deleteLater()
def test_stats_view():
from ui.stats_view import StatsView
dlg = StatsView(db=db)
# 데이터 없어도 정상 로드
assert dlg.weekly_total_hours.text() is not None
dlg.deleteLater()
def test_calendar_view():
from ui.calendar_view import CalendarView
dlg = CalendarView(db=db)
dlg.deleteLater()
def test_main_window_init():
"""MainWindow 초기화 — 가장 무거운 케이스"""
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
from ui.main_window import MainWindow
w = MainWindow()
# 기본 상태
assert w.is_clocked_in == False
assert w.lunch_break_enabled == False
# auto_lunch 캐시 초기 None
assert w._auto_lunch_enabled_cache is None
# 단축키 7개 등록되었는지
from PyQt5.QtWidgets import QShortcut
shortcuts = w.findChildren(QShortcut)
assert len(shortcuts) >= 7, f"shortcuts: {len(shortcuts)}"
w.deleteLater()
def test_settings_save_load_round_trip():
"""설정 저장→로드 라운드트립"""
from ui.settings_view import SettingsView
dlg = SettingsView(db=db)
# 단축근무 프리셋 → 저장
dlg.work_preset_combo.setCurrentIndex(1)
# save 호출 시 메시지박스가 뜨므로 save_settings 직접 호출 회피
# 대신 저장 로직과 동일한 dict 만들기
work_min = dlg.work_hours_spin.value() * 60 + dlg.work_minutes_spin.value()
db.save_settings({
'work_minutes': work_min,
'lunch_duration_minutes': dlg.lunch_spin.value(),
})
# 새 다이얼로그에 다시 로드
dlg2 = SettingsView(db=db)
assert dlg2.work_hours_spin.value() == 7
assert dlg2.work_minutes_spin.value() == 30
assert dlg2.lunch_spin.value() == 30
# 프리셋이 자동 매칭되었는지
assert dlg2.work_preset_combo.currentIndex() == 1
dlg.deleteLater()
dlg2.deleteLater()
# 실행
case("UI-1. SettingsView: 단축근무 프리셋 적용", test_settings_view)
case("UI-2. HelpView: 6개 탭 생성 확인", test_help_view)
case("UI-3. MiniWidget: 시간 업데이트 + 추가근무 표시", test_mini_widget)
case("UI-4. ChartWidget: 빈/유효 데이터 그리기", test_chart_widget)
case("UI-5. StatsView: 데이터 없이 로드", test_stats_view)
case("UI-6. CalendarView: 인스턴스화", test_calendar_view)
case("UI-7. MainWindow: 단축키 7개 등록 + 초기 상태", test_main_window_init)
case("UI-8. Settings 저장→재로드: 단축근무 7h30m round-trip", test_settings_save_load_round_trip)
print()
print("=" * 60)
print(f"PASS: {len(PASS)} FAIL: {len(FAIL)}")
if FAIL:
print("\nFAILED:")
for label, err in FAIL:
print(f" - {label}: {err}")
# Cleanup
if os.path.exists(db_path):
try: os.remove(db_path)
except: pass
sys.exit(1 if FAIL else 0)