Initial release v2.2.0
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>
This commit is contained in:
KINDNICK
2026-04-30 12:54:40 +09:00
co-authored by Claude Opus 4.7
commit bedbb1e9ec
63 changed files with 13353 additions and 0 deletions
View File
+110
View File
@@ -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
+83
View File
@@ -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'
+100
View File
@@ -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'
+90
View File
@@ -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() # 이동되었으므로 사라짐