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>
165 lines
5.2 KiB
Python
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)
|