핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Database 단위 테스트 — 마이그레이션, 동기화, 헬퍼.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path):
|
||||
"""매 테스트마다 빈 DB."""
|
||||
return Database(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
class TestSettingsHelpers:
|
||||
def test_get_setting_int_valid(self, fresh_db):
|
||||
fresh_db.set_setting('foo', '42')
|
||||
assert fresh_db.get_setting_int('foo') == 42
|
||||
|
||||
def test_get_setting_int_invalid_returns_default(self, fresh_db):
|
||||
fresh_db.set_setting('foo', 'abc')
|
||||
assert fresh_db.get_setting_int('foo', 99) == 99
|
||||
|
||||
def test_get_setting_int_missing_returns_default(self, fresh_db):
|
||||
assert fresh_db.get_setting_int('missing', 7) == 7
|
||||
|
||||
def test_get_setting_bool_truthy(self, fresh_db):
|
||||
fresh_db.set_setting('flag', 'true')
|
||||
assert fresh_db.get_setting_bool('flag') is True
|
||||
|
||||
def test_get_setting_bool_falsy(self, fresh_db):
|
||||
fresh_db.set_setting('flag', 'no')
|
||||
assert fresh_db.get_setting_bool('flag') is False
|
||||
|
||||
def test_get_setting_float(self, fresh_db):
|
||||
fresh_db.set_setting('rate', '3.14')
|
||||
assert fresh_db.get_setting_float('rate') == 3.14
|
||||
|
||||
|
||||
class TestSettingsAutoSync:
|
||||
def test_work_minutes_to_work_hours_floor(self, fresh_db):
|
||||
"""work_minutes 저장 시 work_hours는 floor 동기화 (450 → 7)"""
|
||||
fresh_db.save_settings({'work_minutes': 450})
|
||||
assert fresh_db.get_setting('work_hours') == '7'
|
||||
|
||||
def test_work_hours_to_work_minutes(self, fresh_db):
|
||||
fresh_db.save_settings({'work_hours': 8})
|
||||
assert fresh_db.get_setting('work_minutes') == '480'
|
||||
|
||||
def test_annual_leave_bidirectional(self, fresh_db):
|
||||
fresh_db.save_settings({'annual_leave_days': 12})
|
||||
assert fresh_db.get_setting('annual_leave_total') == '12'
|
||||
|
||||
|
||||
class TestWorkMinutes:
|
||||
def test_get_work_minutes_default(self, fresh_db):
|
||||
assert fresh_db.get_work_minutes() == 480
|
||||
|
||||
def test_get_work_minutes_after_save(self, fresh_db):
|
||||
fresh_db.save_settings({'work_minutes': 450})
|
||||
assert fresh_db.get_work_minutes() == 450
|
||||
|
||||
|
||||
class TestLeaveCalculation:
|
||||
def test_leave_minutes_for_short_worker(self, fresh_db):
|
||||
"""단축근무자(7h30m) 1일 연차 = 450분"""
|
||||
fresh_db.save_settings({'work_minutes': 450})
|
||||
today = date.today().isoformat()
|
||||
fresh_db.add_leave_record(today, 'annual', 1.0)
|
||||
assert fresh_db.get_today_leave_minutes() == 450
|
||||
|
||||
def test_half_day_leave(self, fresh_db):
|
||||
today = date.today().isoformat()
|
||||
fresh_db.add_leave_record(today, 'half', 0.5)
|
||||
assert fresh_db.get_today_leave_minutes() == 240 # 8h * 0.5
|
||||
|
||||
|
||||
class TestMigrationIdempotency:
|
||||
def test_annual_leave_keys_migrated_sentinel(self, fresh_db):
|
||||
assert fresh_db.get_setting('annual_leave_keys_migrated') == 'true'
|
||||
|
||||
def test_re_init_does_not_break(self, tmp_path):
|
||||
path = str(tmp_path / "test.db")
|
||||
db1 = Database(path)
|
||||
db1.save_settings({'work_minutes': 450})
|
||||
# 두 번째 init
|
||||
db2 = Database(path)
|
||||
assert db2.get_work_minutes() == 450
|
||||
|
||||
|
||||
class TestConsecutiveOvertimeDays:
|
||||
def test_no_records(self, fresh_db):
|
||||
assert fresh_db.get_consecutive_overtime_days() == 0
|
||||
|
||||
def test_three_consecutive(self, fresh_db):
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
for i in range(3):
|
||||
d = (today - timedelta(days=i)).isoformat()
|
||||
fresh_db.add_work_record(d, '09:00:00')
|
||||
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
|
||||
overtime_minutes=120, overtime_earned=120)
|
||||
assert fresh_db.get_consecutive_overtime_days() == 3
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
i18n 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.i18n import tr, tr_html, set_language, available_languages, language_label
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_language():
|
||||
"""매 테스트 후 ko로 원복."""
|
||||
yield
|
||||
set_language('ko')
|
||||
|
||||
|
||||
class TestBasicTranslation:
|
||||
def test_korean_default(self):
|
||||
set_language('ko')
|
||||
assert '저장' in tr('btn.save')
|
||||
|
||||
def test_english_switch(self):
|
||||
set_language('en')
|
||||
assert 'Save' in tr('btn.save')
|
||||
|
||||
def test_missing_key_fallback_to_self(self):
|
||||
set_language('en')
|
||||
assert tr('non.existent.key') == 'non.existent.key'
|
||||
|
||||
def test_missing_in_en_falls_back_to_ko(self):
|
||||
# ko에만 있는 키가 있으면 en에서도 ko 값 (현재 사전엔 없지만 정책 검증)
|
||||
# 이 테스트는 정책 보장만 함 (사전이 비대칭일 때 안전망)
|
||||
set_language('en')
|
||||
# 모든 카테고리는 양 언어 균형 있게 정의되어야 함
|
||||
for key in ['btn.save', 'menu.stats', 'window.settings']:
|
||||
assert tr(key) != key # 빈 fallback이 아님
|
||||
|
||||
|
||||
class TestFormatArgs:
|
||||
def test_minutes_arg_korean(self):
|
||||
set_language('ko')
|
||||
msg = tr('notif.clock_out_soon.body', minutes=15)
|
||||
assert '15' in msg
|
||||
|
||||
def test_hours_float_arg(self):
|
||||
set_language('ko')
|
||||
msg = tr('notif.weekly_52.body', hours=58.5)
|
||||
assert '58.5' in msg
|
||||
|
||||
def test_missing_arg_graceful(self):
|
||||
# 필요한 인자 없이 format → 빈 문자열 아님
|
||||
set_language('ko')
|
||||
msg = tr('notif.clock_out_soon.body')
|
||||
assert msg # 키 그대로라도 비지 않음
|
||||
|
||||
|
||||
class TestHelpHtml:
|
||||
def test_korean_html(self):
|
||||
set_language('ko')
|
||||
assert '환영' in tr_html('help.html.intro')
|
||||
|
||||
def test_english_html(self):
|
||||
set_language('en')
|
||||
assert 'Welcome' in tr_html('help.html.intro')
|
||||
|
||||
def test_missing_html_returns_placeholder(self):
|
||||
set_language('ko')
|
||||
result = tr_html('help.html.nonexistent')
|
||||
assert '<p>missing:' in result
|
||||
|
||||
|
||||
class TestLanguageMeta:
|
||||
def test_available_languages_includes_ko_en(self):
|
||||
langs = available_languages()
|
||||
assert 'ko' in langs
|
||||
assert 'en' in langs
|
||||
|
||||
def test_language_label(self):
|
||||
assert language_label('ko') == '한국어'
|
||||
assert language_label('en') == 'English'
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
TimeCalculator 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.time_calculator import TimeCalculator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calc_8h():
|
||||
return TimeCalculator(work_hours=8, lunch_duration_minutes=60)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calc_short():
|
||||
"""단축근무 7h30m + 점심 30m"""
|
||||
return TimeCalculator(work_minutes=450, lunch_duration_minutes=30)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock_in_9am():
|
||||
return datetime(2026, 4, 29, 9, 0, 0)
|
||||
|
||||
|
||||
class TestClockOutTime:
|
||||
def test_standard_8h_with_lunch(self, calc_8h, clock_in_9am):
|
||||
co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True)
|
||||
assert co == datetime(2026, 4, 29, 18, 0, 0)
|
||||
|
||||
def test_short_7h30m_with_lunch(self, calc_short, clock_in_9am):
|
||||
co = calc_short.calculate_clock_out_time(clock_in_9am, include_lunch=True)
|
||||
assert co == datetime(2026, 4, 29, 17, 0, 0)
|
||||
|
||||
def test_no_lunch(self, calc_8h, clock_in_9am):
|
||||
co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=False)
|
||||
assert co == datetime(2026, 4, 29, 17, 0, 0)
|
||||
|
||||
def test_with_dinner(self, calc_8h, clock_in_9am):
|
||||
co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True, include_dinner=True)
|
||||
assert co == datetime(2026, 4, 29, 19, 0, 0)
|
||||
|
||||
def test_with_break_minutes(self, calc_8h, clock_in_9am):
|
||||
co_no = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True)
|
||||
co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True, break_minutes=30)
|
||||
assert (co - co_no) == timedelta(minutes=30)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("actual_min,expected_earned", [
|
||||
(29, 0), # 30분 미만 절삭
|
||||
(30, 30),
|
||||
(35, 30),
|
||||
(60, 60),
|
||||
(89, 60),
|
||||
(90, 90),
|
||||
(120, 120),
|
||||
])
|
||||
def test_overtime_30min_truncation(calc_8h, clock_in_9am, actual_min, expected_earned):
|
||||
base_co = clock_in_9am + timedelta(hours=8)
|
||||
actual_co = base_co + timedelta(minutes=actual_min)
|
||||
_, earned = calc_8h.calculate_overtime(clock_in_9am, actual_co, include_lunch=False)
|
||||
assert earned == expected_earned
|
||||
|
||||
|
||||
class TestCompatibility:
|
||||
def test_work_hours_property_returns_float(self):
|
||||
c = TimeCalculator(work_minutes=450)
|
||||
assert c.work_hours == 7.5
|
||||
|
||||
def test_work_hours_constructor_accepts_float(self):
|
||||
c = TimeCalculator(work_hours=7.5, lunch_duration_minutes=30)
|
||||
assert c.work_minutes == 450
|
||||
|
||||
def test_work_minutes_takes_precedence(self):
|
||||
# 둘 다 주면 work_minutes 우선
|
||||
c = TimeCalculator(work_hours=8, work_minutes=450)
|
||||
assert c.work_minutes == 450
|
||||
|
||||
def test_default_8_hours(self):
|
||||
c = TimeCalculator()
|
||||
assert c.work_minutes == 480
|
||||
|
||||
|
||||
class TestDayType:
|
||||
def test_weekend(self):
|
||||
calc = TimeCalculator()
|
||||
sat = datetime(2026, 5, 2)
|
||||
assert calc.is_weekend(sat)
|
||||
assert calc.get_day_type(sat) == 'weekend'
|
||||
|
||||
def test_weekday(self):
|
||||
calc = TimeCalculator()
|
||||
mon = datetime(2026, 5, 4)
|
||||
assert not calc.is_weekend(mon)
|
||||
assert calc.get_day_type(mon) == 'normal'
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
업데이터 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.updater_client import _parse_version, is_newer, RELEASES_API
|
||||
|
||||
|
||||
class TestVersionParsing:
|
||||
@pytest.mark.parametrize("input_str,expected", [
|
||||
('1.0.0', (1, 0, 0)),
|
||||
('v1.0.0', (1, 0, 0)),
|
||||
('V2.1.3', (2, 1, 3)),
|
||||
('1.0', (1, 0, 0)), # 짧은 버전 → 0 패딩
|
||||
('2', (2, 0, 0)),
|
||||
('1.2.3.4', (1, 2, 3)), # 초과 부분 무시
|
||||
('2.0rc1', (2, 0, 0)), # 접미사 제거
|
||||
('', (0, 0, 0)),
|
||||
])
|
||||
def test_parse_version(self, input_str, expected):
|
||||
assert _parse_version(input_str) == expected
|
||||
|
||||
|
||||
class TestVersionComparison:
|
||||
@pytest.mark.parametrize("remote,local,expected", [
|
||||
('2.0.0', '1.0.0', True),
|
||||
('1.0.1', '1.0.0', True),
|
||||
('v2.1.0', '2.0.5', True),
|
||||
('1.0.0', '1.0.0', False), # 동일 버전
|
||||
('1.0.0', '2.0.0', False), # 로컬이 더 최신
|
||||
('v1.0.0', 'v1.0.0', False),
|
||||
])
|
||||
def test_is_newer(self, remote, local, expected):
|
||||
assert is_newer(remote, local) == expected
|
||||
|
||||
|
||||
class TestApiUrl:
|
||||
def test_default_points_to_gitea(self):
|
||||
"""기본 URL이 자체 호스팅 Gitea를 가리키는지."""
|
||||
assert 'kindnick-git.duckdns.org' in RELEASES_API
|
||||
assert '/api/v1/repos/' in RELEASES_API
|
||||
assert '/releases/latest' in RELEASES_API
|
||||
|
||||
def test_env_override(self, monkeypatch):
|
||||
"""환경변수로 URL 오버라이드 가능."""
|
||||
monkeypatch.setenv('CLOCKOUT_RELEASES_API', 'https://example.com/api/test')
|
||||
# 모듈 재로드 필요
|
||||
import importlib
|
||||
from utils import updater_client
|
||||
importlib.reload(updater_client)
|
||||
assert updater_client.RELEASES_API == 'https://example.com/api/test'
|
||||
# 원복
|
||||
monkeypatch.delenv('CLOCKOUT_RELEASES_API', raising=False)
|
||||
importlib.reload(updater_client)
|
||||
|
||||
|
||||
class TestUpdaterScript:
|
||||
"""updater.py 자체 로직."""
|
||||
|
||||
def test_is_pid_running_self(self):
|
||||
"""현재 프로세스 PID는 running."""
|
||||
import updater
|
||||
assert updater.is_pid_running(os.getpid())
|
||||
|
||||
def test_is_pid_running_dead(self):
|
||||
"""존재하지 않는 PID는 not running."""
|
||||
import updater
|
||||
# 절대 사용되지 않을 PID (32비트 max + 1)
|
||||
assert not updater.is_pid_running(99999999)
|
||||
|
||||
def test_replace_file_round_trip(self, tmp_path):
|
||||
"""파일 교체 + 백업 생성 검증."""
|
||||
import updater
|
||||
target = tmp_path / "main.exe"
|
||||
new = tmp_path / "main_new.exe"
|
||||
target.write_bytes(b'OLD VERSION')
|
||||
new.write_bytes(b'NEW VERSION')
|
||||
|
||||
backup = updater.replace_file(new, target)
|
||||
assert backup is not None
|
||||
assert backup.exists()
|
||||
assert backup.read_bytes() == b'OLD VERSION'
|
||||
assert target.exists()
|
||||
assert target.read_bytes() == b'NEW VERSION'
|
||||
assert not new.exists() # 이동되었으므로 사라짐
|
||||
Reference in New Issue
Block a user