From ff71886fd78280d0d272d0f96eb99eeebba3dbba Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Thu, 30 Apr 2026 19:30:47 +0900 Subject: [PATCH] =?UTF-8?q?v2.7.0:=20i18n=20100%=20+=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20retranslate=20+=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20+47?= =?UTF-8?q?=20+=20=ED=8F=B4=EB=A6=AC=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키 - 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용 - MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출 - 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등) - pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122) - README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영 --- AGENTS.md | 163 ++++++++++++++++---- CHANGELOG.md | 30 ++++ CLAUDE.md | 156 +++++++++++-------- INSTALL.md | 176 ++++++++++++--------- README.md | 36 ++++- _integration_test.py | 245 ++++++++++++++++++++++++++++++ core/i18n.py | 196 ++++++++++++++++++++++++ core/version.py | 2 +- tests/test_crash_handler.py | 110 ++++++++++++++ tests/test_csv_importer.py | 127 ++++++++++++++++ tests/test_discord_webhook.py | 129 ++++++++++++++++ tests/test_i18n_runtime.py | 106 +++++++++++++ tests/test_salary.py | 98 ++++++++++++ ui/break_view.py | 47 +++--- ui/clock_in_dialog.py | 12 +- ui/controllers/meal_controller.py | 56 +++++++ ui/i18n_runtime.py | 94 ++++++++++++ ui/leave_view.py | 98 ++++++------ ui/main_window.py | 52 +++---- ui/overtime_view.py | 137 ++++++++++------- ui/settings_view.py | 10 +- 21 files changed, 1744 insertions(+), 336 deletions(-) create mode 100644 tests/test_crash_handler.py create mode 100644 tests/test_csv_importer.py create mode 100644 tests/test_discord_webhook.py create mode 100644 tests/test_i18n_runtime.py create mode 100644 tests/test_salary.py create mode 100644 ui/controllers/meal_controller.py create mode 100644 ui/i18n_runtime.py diff --git a/AGENTS.md b/AGENTS.md index 6fedf4f..31b01ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,40 @@ # Project Conventions and Operational Gotchas ## 🛠️ Setup & Execution -- **Dependencies:** `pip install -r requirements.txt`. Optional: `pip install anthropic` for AI insight feature. +- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays). - **Run:** `python main.py` -- **Module-level tests:** +- **Module-level smoke:** - Event monitoring: `python core/event_monitor.py` - Time calculation: `python core/time_calculator.py` -- **Integration tests:** `python _integration_test.py` (35 scenarios), `python _i18n_gui_test.py` (5 ko/en GUI), `python _gui_smoke_test.py` (8 widget). All should be green before release. +- **Integration tests** (all should be green before release): + - `python _integration_test.py` — business-logic scenarios (35+ for v2.0–2.2 + 15+ for v2.3+) + - `python _i18n_gui_test.py` — ko/en switch on real widgets + - `python _gui_smoke_test.py` — widget instantiation +- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`). ## 🗄️ Architecture Notes (Core Business Logic) -- **8 SQLite tables** in `database.db`: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`. Migrations in `init_database()` ALTER existing DBs. + +### Database (10+ tables in `database.db`) +`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods. + +### Invariants - **`work_records.date` UNIQUE** — one row per workday. -- **Overtime tracking:** earned (bank) and used (usage) tables separate. Both have NULLable `work_record_id` for manual entries — never filter them out. -- **Time representation:** `TimeCalculator.work_minutes` is the canonical attribute (int). `work_hours` is a property for compatibility. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` automatically (floor on minutes→hours). -- **Settings:** all keys defined in `core/settings_keys.py`. Import constants — never use raw strings. `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed). -- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect. +- **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries — never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual". +- **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`). +- **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth. +- **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_total_overtime_balance()`. +- **WAL mode + 5s busy timeout** enabled in `init_database()` for cloud-sync friendliness (OneDrive/Dropbox). + +### Settings system +- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings. +- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed). +- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`. +- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running. + +### i18n +- `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories). +- Sentence formatting via Python `str.format(**kwargs)`. +- Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap. ## ⚠️ Critical Invariants (MUST PRESERVE) @@ -22,46 +42,135 @@ Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` — this caused a +29h display bug previously and was the original Phase 1 fix. ### 2. Hot-path caching -`update_display()` runs at 1Hz. Any DB call inside this method must be cached (see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly notifications) are gated by `now.minute % 5 == 0`. +`update_display()` runs at 1Hz. Any DB call inside must be cached (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly/long-work notifications) are gated by `now.minute % 5 == 0`. ### 3. Time format separation 24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable). ### 4. Workday boundary -`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. Don't naively use `date.today()` in time logic without considering this rollover. +`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. `start_new_workday()` only triggers when crossing this boundary. Don't naively use `date.today()` in time logic. ### 5. Migration idempotency -All `migrate_*` methods must early-return if already applied. Use sentinel keys (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) — without them, every startup runs the migration query. +All `migrate_*` methods must early-return if already applied. Use sentinel keys — without them, every startup re-runs the migration query. + +### 6. Single-file deployment +`main.exe` embeds `updater.exe` via `main.spec` data files. `_ensure_updater_extracted()` in `main.py` extracts on first launch from `sys._MEIPASS`. Never break the staging copy at `build/staging/updater.exe` — `main.spec --clean` would otherwise wipe `dist/updater.exe` mid-build. + +### 7. Updater handoff +`updater.py` is standalone (no PyQt). Args: `--pid --new --target `. Waits for PID exit, swaps file with `.bak` rollback, relaunches. Don't add Qt deps to updater. + +## 🧩 Module Map + +### `core/` +- `database.py` — SQLite schema + migrations + helpers (`get_setting_*`, `get_consecutive_overtime_days`, `add_korean_holidays_auto`, `log_notification`, `add_meal_record`). +- `time_calculator.py` — `work_minutes` canonical, `calculate_overtime(unit_minutes=30)` (user-selectable unit). +- `event_monitor.py` — Win Event IDs 6005/4624/6006. +- `notifier.py` — 7 notifications, `_enabled()` reads NOTIF_* keys, `notification_before_minutes` configurable. +- `i18n.py` — `_DICT` ko/en + `_HELP_HTML` (6 tabs). +- `salary.py` — `estimate_pay(records, hourly_wage, overtime_rate=1.5)`. +- `settings_keys.py` — All setting keys as constants. +- `version.py` — `__version__` single source of truth. + +### `ui/` +- `main_window.py` — 1Hz `update_display()`, single-instance `QLocalServer`, 7 keyboard shortcuts. +- `settings_view.py` — work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals. +- `stats_view.py` — 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget. +- `mini_widget.py` — always-on-top frameless. +- `help_view.py` — 6 tabs from `_HELP_HTML`. Has "🚀 온보딩 다시 보기" button. +- `onboarding_view.py` — 5-step QWizard (forced for new users; `ONBOARDING_COMPLETED` sentinel). +- `today_summary.py` — post-clockout card. +- `goal_widget.py` — monthly progress bars (overtime cap, daily avg). +- `meal_time_dialog.py` — lunch/dinner real start-end input. +- `past_record_dialog.py` — calendar right-click "add past record". +- `leave_calendar_view.py` — color-coded leave (green/yellow/purple). +- `accessibility.py` — `apply_font_scale(scale)`, `apply_high_contrast(enabled)`, `HIGH_CONTRAST_QSS`. +- `chart_widget.py` — matplotlib QtAgg helpers, `_Fallback` widget if matplotlib missing. +- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. + +### `ui/controllers/` +- `lock_monitor.py` — Win32 OpenInputDesktop polling 5s for screen-lock auto-break. +- `auto_lunch.py` — toggles lunch after 4 hours since clock-in. +- `notification_orchestrator.py` — 5-min-tick orchestrator + `maybe_send_weekly_report()` for Mondays. + +### `utils/` +- `backup.py` — once/day, `~/.clockout_backups/`, 7-rotation, `sqlite3.Connection.backup` API. +- `lock_detector.py` — `OpenInputDesktop` + `GetUserObjectInformation` for screen lock. +- `http_api.py` — stdlib `http.server` on `127.0.0.1:17389`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. NEVER expose externally. +- `discord_webhook.py` — browser User-Agent (`Mozilla/5.0 ... ClockOutCalculator/2.3`) for Cloudflare bypass. `send_test/clock_in/clock_out/health_warning`. +- `csv_importer.py` — standard format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`. +- `csv_exporter.py` — same format. +- `crash_handler.py` — `install_global_handler()` registers `sys.excepthook`, dialog with copy/Gitea-report. +- `updater_client.py` — Returns `(info, reason)` tuple. Reasons: `OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET`. +- `system_tray.py` — tray menu with i18n labels. +- `time_format.py` — `format_hours_minutes(minutes)`. +- `debug_log.py` — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. +- `resource_manager.py` — PyInstaller `_MEIPASS` aware path resolver. + +### Top-level +- `main.py` — Bootstraps DB, `_ensure_updater_extracted()`, crash handler, onboarding gate, MainWindow. +- `updater.py` — Standalone PID wait + file replace + relaunch. +- `main.spec` — Conditional updater embedding from `build/staging/updater.exe`. +- `updater.spec` — Standalone updater build. +- `release.ps1` — One-shot release: bump → tests → build → tag push → Gitea Release + assets, optional code signing. ## ⚙️ Build Process ```bash -python -m PyInstaller --clean main.spec +# Manual two-step +python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB) +mkdir -p build/staging && cp dist/updater.exe build/staging/ +python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater) + +# Or one-shot +.\release.ps1 v2.7.0 ``` -- Output: `dist/main.exe` (console disabled, UPX compressed) -- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`) -- **Stale `dist/main.exe` running** → `PermissionError`. Kill it first. -- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env. +- `dist/main.exe` running → `PermissionError`. Kill it first. +- `holidays` package only baked in if installed in build env. +- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`. ## 🚦 External Integrations -- **Anthropic Claude API** (optional): `core/ai_analysis.py`. Without `anthropic` package or API key, falls back to `static_summary()`. Don't crash the stats view. -- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only — never expose externally. Read-only by design. +- **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/`. Repo must be public. +- **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA). +- **Gitea Issues** for crash reports (`utils/crash_handler.py`): user opt-in via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`. +- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only — never expose externally. Read-only. - **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order. - **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates). -## 🐞 Past Incidents +## 🐞 Past Incidents (do NOT re-introduce) -- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. Never re-introduce. -- **Manual overtime invisible**: `overtime_view` previously filtered `work_record_id IS NOT NULL`. Manual additions (no work_record) were hidden. Show all rows; label NULL rows as "수동 추가" / "Manual". -- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. UI used `_days`, internal methods used `_total`. Now auto-synced in `save_settings()`. If you add a method reading either, also handle the sibling. -- **Banker's rounding**: `round(450/60)` = 8 in Python (round-half-even). Use `int(value) // 60` (floor) for hours derivation when consistency matters. +- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. +- **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "수동 추가". +- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. Auto-synced in `save_settings()`. +- **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(value) // 60` (floor). +- **PRAGMA foreign_keys=ON conflict** with existing manual overtime records → IntegrityError. Rolled back FK enforcement, kept WAL+timeout. +- **Discord 403 / Cloudflare 1010**: default `Python-urllib/3.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`. +- **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors. +- **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/staging/updater.exe`. +- **Onboarding wizard auto-skipped** for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog. +- **PowerShell 5.1 ANSI default**: `Get-Content -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`. +- **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`. ## 🌐 i18n Coverage Status -- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 6 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels. -- **Partially translated**: settings_view sub-labels (입력란 placeholder 등), calendar_view detail labels. -- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated. +- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings. +- **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog. +- **Roadmap**: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart). -Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. +Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. For sentence interpolation use `tr('key', name=value)`. + +## 🚢 Release Flow ([release.ps1](release.ps1)) + +``` +0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes) +1. Bump core/version.py +2. Tests (pytest tests/ + python _integration_test.py) — skippable with --SkipTests +3. PyInstaller (updater.spec → staging copy → main.spec) +4. ZIP packaging (main.exe + updater.exe) +5. Git commit (version.py + CHANGELOG.md) + tag + push +6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section) +7. Asset upload (main.exe, updater.exe, ZIP) +``` + +`--DryRun` previews without git push or API calls. diff --git a/CHANGELOG.md b/CHANGELOG.md index 293c860..eb95f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2.7.0] — 2026-04-30 + +### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선) +- **i18n 사전 100% 커버리지** — `break_view`, `overtime_view`, `leave_view`, + `clock_in_dialog` 의 내부 라벨/메시지/플레이스홀더까지 전부 ko/en 키화. + 새 키 50+개 추가 (`view.break.*`, `view.overtime.*`, `view.leave.*`, `dlg.*`). +- **i18n 런타임 재번역** — 재시작 없이 메인 화면 즉시 언어 전환. + - `ui/i18n_runtime.py`: `register(widget, key)` + `set_language_and_retranslate(lang)` + - 위젯은 weakref로 보관되어 삭제 시 자동 정리 + - 메인 윈도우 타이틀/하단 메뉴 5개 버튼 등록 완료 (점진 확대) + - 일부 다이얼로그는 여전히 재시작 필요 (다음 릴리스에서 점진 등록) +- **MealController 분리** — `main_window.py` 에서 점심/저녁 토글·라벨 갱신 로직을 + `ui/controllers/meal_controller.py` 로 추출. 기존 `LockMonitor`/`AutoLunch`/ + `NotificationOrchestrator` 패턴 준수. + +### Tests +- 통합 테스트 +15 시나리오 (S36–S52): 온보딩 신규/기존, salary 추정, CSV 가져오기 + (skip/overwrite/overtime 적립까지), notification_log dedupe, meal_record, + crash_log, updater semver 비교, Discord 입력 검증, goal/accessibility 키 등. +- 신규 pytest 모듈 4종 — `test_salary.py`, `test_csv_importer.py`, + `test_discord_webhook.py`(network mocked), `test_crash_handler.py`. +- `test_i18n_runtime.py` — register/retranslate/post-callback/dead-widget 정리 검증. +- 총 pytest 케이스: 90 → **122**, 통합 시나리오: 33 → **48**. + +### Docs +- `README.md` v2.4–v2.6 신기능 섹션 정리, 재번호. +- `CLAUDE.md` v2.6+ 아키텍처 — 컨트롤러 모듈, 35+ 설정 키, 릴리스 플로우 반영. +- `INSTALL.md` 최신 단일파일 배포 + 온보딩 + 디스코드 + 환경변수 정리. +- `AGENTS.md` 10+ DB 테이블, 컨트롤러 맵, Past Incidents 갱신. + ## [2.6.0] — 2026-04-30 ### Added — Phase 4 (3종) diff --git a/CLAUDE.md b/CLAUDE.md index b0e6827..8129966 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Clock-out Time Calculator** (퇴근시간 계산기) is a Windows desktop app that auto-detects clock-in via Windows Event Log, calculates clock-out time, banks overtime in 30-min units, and tracks leave/breaks. Korean UI by default with English (i18n switchable). +**Clock-out Time Calculator** (퇴근시간 계산기) — Windows desktop app: auto-detects clock-in via Windows Event Log or screen-unlock, calculates clock-out time, banks overtime in 30-min units, tracks leave/breaks, with Discord push, onboarding wizard, and self-updating via Gitea Releases. -**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`, optional `anthropic`. +**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`. -Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md). +Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md). ## Build and Run @@ -20,46 +20,73 @@ python main.py python core/event_monitor.py python core/time_calculator.py -# Production build → dist/main.exe +# Production build → dist/main.exe (78MB, embeds updater.exe) +python -m PyInstaller --clean updater.spec # build first — main.spec datas references it python -m PyInstaller --clean main.spec -# Integration tests (35 + 5 + view scenarios) -python _integration_test.py -python _i18n_gui_test.py -python _gui_smoke_test.py -``` +# Tests +python _integration_test.py # business-logic scenarios +python _i18n_gui_test.py # ko/en GUI verification +python _gui_smoke_test.py # widget instantiation +python -m pytest tests # unit tests -PyInstaller fails with `PermissionError` if `dist/main.exe` is running — kill it first. `_integration_test.py` and `_gui_smoke_test.py` are intentionally hidden behind a leading underscore so they don't ship in the build. +# Release (one-shot to Gitea) +$env:GITEA_TOKEN = '' +.\release.ps1 v2.7.0 +``` ## Architecture -### Core (`core/`) -- **[database.py](core/database.py)** — SQLite. 8 tables (`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`). Runtime migrations (`migrate_*` methods called from `init_database()`) ALTER existing DBs on startup. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`. -- **[time_calculator.py](core/time_calculator.py)** — Internal representation is `work_minutes: int`. `work_hours` is a read-only property (compatibility shim for legacy callers / float input). 30-min truncation in `calculate_overtime()`. -- **[event_monitor.py](core/event_monitor.py)** — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required. -- **[notifier.py](core/notifier.py)** — 6 notifications, each gated by setting key (`NOTIF_CLOCK_OUT/LUNCH/OVERTIME/HEALTH`). Texts come from `tr()` for ko/en. -- **[ai_analysis.py](core/ai_analysis.py)** — Optional Claude API integration. `get_insights(records, api_key)`: with key → Claude, without → `static_summary()` fallback. -- **[i18n.py](core/i18n.py)** — `_DICT` (28 categories × 2 languages) + `_HELP_HTML` (6 large HTML blocks for HelpView). API: `tr('key', **kwargs)`, `tr_html('key')`, `set_language('ko'|'en')`. -- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. +### core/ +- **[database.py](core/database.py)** — SQLite. 8+ tables: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`(+`break_type`), `settings`, `achievements`, `holidays`, `notification_log`, `crash_log`. Runtime migrations chained from `init_database()`. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`, `add_meal_record()`, `log_notification()`, `has_notification_today()`. WAL mode + 5s busy timeout for cloud-sync friendliness. +- **[time_calculator.py](core/time_calculator.py)** — Internal `work_minutes: int`. `calculate_overtime(unit_minutes=30)` truncates to user-selectable unit (15/30/60). `work_hours` is read-only property. +- **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006. +- **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold. +- **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator. +- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0). +- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys. +- **[version.py](core/version.py)** — `__version__` single source of truth. -### UI (`ui/`) -- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz. State: `clock_in_time`, `is_clocked_in`, `lunch_break_enabled`, `dinner_break_enabled`, `is_on_break`, `auto_lunch_applied_today`. Hot-path caches: `_auto_lunch_enabled_cache`, `_today_non_working_cache`. Single-instance via `QLocalServer` named `"ClockOutCalculatorInstance"`. 7 keyboard shortcuts (Ctrl+O/L/D/B/, F1, Ctrl+R). -- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hour+minute split spinboxes, language combo, DB path override, Claude API key, HTTP API toggle, auto-break toggle. `save_settings()` sends only `WORK_MINUTES` — DB auto-syncs `WORK_HOURS`. -- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns) with matplotlib charts (`make_chart_widget`, `draw_daily_hours`, `draw_weekday_avg`) and AI insight button. -- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless widget; updated from `update_display()` when visible. -- **[help_view.py](ui/help_view.py)** — 6 tabs sourced from `_HELP_HTML` dict (ko/en). `_TABS` class constant defines (html_key, label_key) pairs. -- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels still mostly Korean (point of incremental i18n extension). -- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing. +### ui/ +- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz with hot-path caching. Thin delegating shell — heavy work split into controllers below. Single-instance via `QLocalServer "ClockOutCalculatorInstance"`. Inline edit on clock-in/out labels (click). Auto-extracts updater.exe from PyInstaller `_MEIPASS` on first run. +- **[onboarding_view.py](ui/onboarding_view.py)** — 5-step wizard (welcome / work pattern / clock-in detection / leave+salary / discord). Forced on first launch (`ONBOARDING_COMPLETED=false`). Re-runnable from Help dialog. +- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hours+minutes spinboxes, language combo, font scale, high-contrast, DB path override, Discord webhook URL, Gitea feedback token, monthly goals, CSV import. +- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns). Salary card on monthly. Goal progress widget. matplotlib charts via `chart_widget.py`. +- **[today_summary.py](ui/today_summary.py)** — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in. +- **[goal_widget.py](ui/goal_widget.py)** — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0. +- **[meal_time_dialog.py](ui/meal_time_dialog.py)** — Lunch/dinner real start-end input. +- **[past_record_dialog.py](ui/past_record_dialog.py)** — Manual past-day entry (calendar right-click). +- **[leave_calendar_view.py](ui/leave_calendar_view.py)** — Color-coded leave usage calendar. +- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless time display. +- **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button. +- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`. +- **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay. +- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n). -### Utils (`utils/`) -- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Once per day, `~/.clockout_backups/database-YYYY-MM-DD.db`, 7-file rotation. Uses `sqlite3.Connection.backup` API for lock-safe copy. -- **[lock_detector.py](utils/lock_detector.py)** — Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" → locked). -- **[http_api.py](utils/http_api.py)** — stdlib `http.server` on `127.0.0.1`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. Started from `MainWindow.__init__` if `HTTP_API_ENABLED=true`. -- **[debug_log.py](utils/debug_log.py)** — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production. +### ui/controllers/ +- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot). +- **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache. +- **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push. + +### utils/ +- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API. +- **[lock_detector.py](utils/lock_detector.py)** — `is_screen_locked()` via Win32 `OpenInputDesktop` + `GetUserObjectInformation`. +- **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass). +- **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe. +- **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`. +- **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons. +- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`. - **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper. -- **[system_tray.py](utils/system_tray.py)** — Tray icon menu (lunch/break/clock-out/stats/calendar/help/mini-widget/quit), tooltips i18n. +- **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n. -### Time-off accounting in `update_display()` +### Top-level +- **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow. +- **[updater.py](updater.py)** — Standalone helper. `--pid --new --target `. Waits for main exit, replaces, relaunches. Backup `.bak` for rollback. +- **[updater.spec](updater.spec)** — PyInstaller spec (~6MB, no PyQt deps). +- **[main.spec](main.spec)** — Embeds `build/staging/updater.exe` as data (release.ps1 stages it). +- **[release.ps1](release.ps1)** — One-shot release: bump version → tests → build both exe → tag push → Gitea Release + asset upload. Optional Authenticode signing via `$env:CODE_SIGN_CERT`. + +## Time-off accounting in `update_display()` Critical invariant — preserve in any change: ```python @@ -72,49 +99,54 @@ remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_min remaining -= timedelta(minutes=total_time_off) # subtract AFTER, never via break_minutes mutation ``` -Pass actual `break_minutes` to `calculate_remaining_time`. Overtime/leave usage subtracted as a `timedelta` on the result. Progress bar uses `overtime_used_minutes=total_time_off` keyword arg of `calculate_work_progress`. - -### Workday rollover - -`workday_boundary_hour` setting (default 6). `start_new_workday()` triggers when `is_clocked_in=False` and `clock_in_time.date() != now.date() and now.hour >= boundary`. Overnight work past midnight stays attributed to previous workday until that hour. `auto_clock_out_previous_days()` retroactively closes records using shutdown events (6006). - ## Database invariants -- `work_records.date` UNIQUE (one row/day). -- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings. -- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable (manual additions / direct usage). DO NOT filter `WHERE work_record_id IS NOT NULL` — those rows render with "수동 추가" / "Manual" label. -- `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). -- Balance: `SUM(overtime_bank.earned_minutes) - SUM(overtime_usage.used_minutes)` via `get_total_overtime_balance()`. -- Settings dict from `get_settings()` already auto-converts numeric strings to int/float — additional `int(x)` casts in callers are dead code. +- `work_records.date` UNIQUE. +- `lunch_break`, `dinner_break` are BOOLEAN flags; durations from settings; ACTUAL meal times via `break_records.break_type='lunch'/'dinner'`. +- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable. Don't filter `NOT NULL` — those are manual additions. +- `leave_records.days` is FLOAT (1.0/0.5/0.25). +- Balance: `SUM(bank.earned) - SUM(usage.used)`. +- `notification_log` for daily dedupe (channel+event_type+date). +- `crash_log` for unhandled exceptions. ## Settings system -Stored as string key-value pairs in `settings` table. Always import keys from [core/settings_keys.py](core/settings_keys.py) — typos become ImportError. Defaults set in `init_default_settings()`. Auto-sync in `save_settings()`: -- `WORK_MINUTES ↔ WORK_HOURS` (floor: 450 min → 7 h, not 8 — settings_view sends `WORK_MINUTES` only) +Stored as string key-value in `settings` table. Always import keys from [settings_keys.py](core/settings_keys.py). Auto-sync in `save_settings()`: +- `WORK_MINUTES ↔ WORK_HOURS` (floor) - `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL` -Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running migrations every startup. +Migration sentinels prevent re-running. ## i18n -`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then to literal key. `tr_html('help.html.X')` reads `_HELP_HTML` dict. Window titles, menus, buttons, group boxes, tray menu, mini widget, all 6 notification messages, and HelpView tabs are translated. Many deeper dialog labels remain Korean — extending is just adding keys + replacing the literal. +`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장. -Language is read from `LANGUAGE` setting at `MainWindow.__init__`. Changing language requires restart for full propagation (existing widget instances keep their original-language text). +Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched. -## Conventions and gotchas +## Conventions -- **`Database.get_setting()` always returns a string (or default).** Use `get_setting_int/float/bool()` helpers or import a key constant. Already-loaded `settings = db.get_settings()` dict returns proper types. -- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "오전"/"오후" markers when `time_format=12`. -- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover. -- **Hot-path 1 Hz:** `update_display()` runs every second. Don't add un-cached DB calls — see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format` patterns. Health/weekly checks are gated by `now.minute % 5 == 0` to throttle to 5 min. -- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture. -- **Single-instance during dev:** `QLocalServer` blocks a second `python main.py`. Use import-level test or set `QT_QPA_PLATFORM=offscreen` for GUI smoke tests. -- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon). +- **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict. +- 24h `datetime` internal. 12h conversion only in `format_time()`. +- 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min. +- Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests. +- PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths. +- main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py). ## Tests -- [_integration_test.py](_integration_test.py) — 35 business-logic scenarios (no Qt). -- [_gui_smoke_test.py](_gui_smoke_test.py) — 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`. -- [_i18n_gui_test.py](_i18n_gui_test.py) — 5 ko/en switch verifications on real widgets. +- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt). +- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`. +- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets. +- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler` (v2.7.0+). -All three should be green before any release. +All should be green before any release. + +## Release flow + +```bash +# Edit core/version.py + CHANGELOG.md +git add -A && git commit -m "v2.X.Y: ..." +.\release.ps1 v2.X.Y +``` + +Auto-handles: version bump check, pytest+integration tests, two-exe build, ZIP, git tag push, Gitea Release create, asset upload (main.exe + updater.exe + ZIP). diff --git a/INSTALL.md b/INSTALL.md index f9e3502..086a117 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,24 +1,45 @@ # 설치 가이드 -## 1. Python 설치 +## 일반 사용자 — 빌드된 .exe로 설치 (권장) + +소스 빌드 없이 즉시 사용하려면 Gitea Releases에서 받으세요. + +1. https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator/releases +2. 최신 릴리스의 **main.exe** (단일 파일) 다운로드 +3. 원하는 폴더에 두고 더블클릭으로 실행 +4. 첫 실행 시 5단계 온보딩 위저드가 안내 + +`main.exe` 안에 `updater.exe`가 내장되어 있어 첫 실행 시 같은 폴더로 자동 추출됩니다. +이후 새 버전이 올라오면 앱이 알림 → 동의 → 자동 다운로드·교체·재시작합니다. + +> 옵션: 직접 실행하지 않고 ZIP을 받으면 `main.exe + updater.exe`가 같이 들어 있습니다. + +## 개발자 — 소스에서 실행 + +### 1. Python 설치 Python 3.9 이상이 필요합니다. -### Windows +#### Windows 1. https://www.python.org/downloads/ 방문 2. "Download Python 3.x.x" 클릭 -3. 설치 시 **"Add Python to PATH"** 체크 필수! +3. 설치 시 **"Add Python to PATH"** 체크 필수 확인: ```bash python --version ``` -## 2. 프로젝트 다운로드 +### 2. 프로젝트 다운로드 -프로젝트를 다운로드하거나 압축을 해제합니다. +```bash +git clone https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator.git +cd Clock_out_Time_Calculator +``` -## 3. 패키지 설치 +또는 ZIP을 받아 압축 해제. + +### 3. 패키지 설치 프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다. @@ -26,66 +47,26 @@ python --version pip install -r requirements.txt ``` -### 설치되는 패키지: -1. **PyQt5** - GUI 프레임워크 -2. **pywin32** - Windows API 접근 -3. **python-dateutil** - 날짜 계산 -4. **matplotlib** - 그래프 -5. **plyer** - 알림 시스템 -6. **holidays** - 한국 공휴일 자동 등록 (음력 명절 포함) +#### 설치되는 패키지 (requirements.txt) +1. **PyQt5** — GUI 프레임워크 +2. **pywin32** — Windows API (이벤트 뷰어, 화면 잠금 감지) +3. **python-dateutil** — 날짜 계산 +4. **matplotlib** — 통계 차트 +5. **plyer** — 시스템 알림 +6. **holidays** — 한국 공휴일 자동 등록 (음력 명절 포함) -## 4. 리소스 다운로드 (선택) - -리소스가 없어도 프로그램은 작동하지만, 더 예쁜 UI를 위해 다운로드를 권장합니다. - -```bash -python download_resources.py -``` - -이 스크립트는 무료 리소스 다운로드 링크를 안내합니다. - -### 수동 다운로드: - -#### 아이콘 -다음 사이트에서 다운로드: -- **Flaticon**: https://www.flaticon.com/ -- **Material Design Icons**: https://materialdesignicons.com/ -- **Icons8**: https://icons8.com/ - -다운로드 후 `resources/icons/` 폴더에 저장 - -필요한 아이콘: -- `app_icon.ico` (512x512) -- `clock.png`, `timer.png`, `lunch.png` -- `calendar.png`, `statistics.png`, `vacation.png` -- `settings.png`, `notification.png` - -#### 사운드 -다음 사이트에서 다운로드: -- **Mixkit**: https://mixkit.co/free-sound-effects/ (추천) -- **Freesound**: https://freesound.org/ -- **Zapsplat**: https://www.zapsplat.com/ - -다운로드 후 `resources/sounds/` 폴더에 저장 - -필요한 사운드: -- `clock_out_alarm.wav` - 퇴근시간 알림 -- `notification.wav` - 일반 알림 -- `success.wav` - 성공 효과음 - -## 5. 실행 +### 4. 실행 ```bash python main.py ``` -### 관리자 권한으로 실행 (권장) +#### 관리자 권한으로 실행 (권장) Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다. 1. **방법 1**: cmd를 관리자 권한으로 실행 - - Windows 키 + X - - "명령 프롬프트(관리자)" 또는 "Windows PowerShell(관리자)" 선택 + - Windows 키 + X → "터미널(관리자)" 또는 "PowerShell(관리자)" - 프로젝트 폴더로 이동: `cd "경로"` - `python main.py` 실행 @@ -93,11 +74,39 @@ Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있 - `python main.py`를 실행하는 배치 파일(.bat) 생성 - 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크 -## 6. 첫 실행 +### 5. 첫 실행 -1. 프로그램이 실행되면 자동으로 데이터베이스(`database.db`) 생성 -2. Windows 이벤트 뷰어에서 오늘의 부팅 시간 자동 감지 -3. 감지된 시간이 출근시간으로 설정됨 +1. 실행 시 자동으로 데이터베이스(`database.db`) 생성 +2. 5단계 온보딩 위저드 표시 + - 환영 → 근무패턴 → 출근 자동 감지 → 연차/시급 → Discord 웹훅(옵션) +3. 위저드 완료 후 메인 화면 진입 +4. 이후 실행에서는 위저드 없이 바로 시작 + +### 6. 단축근무자 설정 (예: 7시간 30분) + +온보딩에서 미설정한 경우: +1. 설정(`Ctrl+,`) → 근무 시간 → **근무 패턴** +2. "단축근무 7시간 30분 (점심 30분)" 또는 사용자 정의 선택 +3. 시·분 직접 입력 가능 (5분 단위) +4. 저장 → 즉시 메인 화면 반영 + +## 클라우드 동기화 (여러 PC 공용) + +OneDrive / Dropbox 폴더에 DB를 두면 자동 동기화됩니다 (WAL 모드). + +1. OneDrive/Dropbox 안에 `database.db` 위치 결정 +2. 설정 → 데이터 관리 → DB 경로 → 변경 +3. 기존 DB를 새 위치로 복사 → 재시작 +4. 다른 PC에서도 같은 경로 지정하면 데이터 공유 + +## 환경 변수 + +| 변수 | 용도 | +|------|------| +| `CLOCKOUT_DEBUG=1` | `~/.clockout_logs/debug.log`에 디버그 로그 출력 | +| `CLOCKOUT_DEBUG_DIR=경로` | 로그 저장 위치 변경 | +| `GITEA_TOKEN` | 릴리스 발행 시 PAT (개발자용) | +| `CODE_SIGN_CERT` / `CODE_SIGN_PASS` | Authenticode 인증서 경로/암호 (개발자용) | ## 문제 해결 @@ -119,42 +128,57 @@ https://aka.ms/vs/17/release/vc_redist.x64.exe ### 이벤트 뷰어 접근 불가 - 관리자 권한으로 실행 -- 또는 설정에서 수동 입력 모드 사용 +- 또는 메인 화면 출근시각 옆 ✏️ 버튼으로 수동 입력 + +### Discord 웹훅 "실패" 표시 +- v2.3.3 이전 버전에서 발생 — Cloudflare가 Python User-Agent 차단 +- 최신 버전으로 업데이트하면 해결 (브라우저 UA로 우회) + +### 온보딩 위저드를 다시 보고 싶음 +- 도움말(F1) → "🚀 온보딩 다시 보기" ## 업그레이드 +### .exe 사용자 +- 앱이 자동 감지 → 알림 → 동의 → 자동 처리 +- 수동 트리거: F5 또는 설정 → 데이터 관리 → "업데이트 확인" + +### 소스 사용자 ```bash +git pull pip install --upgrade -r requirements.txt ``` ## 제거 -1. 프로젝트 폴더 삭제 -2. 패키지 제거 (선택): -```bash -pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer -``` +1. 프로젝트 폴더 삭제 (`main.exe` 또는 소스) +2. 데이터 보존하려면 `database.db`만 별도 백업 +3. 자동 백업: `~/.clockout_backups/` 에 7개 회전 보관 (필요 시 삭제) ## 프로덕션 빌드 (PyInstaller) 소스 없이 실행 파일만 배포하려면: + ```bash -python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB) -python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB, 자가 업데이터) +# 자가 업데이터 먼저 빌드 (main.spec이 데이터로 임베드) +python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB) + +# updater.exe를 staging 폴더로 복사 (main.spec --clean 시 보호) +mkdir -p build/staging +cp dist/updater.exe build/staging/ + +# 메인 앱 빌드 (updater.exe 임베드 포함) +python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB) ``` -배포 패키지에는 두 .exe를 함께 포함시켜 같은 폴더에 두세요. -자동 업데이트는 main.exe가 같은 폴더의 updater.exe를 호출해야 동작합니다. +또는 [release.ps1](release.ps1) 한 번 실행으로 전체 자동화. 빌드 시 주의: - `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행 - `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨 - -## 환경 변수 - -- `CLOCKOUT_DEBUG=1` — 디버그 로그를 `~/.clockout_logs/debug.log`로 출력 -- `CLOCKOUT_DEBUG_DIR=경로` — 로그 저장 위치 변경 +- `main.exe` 단일 배포가 가능 (updater.exe는 첫 실행 시 자동 추출) ## 다음 단계 -설치가 완료되었다면 [README.md](README.md)를 참고하여 프로그램을 사용하세요! +설치가 완료되었다면 [README.md](README.md) 와 도움말(F1)을 참고하세요. +개발자는 [CLAUDE.md](CLAUDE.md) + [AGENTS.md](AGENTS.md) 도 함께 읽으세요. diff --git a/README.md b/README.md index 43d65dc..0f6d47c 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,11 @@ ### 7. 통계·분석 - 주간/월간 요약 + matplotlib 차트 -- 일별 근무시간 + 연장 누적 막대 그래프 +- 일별 근무시간 + 연장 누적 막대 그래프 (호버 시 정확한 수치 툴팁) - 요일별 평균 근무시간 -- 근무 패턴 인사이트 (정적 통계 요약) +- 출근 시각 분포 히스토그램 (30분 단위 + 평균선) +- 근무 패턴 인사이트 +- **시급 옵션 활성 시 추정 급여** (월간 + 오늘 요약) ### 8. 공휴일 관리 - 한국 공휴일 자동 등록 (`holidays` 패키지) @@ -69,13 +71,35 @@ - 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작 - F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거 - 실패 시 자동 롤백 +- **main.exe 단독 배포** (updater.exe 내장, 첫 실행 시 자동 추출) -### 12. 다국어 지원 (i18n) -- 한국어 / English 전환 -- 알림 메시지·UI 라벨 28개 카테고리 +### 12. 첫 실행 온보딩 위저드 +- 신규 사용자: 5단계 (환영 → 근무패턴 → 출근 감지 → 연차/시급 → Discord) 강제 표시 +- 기존 사용자: 자동 완료 처리 + 도움말(F1) → "🚀 온보딩 다시 보기" + +### 13. 사용자 친화 기능 +- **메인 화면 인라인 편집** — 출퇴근 시각 라벨 클릭으로 즉시 수정 +- **퇴근 후 "오늘 요약" 카드** — 총 근무·점심·외출·연장·추정급여 한눈에 +- **장시간 근무 휴식 권고** — 4시간 연속 근무 시 토스트 + Discord push +- **점심/저녁 실제 시간 입력** — 버튼 우클릭 → 시작·종료 시각 +- **캘린더 우클릭 → 과거 일자 추가/편집/삭제** +- **월간 목표** — 연장근무 상한 / 일평균 목표 + 진행률 게이지 +- **CSV 가져오기** — 표준 포맷으로 타 도구에서 마이그레이션 +- **자동 Crash Report** — 예외 발생 시 Gitea Issues 자동 등록 (옵션) +- **주간 자동 리포트** — 월요일 첫 출근 시 지난주 요약 push +- **휴가 캘린더** — 종일/반차/반반차 색상 구분 +- **Discord 웹훅 알림** — 출퇴근/휴식권고/주간리포트 모바일 push (옵션, 서버 0) + +### 14. 접근성 +- 글꼴 크기 100% / 125% / 150% +- 고대비 모드 (검정 배경 + 노란 텍스트) + +### 16. 다국어 지원 (i18n) +- 한국어 / English 전환 (재시작 또는 즉시 갱신) +- 알림 메시지·UI 라벨 30+ 카테고리 - HelpView 6개 탭 ko/en HTML 콘텐츠 -### 13. 단축키 +### 17. 단축키 - `Ctrl+O` 출/퇴근 토글 - `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글 - `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말 diff --git a/_integration_test.py b/_integration_test.py index 0756f2f..9b4e25d 100644 --- a/_integration_test.py +++ b/_integration_test.py @@ -485,6 +485,234 @@ def s35_lock(): # 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트) +# ============================================================ +# 시나리오 36-50: v2.3+ 신규 기능 (온보딩, Discord, 급여, 목표, CSV, 알림 dedupe 등) +# ============================================================ + +@case("S36. 신규 DB는 onboarding_completed = 'false' (위저드 강제)") +def s36_onboarding_new(): + db = fresh_db('s36') + assert db.get_setting('onboarding_completed') == 'false' + + +@case("S37. 기존 사용자 (work_records 있음) → 자동 완료") +def s37_onboarding_existing(): + db = fresh_db('s37') + # work_record 1건 추가 후 마이그레이션 재실행 + today = date.today().isoformat() + db.add_work_record(today, '09:00:00', is_manual=True) + # 다시 호출 (init_database에서 호출되는 헬퍼) + db.migrate_v23_onboarding_for_existing() + assert db.get_setting('onboarding_completed') == 'true' + + +@case("S38. salary.estimate_pay: 시급 0원 → 모두 0") +def s38_salary_zero_wage(): + from core.salary import estimate_pay + out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0) + assert out['base'] == 0 and out['overtime'] == 0 and out['total'] == 0 + + +@case("S39. salary.estimate_pay: 8h 근무 + 30분 연장, 시급 10000 → base 75000 + ot 7500") +def s39_salary_basic(): + from core.salary import estimate_pay + out = estimate_pay( + [{'total_hours': 8.0, 'overtime_minutes': 30}], + hourly_wage=10000, + overtime_rate=1.5, + ) + # 정규 = 8 - 0.5 = 7.5h × 10000 = 75000 + # 연장 = 0.5h × 10000 × 1.5 = 7500 + assert abs(out['base'] - 75000) < 0.01, out['base'] + assert abs(out['overtime'] - 7500) < 0.01, out['overtime'] + assert abs(out['total'] - 82500) < 0.01 + + +@case("S40. salary.format_won: 콤마 + '원' 접미사") +def s40_format_won(): + from core.salary import format_won + assert format_won(0) == '0원' + assert format_won(1234567) == '1,234,567원' + assert format_won(82500.4) == '82,500원' + + +@case("S41. CSV 가져오기: 표준 포맷 파싱") +def s41_csv_parse(): + from utils.csv_importer import parse_csv + p = os.path.join(tempfile.gettempdir(), 'clockout_test.csv') + with open(p, 'w', encoding='utf-8') as f: + f.write("date,clock_in,clock_out,lunch_minutes,memo\n") + f.write("2026-04-01,09:00,18:00,60,첫째날\n") + f.write("2026-04-02,09:30:00,17:30:00,30,단축\n") + rows = parse_csv(p) + os.remove(p) + assert len(rows) == 2 + assert rows[0]['clock_in'] == '09:00:00' # 정규화 (HH:MM → HH:MM:SS) + assert rows[0]['lunch_minutes'] == 60 + assert rows[1]['lunch_minutes'] == 30 + assert rows[1]['memo'] == '단축' + + +@case("S42. CSV 검증 실패: 잘못된 날짜 형식 → ValueError") +def s42_csv_invalid(): + from utils.csv_importer import parse_csv + p = os.path.join(tempfile.gettempdir(), 'clockout_bad.csv') + with open(p, 'w', encoding='utf-8') as f: + f.write("date,clock_in,clock_out,lunch_minutes,memo\n") + f.write("not-a-date,09:00,18:00,60,\n") + try: + parse_csv(p) + assert False, "should have raised" + except ValueError: + pass + finally: + os.remove(p) + + +@case("S43. CSV import on_conflict='skip': 기존 일자는 건너뜀") +def s43_csv_skip(): + from utils.csv_importer import parse_csv, import_records + db = fresh_db('s43') + # 기존 레코드 1건 + db.add_work_record('2026-04-01', '08:30:00', is_manual=True) + + p = os.path.join(tempfile.gettempdir(), 'clockout_dup.csv') + with open(p, 'w', encoding='utf-8') as f: + f.write("date,clock_in,clock_out,lunch_minutes,memo\n") + f.write("2026-04-01,09:00,18:00,60,중복\n") + f.write("2026-04-02,09:00,18:00,60,신규\n") + + rows = parse_csv(p) + added, updated, skipped = import_records(db, rows, on_conflict='skip') + os.remove(p) + assert added == 1 and updated == 0 and skipped == 1, (added, updated, skipped) + # 기존 레코드 보존 확인 (08:30 그대로) + rec = db.get_work_record('2026-04-01') + assert rec['clock_in'] == '08:30:00' + + +@case("S44. CSV import on_conflict='overwrite': 기존 일자 덮어씀") +def s44_csv_overwrite(): + from utils.csv_importer import parse_csv, import_records + db = fresh_db('s44') + db.add_work_record('2026-04-01', '08:30:00', is_manual=True) + + p = os.path.join(tempfile.gettempdir(), 'clockout_ov.csv') + with open(p, 'w', encoding='utf-8') as f: + f.write("date,clock_in,clock_out,lunch_minutes,memo\n") + f.write("2026-04-01,09:00,18:00,60,덮어쓰기\n") + + rows = parse_csv(p) + added, updated, skipped = import_records(db, rows, on_conflict='overwrite') + os.remove(p) + assert updated == 1 and added == 0 + rec = db.get_work_record('2026-04-01') + assert rec['clock_in'] == '09:00:00' + + +@case("S45. notification_log dedupe: 같은 (channel, event_type) 같은 날 1회") +def s45_notification_dedupe(): + db = fresh_db('s45') + assert not db.has_notification_today('discord', 'weekly_report') + db.log_notification('discord', 'weekly_report', payload='test', success=True) + assert db.has_notification_today('discord', 'weekly_report') + # 다른 event_type은 별개 + assert not db.has_notification_today('discord', 'clock_in') + + +@case("S46. add_meal_record: 12:00-13:00 → 60분 누적") +def s46_meal_record(): + db = fresh_db('s46') + today = date.today().isoformat() + # 오늘이 아닌 날짜로 add (work_record 미존재 OK) + db.add_meal_record('2026-04-01', '12:00:00', '13:00:00', meal_type='lunch') + db.add_meal_record('2026-04-01', '18:30:00', '19:00:00', meal_type='dinner') + # get_meal_minutes_today은 오늘만 → 일반화된 검증은 SQL로 + conn = db.get_connection() + cur = conn.cursor() + cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='lunch'", + ('2026-04-01',)) + assert cur.fetchone()[0] == 60 + cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='dinner'", + ('2026-04-01',)) + assert cur.fetchone()[0] == 30 + conn.close() + + +@case("S47. crash_log 자동 생성 + 기록") +def s47_crash_log(): + from utils.crash_handler import _log_crash + db = fresh_db('s47') + _log_crash(db, 'TestException', 'sample message', 'Traceback ...', 'v2.6.0') + conn = db.get_connection() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM crash_log WHERE exception_type = 'TestException'") + assert cur.fetchone()[0] == 1 + cur.execute("SELECT message, app_version FROM crash_log WHERE exception_type = 'TestException'") + msg, ver = cur.fetchone() + assert msg == 'sample message' and ver == 'v2.6.0' + conn.close() + + +@case("S48. updater_client.is_newer: 정확한 semver 비교") +def s48_updater_compare(): + from utils.updater_client import is_newer + assert is_newer('v2.7.0', '2.6.0') + assert is_newer('2.6.1', 'v2.6.0') + assert not is_newer('v2.6.0', 'v2.6.0') + assert not is_newer('v2.5.0', 'v2.6.0') + assert is_newer('v2.10.0', 'v2.9.99') # 자릿수 비교가 아니라 정수 비교 + + +@case("S49. Discord webhook URL 비어있으면 silent False (네트워크 안 탐)") +def s49_discord_empty(): + from utils.discord_webhook import send, send_test + assert send('', 't', 'd') is False + assert send('http://invalid', 't', 'd') is False # https:// 아님 + assert send_test('') is False + + +@case("S50. Goal 설정: 0 = 비활성 / 양수 = 활성") +def s50_goals(): + db = fresh_db('s50') + # 기본값 확인 (0) + assert db.get_setting_int('goal_overtime_max_monthly', 0) == 0 + assert db.get_setting_int('goal_avg_hours_daily', 0) == 0 + # 활성화 + db.save_settings({'goal_overtime_max_monthly': 1200, 'goal_avg_hours_daily': 8}) + assert db.get_setting_int('goal_overtime_max_monthly') == 1200 + assert db.get_setting_int('goal_avg_hours_daily') == 8 + + +@case("S51. 글꼴 크기 / 고대비 설정 키") +def s51_accessibility_keys(): + db = fresh_db('s51') + # 기본값 + assert db.get_setting_float('font_scale', 1.0) == 1.0 + assert db.get_setting_bool('high_contrast', False) is False + # 변경 + db.save_settings({'font_scale': 1.5, 'high_contrast': True}) + assert db.get_setting_float('font_scale') == 1.5 + assert db.get_setting_bool('high_contrast') is True + + +@case("S52. CSV import + overtime 적립까지 정상 동작") +def s52_csv_overtime(): + from utils.csv_importer import parse_csv, import_records + db = fresh_db('s52') + p = os.path.join(tempfile.gettempdir(), 'clockout_ot.csv') + with open(p, 'w', encoding='utf-8') as f: + f.write("date,clock_in,clock_out,lunch_minutes,memo\n") + # 8h 근무 + 점심 60m + 90분 연장 → 90분 적립 예상 + f.write("2026-04-01,09:00,19:30,60,연장근무\n") + rows = parse_csv(p) + added, _, _ = import_records(db, rows, on_conflict='skip') + os.remove(p) + assert added == 1 + bal = db.get_total_overtime_balance() + assert bal == 90, f"overtime balance: {bal}" + + # ============================================================ # Run all # ============================================================ @@ -520,6 +748,23 @@ def main(): s33_short_overtime() s34_format() s35_lock() + s36_onboarding_new() + s37_onboarding_existing() + s38_salary_zero_wage() + s39_salary_basic() + s40_format_won() + s41_csv_parse() + s42_csv_invalid() + s43_csv_skip() + s44_csv_overwrite() + s45_notification_dedupe() + s46_meal_record() + s47_crash_log() + s48_updater_compare() + s49_discord_empty() + s50_goals() + s51_accessibility_keys() + s52_csv_overtime() print() print("=" * 60) diff --git a/core/i18n.py b/core/i18n.py index 44f0896..3cb56d7 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -103,6 +103,7 @@ _DICT = { 'msg.no_record.body': '오늘 출근 기록이 없습니다.', 'msg.confirm_delete.title': '삭제 확인', 'msg.no_data.title': '데이터 없음', + 'msg.manual_added': '수동 추가', # === 트레이 === 'tray.open': '프로그램 열기', @@ -153,6 +154,103 @@ _DICT = { 'help.tab_leave': '🌴 연차/휴가', 'help.tab_break': '🚪 외출/저녁', 'help.tab_faq': '❓ 자주 묻는 질문', + + # === clock_in_dialog === + 'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요', + 'dlg.clock_in.label': '출근시간:', + 'dlg.clock_in.quick': '빠른 선택:', + 'dlg.clock_in.btn_now': '현재', + + # === break_view === + 'dlg.break.edit_title': '외출 기록 수정', + 'dlg.break.out_label': '외출 시간:', + 'dlg.break.in_label': '복귀 시간:', + 'dlg.break.reason_label': '사유:', + 'view.break.today_title': '오늘의 외출 기록', + 'view.break.col_out': '외출 시간', + 'view.break.col_in': '복귀 시간', + 'view.break.col_duration': '소요 시간', + 'view.break.col_reason': '사유', + 'view.break.in_progress': '진행중', + 'view.break.total_zero': '총 외출 시간: 0분', + 'view.break.total_fmt': '총 외출 시간: {h}시간 {m}분', + 'view.break.total_min_only': '총 외출 시간: {m}분', + 'view.break.duration_fmt': '{h}시간 {m}분', + 'view.break.duration_min_only': '{m}분', + 'view.break.delete_confirm': '이 외출 기록을 삭제하시겠습니까?', + 'btn.refresh': '새로고침', + 'btn.edit_short': '수정', + 'btn.delete_short': '삭제', + + # === overtime_view === + 'view.overtime.title': '연장근무 내역', + 'view.overtime.balance_zero': '잔액: 0분', + 'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)', + 'view.overtime.earned_group': '💰 적립 내역', + 'view.overtime.used_group': '📤 사용 내역', + 'view.overtime.col_date': '날짜', + 'view.overtime.col_earned': '적립', + 'view.overtime.col_used': '사용', + 'view.overtime.col_memo': '메모', + 'view.overtime.col_reason': '사유', + 'view.overtime.btn_add_earned': '➕ 수동 적립', + 'view.overtime.btn_add_used': '➕ 수동 사용', + 'view.overtime.menu_delete': '❌ 삭제', + 'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}', + 'view.overtime.manual_earned_title': '추가근무 수동 적립', + 'view.overtime.manual_used_title': '추가근무 수동 사용', + 'view.overtime.field_date': '날짜:', + 'view.overtime.field_time': '시간:', + 'view.overtime.field_memo': '메모:', + 'view.overtime.field_reason': '사유:', + 'view.overtime.unit_hour_suffix': '시간', + 'view.overtime.minute_0': '0분', + 'view.overtime.minute_30': '30분', + 'view.overtime.placeholder_memo': '선택사항', + 'view.overtime.placeholder_reason': '예: 개인 사유', + 'view.overtime.zero_add_error': '0분은 추가할 수 없습니다.', + 'view.overtime.zero_use_error': '0분은 사용할 수 없습니다.', + 'view.overtime.balance_short_title': '잔액 부족', + 'view.overtime.balance_short_body': '사용 가능한 시간이 부족합니다.\n\n요청: {req_h}시간 {req_m}분\n잔액: {bal_h}시간 {bal_m}분', + 'view.overtime.saved_earned': '{h}시간 {m}분이 적립되었습니다.', + 'view.overtime.saved_used': '{h}시간 {m}분이 사용 처리되었습니다.', + + # === leave_view === + 'view.leave.title': '연차 관리', + 'view.leave.balance_zero': '잔여: 0일', + 'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)', + 'view.leave.btn_set_balance': '잔여 설정', + 'view.leave.used_group': '📤 사용 내역', + 'view.leave.col_date': '날짜', + 'view.leave.col_type': '구분', + 'view.leave.col_used': '사용', + 'view.leave.col_reason': '사유', + 'view.leave.btn_add': '➕ 연차 사용 추가', + 'view.leave.btn_calendar': '📅 캘린더 보기', + 'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}', + 'view.leave.set_title': '연차 시간 설정', + 'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분', + 'view.leave.set_done_title': '설정 완료', + 'view.leave.set_done_body': '연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다.', + 'view.leave.add_title': '연차 사용 기록 추가', + 'view.leave.field_date': '날짜:', + 'view.leave.field_type': '구분:', + 'view.leave.field_hours': '시간:', + 'view.leave.field_reason': '사유:', + 'view.leave.type_annual': '연차', + 'view.leave.type_half': '반차', + 'view.leave.type_quarter': '반반차', + 'view.leave.type_hourly': '시간', + 'view.leave.placeholder_reason': '예) 개인 사유, 병원 방문 등', + 'view.leave.note_auto_deduct': '※ 잔여 연차가 자동 차감됩니다.', + 'view.leave.short_title': '잔여 연차 부족', + 'view.leave.short_body': '잔여 연차가 부족합니다.\n현재 잔여: {balance}일\n사용 요청: {req}일', + 'view.leave.confirm_title': '연차 사용 기록 추가', + 'view.leave.confirm_body': '날짜: {date}\n구분: {type}\n사용: {days}일 ({hours}시간)\n사유: {reason}\n\n이 기록을 추가하시겠습니까?', + 'view.leave.added_title': '추가 완료', + 'view.leave.added_body': '{days}일 ({hours}시간)의 연차 사용이 기록되었습니다.', + 'view.leave.error_title': '오류', + 'view.leave.error_body': '연차 기록 추가 중 오류가 발생했습니다:\n{err}', }, 'en': { # === Menu/Buttons === @@ -244,6 +342,7 @@ _DICT = { 'msg.no_record.body': 'No clock-in record for today.', 'msg.confirm_delete.title': 'Confirm Delete', 'msg.no_data.title': 'No Data', + 'msg.manual_added': 'Manual', # === Tray === 'tray.open': 'Open Program', @@ -294,6 +393,103 @@ _DICT = { 'help.tab_leave': '🌴 Leave', 'help.tab_break': '🚪 Break/Dinner', 'help.tab_faq': '❓ FAQ', + + # === clock_in_dialog === + 'dlg.clock_in.prompt': "Enter today's clock-in time", + 'dlg.clock_in.label': 'Clock-in time:', + 'dlg.clock_in.quick': 'Quick pick:', + 'dlg.clock_in.btn_now': 'Now', + + # === break_view === + 'dlg.break.edit_title': 'Edit Break Record', + 'dlg.break.out_label': 'Break out:', + 'dlg.break.in_label': 'Return:', + 'dlg.break.reason_label': 'Reason:', + 'view.break.today_title': "Today's Break Records", + 'view.break.col_out': 'Break out', + 'view.break.col_in': 'Return', + 'view.break.col_duration': 'Duration', + 'view.break.col_reason': 'Reason', + 'view.break.in_progress': 'In progress', + 'view.break.total_zero': 'Total break: 0 min', + 'view.break.total_fmt': 'Total break: {h}h {m}m', + 'view.break.total_min_only': 'Total break: {m} min', + 'view.break.duration_fmt': '{h}h {m}m', + 'view.break.duration_min_only': '{m} min', + 'view.break.delete_confirm': 'Delete this break record?', + 'btn.refresh': 'Refresh', + 'btn.edit_short': 'Edit', + 'btn.delete_short': 'Delete', + + # === overtime_view === + 'view.overtime.title': 'Overtime History', + 'view.overtime.balance_zero': 'Balance: 0 min', + 'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)', + 'view.overtime.earned_group': '💰 Earned', + 'view.overtime.used_group': '📤 Used', + 'view.overtime.col_date': 'Date', + 'view.overtime.col_earned': 'Earned', + 'view.overtime.col_used': 'Used', + 'view.overtime.col_memo': 'Memo', + 'view.overtime.col_reason': 'Reason', + 'view.overtime.btn_add_earned': '➕ Manual Earn', + 'view.overtime.btn_add_used': '➕ Manual Use', + 'view.overtime.menu_delete': '❌ Delete', + 'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}', + 'view.overtime.manual_earned_title': 'Manual Overtime Earn', + 'view.overtime.manual_used_title': 'Manual Overtime Use', + 'view.overtime.field_date': 'Date:', + 'view.overtime.field_time': 'Time:', + 'view.overtime.field_memo': 'Memo:', + 'view.overtime.field_reason': 'Reason:', + 'view.overtime.unit_hour_suffix': 'h', + 'view.overtime.minute_0': '0 min', + 'view.overtime.minute_30': '30 min', + 'view.overtime.placeholder_memo': 'Optional', + 'view.overtime.placeholder_reason': 'e.g., personal', + 'view.overtime.zero_add_error': 'Cannot add 0 minutes.', + 'view.overtime.zero_use_error': 'Cannot use 0 minutes.', + 'view.overtime.balance_short_title': 'Insufficient Balance', + 'view.overtime.balance_short_body': 'Not enough balance.\n\nRequested: {req_h}h {req_m}m\nBalance: {bal_h}h {bal_m}m', + 'view.overtime.saved_earned': '{h}h {m}m banked.', + 'view.overtime.saved_used': '{h}h {m}m used.', + + # === leave_view === + 'view.leave.title': 'Leave Management', + 'view.leave.balance_zero': 'Balance: 0 days', + 'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)', + 'view.leave.btn_set_balance': 'Set Balance', + 'view.leave.used_group': '📤 Used', + 'view.leave.col_date': 'Date', + 'view.leave.col_type': 'Type', + 'view.leave.col_used': 'Used', + 'view.leave.col_reason': 'Reason', + 'view.leave.btn_add': '➕ Add Leave Usage', + 'view.leave.btn_calendar': '📅 Calendar', + 'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}', + 'view.leave.set_title': 'Set Leave Hours', + 'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min', + 'view.leave.set_done_title': 'Saved', + 'view.leave.set_done_body': 'Leave balance set to {days} days ({hours}h).', + 'view.leave.add_title': 'Add Leave Usage', + 'view.leave.field_date': 'Date:', + 'view.leave.field_type': 'Type:', + 'view.leave.field_hours': 'Hours:', + 'view.leave.field_reason': 'Reason:', + 'view.leave.type_annual': 'Annual', + 'view.leave.type_half': 'Half', + 'view.leave.type_quarter': 'Quarter', + 'view.leave.type_hourly': 'Hourly', + 'view.leave.placeholder_reason': 'e.g., personal, medical', + 'view.leave.note_auto_deduct': '※ Leave balance is auto-deducted.', + 'view.leave.short_title': 'Insufficient Leave', + 'view.leave.short_body': 'Not enough leave.\nCurrent: {balance} days\nRequested: {req} days', + 'view.leave.confirm_title': 'Add Leave Usage', + 'view.leave.confirm_body': 'Date: {date}\nType: {type}\nUsed: {days} days ({hours}h)\nReason: {reason}\n\nAdd this record?', + 'view.leave.added_title': 'Added', + 'view.leave.added_body': '{days} days ({hours}h) of leave usage recorded.', + 'view.leave.error_title': 'Error', + 'view.leave.error_body': 'Failed to add leave record:\n{err}', }, } diff --git a/core/version.py b/core/version.py index 18b818e..0ae8f25 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.6.0' +__version__ = '2.7.0' diff --git a/tests/test_crash_handler.py b/tests/test_crash_handler.py new file mode 100644 index 0000000..2e02dc4 --- /dev/null +++ b/tests/test_crash_handler.py @@ -0,0 +1,110 @@ +""" +utils.crash_handler 단위 테스트. + +GUI 다이얼로그는 호출하지 않음 (테스트 주체는 _log_crash + _send_to_gitea). +""" +import os +import sys +import tempfile +from unittest.mock import patch, MagicMock + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.database import Database +from utils.crash_handler import _log_crash, _send_to_gitea + + +@pytest.fixture +def db(): + p = os.path.join(tempfile.gettempdir(), 'clockout_crash_ut.db') + if os.path.exists(p): + os.remove(p) + d = Database(p) + yield d + try: + os.remove(p) + except OSError: + pass + + +class TestLogCrash: + def test_creates_table_and_inserts(self, db): + _log_crash(db, 'TestExc', 'msg', 'Traceback ...', 'v2.6.0') + conn = db.get_connection() + cur = conn.cursor() + cur.execute("SELECT exception_type, message, app_version FROM crash_log") + row = cur.fetchone() + conn.close() + assert row[0] == 'TestExc' + assert row[1] == 'msg' + assert row[2] == 'v2.6.0' + + def test_table_idempotent_creation(self, db): + # 두 번 호출해도 두 행이 들어가야 (CREATE TABLE IF NOT EXISTS) + _log_crash(db, 'A', 'a', 't', 'v1') + _log_crash(db, 'B', 'b', 't', 'v1') + conn = db.get_connection() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM crash_log") + count = cur.fetchone()[0] + conn.close() + assert count == 2 + + def test_silent_on_db_error(self, db): + # 잘못된 DB 객체를 줘도 예외 전파 안 됨 (안 그러면 후킹 자체가 죽음) + broken = MagicMock() + broken.get_connection.side_effect = RuntimeError('boom') + # raise되면 안 됨 + _log_crash(broken, 'X', 'x', 'tb', 'v') + + +class TestSendToGitea: + @patch('utils.crash_handler.urllib.request.urlopen') + def test_success(self, mock_urlopen): + resp = MagicMock() + resp.status = 201 + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + mock_urlopen.return_value = resp + + ok = _send_to_gitea('fake_token', 'title', 'body') + assert ok is True + + req = mock_urlopen.call_args[0][0] + # PAT 헤더 확인 + assert req.headers.get('Authorization') == 'token fake_token' + # User-Agent 위장 + assert 'Mozilla' in req.headers.get('User-agent', '') + + @patch('utils.crash_handler.urllib.request.urlopen') + def test_4xx_returns_false(self, mock_urlopen): + resp = MagicMock() + resp.status = 401 + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + mock_urlopen.return_value = resp + + assert _send_to_gitea('bad', 't', 'b') is False + + @patch('utils.crash_handler.urllib.request.urlopen') + def test_network_error(self, mock_urlopen): + import urllib.error + mock_urlopen.side_effect = urllib.error.URLError('boom') + assert _send_to_gitea('t', 't', 'b') is False + + @patch('utils.crash_handler.urllib.request.urlopen') + def test_payload_json(self, mock_urlopen): + import json as _json + resp = MagicMock() + resp.status = 201 + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + mock_urlopen.return_value = resp + + _send_to_gitea('tok', 'TITLE', 'BODY') + req = mock_urlopen.call_args[0][0] + body = _json.loads(req.data.decode('utf-8')) + assert body['title'] == 'TITLE' + assert body['body'] == 'BODY' diff --git a/tests/test_csv_importer.py b/tests/test_csv_importer.py new file mode 100644 index 0000000..dd810a3 --- /dev/null +++ b/tests/test_csv_importer.py @@ -0,0 +1,127 @@ +""" +utils.csv_importer 단위 테스트. +""" +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from utils.csv_importer import parse_csv, _normalize_row, _normalize_time + + +class TestNormalizeTime: + def test_hh_mm_to_hh_mm_ss(self): + assert _normalize_time('09:00', 'clock_in') == '09:00:00' + + def test_hh_mm_ss_unchanged(self): + assert _normalize_time('09:00:00', 'clock_in') == '09:00:00' + + def test_empty_raises(self): + with pytest.raises(ValueError): + _normalize_time('', 'clock_in') + + def test_invalid_format_raises(self): + with pytest.raises(ValueError): + _normalize_time('foo', 'clock_in') + with pytest.raises(ValueError): + _normalize_time('25:00', 'clock_in') + + +class TestNormalizeRow: + def test_basic_row(self): + row = { + 'date': '2026-04-01', + 'clock_in': '09:00', + 'clock_out': '18:00', + 'lunch_minutes': '60', + 'memo': '메모', + } + out = _normalize_row(row) + assert out['date'] == '2026-04-01' + assert out['clock_in'] == '09:00:00' + assert out['clock_out'] == '18:00:00' + assert out['lunch_minutes'] == 60 + assert out['memo'] == '메모' + + def test_optional_clock_out(self): + row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '', + 'lunch_minutes': '0', 'memo': ''} + out = _normalize_row(row) + assert out['clock_out'] is None + + def test_invalid_date(self): + row = {'date': 'not-a-date', 'clock_in': '09:00', 'clock_out': '', + 'lunch_minutes': '0', 'memo': ''} + with pytest.raises(ValueError): + _normalize_row(row) + + def test_negative_lunch_minutes(self): + row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '', + 'lunch_minutes': '-30', 'memo': ''} + with pytest.raises(ValueError): + _normalize_row(row) + + +class TestParseCsv: + def _write(self, content: str) -> str: + f = tempfile.NamedTemporaryFile('w', encoding='utf-8', + delete=False, suffix='.csv', newline='') + f.write(content) + f.close() + return f.name + + def test_valid_csv(self): + path = self._write( + "date,clock_in,clock_out,lunch_minutes,memo\n" + "2026-04-01,09:00,18:00,60,첫째날\n" + "2026-04-02,09:30:00,17:30:00,30,단축\n" + ) + try: + rows = parse_csv(path) + assert len(rows) == 2 + assert rows[0]['lunch_minutes'] == 60 + assert rows[1]['memo'] == '단축' + finally: + os.remove(path) + + def test_utf8_bom(self): + # 엑셀 저장본 호환 + path = self._write('\ufeff' + + "date,clock_in,clock_out,lunch_minutes,memo\n" + "2026-04-01,09:00,18:00,60,첫째날\n" + ) + try: + rows = parse_csv(path) + assert len(rows) == 1 + finally: + os.remove(path) + + def test_missing_required_header(self): + path = self._write("date,memo\n2026-04-01,foo\n") + try: + with pytest.raises(ValueError) as exc: + parse_csv(path) + assert 'clock_in' in str(exc.value) + finally: + os.remove(path) + + def test_file_not_found(self): + with pytest.raises(FileNotFoundError): + parse_csv('/nonexistent/file.csv') + + def test_line_number_in_error(self): + path = self._write( + "date,clock_in,clock_out,lunch_minutes,memo\n" + "2026-04-01,09:00,18:00,60,ok\n" + "bad-date,09:00,18:00,60,broken\n" + ) + try: + with pytest.raises(ValueError) as exc: + parse_csv(path) + assert '줄 3' in str(exc.value) + finally: + os.remove(path) diff --git a/tests/test_discord_webhook.py b/tests/test_discord_webhook.py new file mode 100644 index 0000000..f79816a --- /dev/null +++ b/tests/test_discord_webhook.py @@ -0,0 +1,129 @@ +""" +utils.discord_webhook 단위 테스트. + +네트워크 호출은 mock — 실제 Discord API는 절대 건드리지 않음. +""" +import os +import sys +from unittest.mock import patch, MagicMock + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from utils.discord_webhook import ( + send, send_test, send_clock_in, send_clock_out, send_health_warning, + USER_AGENT, COLOR_GREEN, COLOR_BLUE, +) + + +class TestSendInputValidation: + def test_empty_url_returns_false(self): + assert send('', 'title', 'desc') is False + + def test_non_https_url_returns_false(self): + # 보안상 http:// 거부 + assert send('http://example.com/webhook', 'title', 'desc') is False + assert send('ftp://example.com', 'title', 'desc') is False + + def test_none_url_returns_false(self): + assert send(None, 'title', 'desc') is False + + +class TestSendNetwork: + @patch('utils.discord_webhook.urllib.request.urlopen') + def test_success(self, mock_urlopen): + resp = MagicMock() + resp.status = 204 + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + mock_urlopen.return_value = resp + + ok = send('https://discord.com/api/webhooks/123/abc', 'title', 'desc') + assert ok is True + + # User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회) + req = mock_urlopen.call_args[0][0] + assert req.headers.get('User-agent') == USER_AGENT + assert 'Mozilla' in USER_AGENT # 봇으로 인식 안 되도록 + + @patch('utils.discord_webhook.urllib.request.urlopen') + def test_4xx_returns_false(self, mock_urlopen): + resp = MagicMock() + resp.status = 403 + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + mock_urlopen.return_value = resp + + assert send('https://discord.com/api/webhooks/x', 't', 'd') is False + + @patch('utils.discord_webhook.urllib.request.urlopen') + def test_network_error_returns_false(self, mock_urlopen): + import urllib.error + mock_urlopen.side_effect = urllib.error.URLError('boom') + assert send('https://discord.com/api/webhooks/x', 't', 'd') is False + + @patch('utils.discord_webhook.urllib.request.urlopen') + def test_timeout_returns_false(self, mock_urlopen): + mock_urlopen.side_effect = TimeoutError('slow') + assert send('https://discord.com/api/webhooks/x', 't', 'd') is False + + +class TestPayloadShape: + @patch('utils.discord_webhook.urllib.request.urlopen') + def test_payload_contains_embed(self, mock_urlopen): + import json as _json + resp = MagicMock() + resp.status = 204 + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + mock_urlopen.return_value = resp + + send('https://discord.com/api/webhooks/x', 'TITLE', 'DESC', + color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}]) + req = mock_urlopen.call_args[0][0] + body = _json.loads(req.data.decode('utf-8')) + assert 'embeds' in body + assert body['embeds'][0]['title'] == 'TITLE' + assert body['embeds'][0]['description'] == 'DESC' + assert body['embeds'][0]['color'] == COLOR_GREEN + assert body['embeds'][0]['fields'] == [{"name": "f", "value": "v"}] + + +class TestHelpers: + @patch('utils.discord_webhook.send') + def test_send_test(self, mock_send): + mock_send.return_value = True + send_test('https://discord.com/x') + assert mock_send.called + kwargs = mock_send.call_args.kwargs + assert kwargs.get('color') == COLOR_GREEN + + @patch('utils.discord_webhook.send') + def test_send_clock_in(self, mock_send): + send_clock_in('https://discord.com/x', '09:00') + kwargs = mock_send.call_args.kwargs + assert '09:00' in kwargs.get('description', '') + + @patch('utils.discord_webhook.send') + def test_send_clock_out_no_overtime(self, mock_send): + send_clock_out('https://discord.com/x', '18:00', 8.0, 0, 0) + kwargs = mock_send.call_args.kwargs + # 연장 없음 → 파란색 + assert kwargs.get('color') == COLOR_BLUE + # 필드: 총 근무시간만 + assert len(kwargs.get('fields', [])) == 1 + + @patch('utils.discord_webhook.send') + def test_send_clock_out_with_overtime(self, mock_send): + send_clock_out('https://discord.com/x', '19:30', 9.5, 90, 90) + kwargs = mock_send.call_args.kwargs + # 연장 있음 → 노란색 + assert kwargs.get('color') != COLOR_BLUE + assert len(kwargs.get('fields', [])) == 2 + + @patch('utils.discord_webhook.send') + def test_send_health_warning(self, mock_send): + send_health_warning('https://discord.com/x', 4.5) + kwargs = mock_send.call_args.kwargs + assert '4.5' in kwargs.get('description', '') diff --git a/tests/test_i18n_runtime.py b/tests/test_i18n_runtime.py new file mode 100644 index 0000000..ccee855 --- /dev/null +++ b/tests/test_i18n_runtime.py @@ -0,0 +1,106 @@ +""" +ui.i18n_runtime 단위 테스트. + +QApplication이 필요해서 offscreen으로. +""" +import os +import sys + +import pytest + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +@pytest.fixture(scope='module') +def qapp(): + from PyQt5.QtWidgets import QApplication + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture +def i18n(): + from core import i18n + from ui import i18n_runtime + saved_lang = i18n.get_language() + yield i18n_runtime + i18n_runtime.clear() + i18n.set_language(saved_lang) + + +def test_register_applies_initial_text(qapp, i18n): + from PyQt5.QtWidgets import QLabel + from core.i18n import set_language + set_language('ko') + label = QLabel() + i18n.register(label, 'btn.save') + assert label.text() == '💾 저장' + + +def test_retranslate_after_language_change(qapp, i18n): + from PyQt5.QtWidgets import QLabel + from core.i18n import set_language + set_language('ko') + label = QLabel() + i18n.register(label, 'btn.close') + assert label.text() == '닫기' + + i18n.set_language_and_retranslate('en') + assert label.text() == 'Close' + + i18n.set_language_and_retranslate('ko') + assert label.text() == '닫기' + + +def test_setter_kwarg_for_window_title(qapp, i18n): + from PyQt5.QtWidgets import QDialog + from core.i18n import set_language + set_language('ko') + dlg = QDialog() + i18n.register(dlg, 'window.settings', setter='setWindowTitle') + assert dlg.windowTitle() == '⚙️ 설정' + + i18n.set_language_and_retranslate('en') + assert dlg.windowTitle() == '⚙️ Settings' + + +def test_post_callback_applied(qapp, i18n): + from PyQt5.QtWidgets import QLabel + from core.i18n import set_language + set_language('ko') + label = QLabel() + i18n.register(label, 'btn.save', post=lambda t: f"[{t}]") + assert label.text() == '[💾 저장]' + + i18n.set_language_and_retranslate('en') + assert label.text() == '[💾 Save]' + + +def test_dead_widget_pruned(qapp, i18n): + """삭제된 위젯은 retranslate에서 자동 제외 (RuntimeError 안 남).""" + from PyQt5.QtWidgets import QLabel + from core.i18n import set_language + set_language('ko') + + label = QLabel() + i18n.register(label, 'btn.cancel') + label.deleteLater() + label = None # weakref 끊기 + + # Qt 이벤트 처리 한 번 강제로 (deleteLater 처리) + qapp.processEvents() + + # 죽은 위젯이 있어도 예외 없이 실행돼야 함 + i18n.set_language_and_retranslate('en') + i18n.set_language_and_retranslate('ko') + + +def test_kwargs_format(qapp, i18n): + from PyQt5.QtWidgets import QLabel + from core.i18n import set_language + set_language('ko') + label = QLabel() + # 'tray.tooltip_remaining': '퇴근까지: {time}' + i18n.register(label, 'tray.tooltip_remaining', kwargs={'time': '01:23'}) + assert '01:23' in label.text() diff --git a/tests/test_salary.py b/tests/test_salary.py new file mode 100644 index 0000000..7524fd5 --- /dev/null +++ b/tests/test_salary.py @@ -0,0 +1,98 @@ +""" +core.salary 단위 테스트 — 포괄임금제 외 시급 추정. +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.salary import estimate_pay, format_won + + +class TestEstimatePay: + def test_zero_wage_returns_zero(self): + out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0) + assert out['base'] == 0 + assert out['overtime'] == 0 + assert out['total'] == 0 + + def test_negative_wage_returns_zero(self): + out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 0}], -1000) + assert out['total'] == 0 + + def test_empty_records(self): + out = estimate_pay([], 10000) + assert out['base'] == 0 + assert out['overtime'] == 0 + assert out['total'] == 0 + + def test_basic_8h_no_overtime(self): + # 8h 정규 × 10000 = 80000 + out = estimate_pay([{'total_hours': 8.0, 'overtime_minutes': 0}], 10000) + assert out['base'] == 80000 + assert out['overtime'] == 0 + assert out['total'] == 80000 + + def test_8h_with_30min_overtime(self): + # 정규 = 7.5h × 10000 = 75000 + # 연장 = 0.5h × 10000 × 1.5 = 7500 + out = estimate_pay( + [{'total_hours': 8.0, 'overtime_minutes': 30}], + hourly_wage=10000, + overtime_rate=1.5, + ) + assert out['base'] == pytest.approx(75000) + assert out['overtime'] == pytest.approx(7500) + assert out['total'] == pytest.approx(82500) + + def test_custom_overtime_rate(self): + # 연장 = 1h × 10000 × 2.0 = 20000 + out = estimate_pay( + [{'total_hours': 9.0, 'overtime_minutes': 60}], + hourly_wage=10000, + overtime_rate=2.0, + ) + assert out['overtime'] == pytest.approx(20000) + assert out['base'] == pytest.approx(80000) + + def test_aggregated_multiple_records(self): + records = [ + {'total_hours': 8.0, 'overtime_minutes': 0}, + {'total_hours': 9.0, 'overtime_minutes': 60}, + {'total_hours': 8.5, 'overtime_minutes': 30}, + ] + out = estimate_pay(records, hourly_wage=10000) + # base_hours = 8 + 8 + 8 = 24h + # overtime_hours = 0 + 1 + 0.5 = 1.5h + assert out['base_hours'] == pytest.approx(24.0) + assert out['overtime_hours'] == pytest.approx(1.5) + assert out['base'] == pytest.approx(240000) + assert out['overtime'] == pytest.approx(22500) # 1.5 * 10000 * 1.5 + + def test_missing_keys_default_zero(self): + out = estimate_pay([{}], 10000) + assert out['total'] == 0 + + def test_overtime_minutes_zero_when_negative_total(self): + # total - overtime이 음수가 되면 base는 0으로 클램프 + out = estimate_pay( + [{'total_hours': 0.3, 'overtime_minutes': 60}], # 0.3h - 1h = -0.7 + hourly_wage=10000, + ) + assert out['base'] == 0 + assert out['overtime'] == pytest.approx(15000) + + +class TestFormatWon: + @pytest.mark.parametrize("amount,expected", [ + (0, '0원'), + (1000, '1,000원'), + (1234567, '1,234,567원'), + (999, '999원'), + (82500.4, '82,500원'), # round + (82500.6, '82,501원'), + ]) + def test_format(self, amount, expected): + assert format_won(amount) == expected diff --git a/ui/break_view.py b/ui/break_view.py index 7ef00e5..c0071c5 100644 --- a/ui/break_view.py +++ b/ui/break_view.py @@ -21,7 +21,7 @@ class BreakEditDialog(QDialog): def init_ui(self): """UI 초기화""" - self.setWindowTitle("외출 기록 수정") + self.setWindowTitle(tr('dlg.break.edit_title')) self.setFixedSize(380, 180) layout = QVBoxLayout() @@ -30,7 +30,7 @@ class BreakEditDialog(QDialog): # 외출 시간 out_layout = QHBoxLayout() - out_label = QLabel("외출 시간:") + out_label = QLabel(tr('dlg.break.out_label')) out_label.setFixedWidth(80) self.out_time_edit = QTimeEdit() self.out_time_edit.setDisplayFormat("HH:mm:ss") @@ -40,7 +40,7 @@ class BreakEditDialog(QDialog): # 복귀 시간 in_layout = QHBoxLayout() - in_label = QLabel("복귀 시간:") + in_label = QLabel(tr('dlg.break.in_label')) in_label.setFixedWidth(80) self.in_time_edit = QTimeEdit() self.in_time_edit.setDisplayFormat("HH:mm:ss") @@ -50,7 +50,7 @@ class BreakEditDialog(QDialog): # 사유 reason_layout = QHBoxLayout() - reason_label = QLabel("사유:") + reason_label = QLabel(tr('dlg.break.reason_label')) reason_label.setFixedWidth(80) self.reason_edit = QLineEdit() reason_layout.addWidget(reason_label) @@ -74,8 +74,8 @@ class BreakEditDialog(QDialog): # 버튼 button_layout = QHBoxLayout() - save_button = QPushButton("저장") - cancel_button = QPushButton("취소") + save_button = QPushButton(tr('btn.save')) + cancel_button = QPushButton(tr('btn.cancel')) save_button.clicked.connect(self.accept) cancel_button.clicked.connect(self.reject) @@ -128,7 +128,7 @@ class BreakView(QDialog): layout.setContentsMargins(12, 10, 12, 10) # 제목 - title = QLabel("오늘의 외출 기록") + title = QLabel(tr('view.break.today_title')) title.setObjectName("dialog_subtitle") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) @@ -136,7 +136,13 @@ class BreakView(QDialog): # 외출 리스트 테이블 self.table = QTableWidget() self.table.setColumnCount(5) - self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""]) + self.table.setHorizontalHeaderLabels([ + tr('view.break.col_out'), + tr('view.break.col_in'), + tr('view.break.col_duration'), + tr('view.break.col_reason'), + "", + ]) # 테이블 설정 header = self.table.horizontalHeader() @@ -152,7 +158,7 @@ class BreakView(QDialog): layout.addWidget(self.table) # 총 외출 시간 표시 - self.total_label = QLabel("총 외출 시간: 0분") + self.total_label = QLabel(tr('view.break.total_zero')) self.total_label.setObjectName("section_title") self.total_label.setAlignment(Qt.AlignRight) layout.addWidget(self.total_label) @@ -160,8 +166,8 @@ class BreakView(QDialog): # 버튼 button_layout = QHBoxLayout() - self.refresh_button = QPushButton("새로고침") - close_button = QPushButton("닫기") + self.refresh_button = QPushButton(tr('btn.refresh')) + close_button = QPushButton(tr('btn.close')) self.refresh_button.clicked.connect(self.load_break_records) close_button.clicked.connect(self.accept) @@ -190,7 +196,7 @@ class BreakView(QDialog): if break_in: self.table.setItem(i, 1, QTableWidgetItem(break_in)) else: - item = QTableWidgetItem("진행중") + item = QTableWidgetItem(tr('view.break.in_progress')) item.setForeground(Qt.red) self.table.setItem(i, 1, item) @@ -199,7 +205,10 @@ class BreakView(QDialog): if total_minutes: hours = total_minutes // 60 minutes = total_minutes % 60 - duration_str = f"{hours}시간 {minutes}분" if hours > 0 else f"{minutes}분" + if hours > 0: + duration_str = tr('view.break.duration_fmt', h=hours, m=minutes) + else: + duration_str = tr('view.break.duration_min_only', m=minutes) self.table.setItem(i, 2, QTableWidgetItem(duration_str)) else: self.table.setItem(i, 2, QTableWidgetItem("-")) @@ -214,8 +223,8 @@ class BreakView(QDialog): action_layout.setContentsMargins(0, 0, 0, 0) action_layout.setSpacing(5) - edit_button = QPushButton("수정") - delete_button = QPushButton("삭제") + edit_button = QPushButton(tr('btn.edit_short')) + delete_button = QPushButton(tr('btn.delete_short')) edit_button.setFixedSize(50, 25) delete_button.setFixedSize(50, 25) @@ -237,9 +246,9 @@ class BreakView(QDialog): minutes = total_minutes % 60 if hours > 0: - self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분") + self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes)) else: - self.total_label.setText(f"총 외출 시간: {minutes}분") + self.total_label.setText(tr('view.break.total_min_only', m=minutes)) def edit_record(self, record_id): """외출 기록 수정""" @@ -277,8 +286,8 @@ class BreakView(QDialog): """외출 기록 삭제""" reply = QMessageBox.question( self, - "삭제 확인", - "이 외출 기록을 삭제하시겠습니까?", + tr('msg.confirm_delete.title'), + tr('view.break.delete_confirm'), QMessageBox.Yes | QMessageBox.No ) diff --git a/ui/clock_in_dialog.py b/ui/clock_in_dialog.py index e443416..76e5d4a 100644 --- a/ui/clock_in_dialog.py +++ b/ui/clock_in_dialog.py @@ -29,14 +29,14 @@ class ClockInDialog(QDialog): layout.setContentsMargins(12, 10, 12, 10) # 안내 문구 - info_label = QLabel("오늘의 출근시간을 입력해주세요") + info_label = QLabel(tr('dlg.clock_in.prompt')) info_label.setObjectName("field_label") info_label.setAlignment(Qt.AlignCenter) layout.addWidget(info_label) # 시간 입력 time_layout = QHBoxLayout() - time_label = QLabel("출근시간:") + time_label = QLabel(tr('dlg.clock_in.label')) time_label.setObjectName("field_label") self.time_edit = QTimeEdit() @@ -59,13 +59,13 @@ class ClockInDialog(QDialog): # 빠른 선택 버튼 quick_layout = QHBoxLayout() - quick_label = QLabel("빠른 선택:") + quick_label = QLabel(tr('dlg.clock_in.quick')) quick_label.setObjectName("field_label") btn_8am = QPushButton("08:00") btn_9am = QPushButton("09:00") btn_10am = QPushButton("10:00") - btn_now = QPushButton("현재") + btn_now = QPushButton(tr('dlg.clock_in.btn_now')) for btn in [btn_8am, btn_9am, btn_10am, btn_now]: btn.setMinimumHeight(30) @@ -87,12 +87,12 @@ class ClockInDialog(QDialog): # 버튼 button_layout = QHBoxLayout() - ok_button = QPushButton("확인") + ok_button = QPushButton(tr('btn.confirm')) ok_button.setObjectName("btn_primary") ok_button.setMinimumHeight(40) ok_button.clicked.connect(self.accept) - cancel_button = QPushButton("취소") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.setMinimumHeight(40) cancel_button.clicked.connect(self.reject) diff --git a/ui/controllers/meal_controller.py b/ui/controllers/meal_controller.py new file mode 100644 index 0000000..d3f041c --- /dev/null +++ b/ui/controllers/meal_controller.py @@ -0,0 +1,56 @@ +""" +점심/저녁 토글 컨트롤러. + +main_window.py에서 toggle_lunch_break / toggle_dinner_break / update_lunch_status / +update_dinner_status 가 합쳐져 있던 것을 분리. 1Hz hot path 외 사용자 액션 응답. + +단위 테스트가 가능하도록 window 의존성을 명시적으로 받음. +""" +from __future__ import annotations +from datetime import datetime + +from core.i18n import tr + + +class MealController: + """점심/저녁 토글 + 상태 라벨 갱신.""" + + def __init__(self, window): + self.window = window + self.db = window.db + + # -------- 토글 -------- + def toggle_lunch(self) -> None: + w = self.window + w.lunch_break_enabled = w.lunch_button.isChecked() + self.refresh_lunch_label() + + # 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지) + if w.lunch_break_enabled: + w.auto_lunch_applied_today = True + + if w.is_clocked_in: + today = datetime.now().date().isoformat() + self.db.update_lunch_break(today, w.lunch_break_enabled) + + def toggle_dinner(self) -> None: + w = self.window + w.dinner_break_enabled = w.dinner_button.isChecked() + self.refresh_dinner_label() + + if w.is_clocked_in: + today = datetime.now().date().isoformat() + self.db.update_dinner_break(today, w.dinner_break_enabled) + + # -------- 라벨 -------- + def refresh_lunch_label(self) -> None: + w = self.window + w.lunch_button.setText( + tr('btn.lunch_applied') if w.lunch_break_enabled else tr('btn.lunch_add') + ) + + def refresh_dinner_label(self) -> None: + w = self.window + w.dinner_button.setText( + tr('btn.dinner_applied') if w.dinner_break_enabled else tr('btn.dinner_add') + ) diff --git a/ui/i18n_runtime.py b/ui/i18n_runtime.py new file mode 100644 index 0000000..5d9e4fb --- /dev/null +++ b/ui/i18n_runtime.py @@ -0,0 +1,94 @@ +""" +런타임 i18n 재번역 — 재시작 없이 언어 전환. + +사용법: + from ui.i18n_runtime import register, retranslate_all, set_language_and_retranslate + + label = QLabel(tr('label.foo')) + register(label, 'label.foo') # 약한 참조로 등록 + + button = QPushButton() + register(button, 'btn.bar', kwargs={'name': 'X'}) + + # 그룹박스 제목 등 setter가 다른 경우 + register(group, 'group.work_time', setter='setTitle') + + # 윈도우 제목 + register(dialog, 'window.foo', setter='setWindowTitle') + +언어 변경: + set_language_and_retranslate('en') + +각 widget은 weakref로 보관되므로 삭제되면 자동 정리. format placeholder가 있는 +키는 kwargs를 함께 등록하면 retranslate 시 같은 인자로 재계산. +""" +from __future__ import annotations +import weakref +from typing import Any, Callable, List, Tuple, Optional + +from core.i18n import tr, set_language as _set_language + +# (weakref, key, setter_name, kwargs, post_format) 튜플 리스트 +# weakref가 죽으면 다음 retranslate 시 정리. +_registry: List[Tuple[weakref.ReferenceType, str, str, dict, Optional[Callable]]] = [] + + +def register(widget: Any, key: str, *, setter: str = 'setText', + kwargs: Optional[dict] = None, + post: Optional[Callable[[str], str]] = None) -> None: + """위젯을 retranslate 대상으로 등록. + + Args: + widget: PyQt 위젯 (setText/setTitle/setWindowTitle 등 지원) + key: i18n 키 + setter: 호출할 메서드명 (기본 setText) + kwargs: tr()에 전달할 format 인자 (정적인 경우만) + post: 번역 후 한 번 더 가공할 콜백 — 예: 이모지 prefix + """ + # 약한 참조 — 위젯 삭제 시 자동 GC + try: + ref = weakref.ref(widget) + except TypeError: + # weakref 미지원 객체는 retranslate 불가 + return + _registry.append((ref, key, setter, kwargs or {}, post)) + # 초기 적용 + _apply(widget, key, setter, kwargs or {}, post) + + +def retranslate_all() -> None: + """모든 등록된 위젯에 현재 언어로 텍스트 재적용.""" + global _registry + alive = [] + for ref, key, setter, kw, post in _registry: + widget = ref() + if widget is None: + continue # 죽은 위젯은 빼버림 + try: + _apply(widget, key, setter, kw, post) + alive.append((ref, key, setter, kw, post)) + except RuntimeError: + # Qt C++ 객체 삭제 후 호출 — 정리만 하고 패스 + continue + _registry = alive + + +def set_language_and_retranslate(lang: str) -> None: + """언어 전환 + 즉시 재번역.""" + _set_language(lang) + retranslate_all() + + +def clear() -> None: + """레지스트리 비우기 (테스트용).""" + _registry.clear() + + +def _apply(widget: Any, key: str, setter: str, kw: dict, + post: Optional[Callable[[str], str]]) -> None: + text = tr(key, **kw) + if post: + text = post(text) + fn = getattr(widget, setter, None) + if callable(fn): + fn(text) diff --git a/ui/leave_view.py b/ui/leave_view.py index 1fc6916..cbaaf9b 100644 --- a/ui/leave_view.py +++ b/ui/leave_view.py @@ -40,27 +40,32 @@ class LeaveView(QDialog): # 제목 + 잔액 + 설정 한 줄 header_layout = QHBoxLayout() - title = QLabel("연차 관리") + title = QLabel(tr('view.leave.title')) title.setObjectName("dialog_title") header_layout.addWidget(title) header_layout.addStretch() - self.balance_label = QLabel("잔여: 0일") + self.balance_label = QLabel(tr('view.leave.balance_zero')) self.balance_label.setObjectName("badge_leave") header_layout.addWidget(self.balance_label) - set_balance_button = QPushButton("잔여 설정") + set_balance_button = QPushButton(tr('view.leave.btn_set_balance')) set_balance_button.clicked.connect(self.set_balance) header_layout.addWidget(set_balance_button) layout.addLayout(header_layout) # 사용 내역 - used_group = QGroupBox("📤 사용 내역") + used_group = QGroupBox(tr('view.leave.used_group')) used_layout = QVBoxLayout() used_layout.setSpacing(4) used_layout.setContentsMargins(8, 20, 8, 6) self.used_table = QTableWidget() self.used_table.setColumnCount(4) - self.used_table.setHorizontalHeaderLabels(["날짜", "구분", "사용", "사유"]) + self.used_table.setHorizontalHeaderLabels([ + tr('view.leave.col_date'), + tr('view.leave.col_type'), + tr('view.leave.col_used'), + tr('view.leave.col_reason'), + ]) self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) @@ -77,15 +82,15 @@ class LeaveView(QDialog): # 버튼들 button_layout = QHBoxLayout() - add_leave_button = QPushButton("➕ 연차 사용 추가") + add_leave_button = QPushButton(tr('view.leave.btn_add')) add_leave_button.clicked.connect(self.add_leave_record) button_layout.addWidget(add_leave_button) - cal_button = QPushButton("📅 캘린더 보기") + cal_button = QPushButton(tr('view.leave.btn_calendar')) cal_button.clicked.connect(self._show_calendar) button_layout.addWidget(cal_button) - close_button = QPushButton("닫기") + close_button = QPushButton(tr('btn.close')) close_button.clicked.connect(self.close) button_layout.addWidget(close_button) layout.addLayout(button_layout) @@ -102,7 +107,8 @@ class LeaveView(QDialog): # 잔액 업데이트 balance = self.db.get_leave_balance() hours = balance * 8 - self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)") + self.balance_label.setText(tr('view.leave.balance_fmt', + days=balance, hours=hours)) # 사용 내역 로드 (잔액 조정 제외) records = self.db.get_leave_records(exclude_bulk=True) @@ -144,7 +150,7 @@ class LeaveView(QDialog): return menu = QMenu(self) - delete_action = QAction("삭제", self) + delete_action = QAction(tr('btn.delete_short'), self) delete_action.triggered.connect(self.delete_leave_record) menu.addAction(delete_action) menu.exec_(self.used_table.viewport().mapToGlobal(position)) @@ -164,11 +170,10 @@ class LeaveView(QDialog): reply = QMessageBox.question( self, - "삭제 확인", - f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n" - f"날짜: {date_item.text()}\n" - f"구분: {type_item.text()}\n" - f"사용: {days_item.text()}", + tr('msg.confirm_delete.title'), + tr('view.leave.delete_confirm_body', + date=date_item.text(), type=type_item.text(), + days=days_item.text()), QMessageBox.Yes | QMessageBox.No ) @@ -183,13 +188,12 @@ class LeaveView(QDialog): hours, ok = QInputDialog.getDouble( self, - "연차 시간 설정", - "연차 잔여 시간을 입력하세요 (0.5시간 단위):\n" - "예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분", + tr('view.leave.set_title'), + tr('view.leave.set_prompt'), current_hours, 0.0, 999.0, - 1 # 소수점 첫째자리까지 (0.5 단위) + 1 ) if ok: @@ -200,8 +204,8 @@ class LeaveView(QDialog): self.db.set_leave_balance(days) QMessageBox.information( self, - "설정 완료", - f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다." + tr('view.leave.set_done_title'), + tr('view.leave.set_done_body', days=days, hours=hours) ) self.load_data() @@ -223,7 +227,7 @@ class AddLeaveDialog(QDialog): def init_ui(self): """UI 초기화""" - self.setWindowTitle("연차 사용 기록 추가") + self.setWindowTitle(tr('view.leave.add_title')) self.setModal(True) self.setMinimumWidth(360) @@ -232,14 +236,14 @@ class AddLeaveDialog(QDialog): layout.setContentsMargins(12, 10, 12, 10) # 제목 - title = QLabel("연차 사용 기록 추가") + title = QLabel(tr('view.leave.add_title')) title.setObjectName("dialog_subtitle") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) # 날짜 + 구분 한 줄 row1 = QHBoxLayout() - date_label = QLabel("날짜:") + date_label = QLabel(tr('view.leave.field_date')) date_label.setObjectName("field_label") date_label.setFixedWidth(40) self.date_edit = QDateEdit() @@ -248,14 +252,14 @@ class AddLeaveDialog(QDialog): row1.addWidget(date_label) row1.addWidget(self.date_edit) row1.addSpacing(8) - type_label = QLabel("구분:") + type_label = QLabel(tr('view.leave.field_type')) type_label.setObjectName("field_label") type_label.setFixedWidth(40) self.type_combo = QComboBox() - self.type_combo.addItem("연차", "annual") - self.type_combo.addItem("반차", "half") - self.type_combo.addItem("반반차", "quarter") - self.type_combo.addItem("시간", "hourly") + self.type_combo.addItem(tr('view.leave.type_annual'), "annual") + self.type_combo.addItem(tr('view.leave.type_half'), "half") + self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter") + self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly") self.type_combo.currentIndexChanged.connect(self.on_type_changed) row1.addWidget(type_label) row1.addWidget(self.type_combo) @@ -263,14 +267,14 @@ class AddLeaveDialog(QDialog): # 사용 시간 (시간 연차용) hours_layout = QHBoxLayout() - hours_label = QLabel("시간:") + hours_label = QLabel(tr('view.leave.field_hours')) hours_label.setObjectName("field_label") hours_label.setFixedWidth(40) self.hours_spin = QDoubleSpinBox() self.hours_spin.setRange(0.5, 8.0) self.hours_spin.setSingleStep(0.5) self.hours_spin.setValue(1.0) - self.hours_spin.setSuffix(" 시간") + self.hours_spin.setSuffix(' ' + tr('label.unit_hour')) self.hours_spin.setEnabled(False) hours_layout.addWidget(hours_label) hours_layout.addWidget(self.hours_spin) @@ -278,27 +282,27 @@ class AddLeaveDialog(QDialog): # 사유 memo_layout = QHBoxLayout() - memo_label = QLabel("사유:") + memo_label = QLabel(tr('view.leave.field_reason')) memo_label.setObjectName("field_label") memo_label.setFixedWidth(40) self.memo_input = QLineEdit() - self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등") + self.memo_input.setPlaceholderText(tr('view.leave.placeholder_reason')) memo_layout.addWidget(memo_label) memo_layout.addWidget(self.memo_input) layout.addLayout(memo_layout) # 안내 - info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.") + info_label = QLabel(tr('view.leave.note_auto_deduct')) info_label.setObjectName("note_text") layout.addWidget(info_label) # 버튼 button_layout = QHBoxLayout() - save_button = QPushButton("저장") + save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.clicked.connect(self.save_record) button_layout.addWidget(save_button) - cancel_button = QPushButton("취소") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.clicked.connect(self.reject) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) @@ -336,8 +340,8 @@ class AddLeaveDialog(QDialog): if current_balance < days: QMessageBox.warning( self, - "잔여 연차 부족", - f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일" + tr('view.leave.short_title'), + tr('view.leave.short_body', balance=current_balance, req=days) ) return @@ -345,12 +349,10 @@ class AddLeaveDialog(QDialog): hours = days * 8 reply = QMessageBox.question( self, - "연차 사용 기록 추가", - f"날짜: {date}\n" - f"구분: {leave_type_name}\n" - f"사용: {days}일 ({hours}시간)\n" - f"사유: {memo if memo else '(없음)'}\n\n" - f"이 기록을 추가하시겠습니까?", + tr('view.leave.confirm_title'), + tr('view.leave.confirm_body', + date=date, type=leave_type_name, days=days, hours=hours, + reason=(memo if memo else '-')), QMessageBox.Yes | QMessageBox.No ) @@ -360,15 +362,15 @@ class AddLeaveDialog(QDialog): self.db.use_leave(days, date, leave_type_name, memo) QMessageBox.information( self, - "추가 완료", - f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다." + tr('view.leave.added_title'), + tr('view.leave.added_body', days=days, hours=hours) ) self.accept() except Exception as e: QMessageBox.critical( self, - "오류", - f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}" + tr('view.leave.error_title'), + tr('view.leave.error_body', err=str(e)) ) diff --git a/ui/main_window.py b/ui/main_window.py index f7fd7dc..8ed8f8d 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -81,13 +81,15 @@ class MainWindow(QMainWindow): self.notifier = Notifier(self, db=self.db) self.notifier.notification_signal.connect(self.show_notification) - # 책임 분리된 컨트롤러들 (1Hz hot path) + # 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션) from ui.controllers.lock_monitor import LockMonitor from ui.controllers.auto_lunch import AutoLunchManager from ui.controllers.notification_orchestrator import NotificationOrchestrator + from ui.controllers.meal_controller import MealController self._lock_monitor = LockMonitor(self) self._auto_lunch = AutoLunchManager(self) self._notif_orch = NotificationOrchestrator(self) + self._meal = MealController(self) # 시스템 트레이 self.tray_icon = SystemTrayIcon(self) @@ -184,7 +186,11 @@ class MainWindow(QMainWindow): def init_ui(self): """UI 초기화""" from core.version import __version__ + from ui.i18n_runtime import register + self._app_version = __version__ self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}") + register(self, 'window.main_title', setter='setWindowTitle', + post=lambda t: f"⏰ {t} v{__version__}") self.setGeometry(100, 100, 500, 620) self.setMinimumSize(480, 520) @@ -326,6 +332,14 @@ class MainWindow(QMainWindow): help_button = QPushButton(tr('menu.help')) settings_button = QPushButton(tr('menu.settings')) + # 런타임 i18n 등록 + for btn, key in [(stats_button, 'menu.stats'), + (calendar_button, 'menu.calendar'), + (report_button, 'menu.daily_report'), + (help_button, 'menu.help'), + (settings_button, 'menu.settings')]: + register(btn, key) + for btn in [stats_button, calendar_button, report_button, help_button, settings_button]: bottom_layout.addWidget(btn) @@ -750,40 +764,20 @@ class MainWindow(QMainWindow): self.date_label.setText(date_str) def toggle_lunch_break(self): - """점심시간 토글""" - self.lunch_break_enabled = self.lunch_button.isChecked() - self.update_lunch_status() - - # 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지) - if self.lunch_break_enabled: - self.auto_lunch_applied_today = True - - # DB 업데이트 - if self.is_clocked_in: - today = datetime.now().date().isoformat() - self.db.update_lunch_break(today, self.lunch_break_enabled) + """점심시간 토글 — MealController 위임.""" + self._meal.toggle_lunch() def toggle_dinner_break(self): - """저녁시간 토글""" - self.dinner_break_enabled = self.dinner_button.isChecked() - self.update_dinner_status() - - # DB 업데이트 - if self.is_clocked_in: - today = datetime.now().date().isoformat() - self.db.update_dinner_break(today, self.dinner_break_enabled) + """저녁시간 토글 — MealController 위임.""" + self._meal.toggle_dinner() def update_lunch_status(self): - """점심시간 상태 업데이트""" - self.lunch_button.setText( - tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add') - ) + """점심시간 상태 업데이트 — MealController 위임.""" + self._meal.refresh_lunch_label() def update_dinner_status(self): - """저녁시간 상태 업데이트""" - self.dinner_button.setText( - tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add') - ) + """저녁시간 상태 업데이트 — MealController 위임.""" + self._meal.refresh_dinner_label() def update_overtime_balance(self): """연장근무 잔액 업데이트""" diff --git a/ui/overtime_view.py b/ui/overtime_view.py index 51b8b86..aea2bb4 100644 --- a/ui/overtime_view.py +++ b/ui/overtime_view.py @@ -38,24 +38,28 @@ class OvertimeView(QDialog): # 제목 + 잔액 한 줄 header_layout = QHBoxLayout() - title = QLabel("연장근무 내역") + title = QLabel(tr('view.overtime.title')) title.setObjectName("dialog_title") header_layout.addWidget(title) header_layout.addStretch() - self.balance_label = QLabel("잔액: 0분") + self.balance_label = QLabel(tr('view.overtime.balance_zero')) self.balance_label.setObjectName("badge_balance") header_layout.addWidget(self.balance_label) layout.addLayout(header_layout) # 적립 내역 - earned_group = QGroupBox("💰 적립 내역") + earned_group = QGroupBox(tr('view.overtime.earned_group')) earned_layout = QVBoxLayout() earned_layout.setSpacing(4) earned_layout.setContentsMargins(8, 20, 8, 6) self.earned_table = QTableWidget() self.earned_table.setColumnCount(3) - self.earned_table.setHorizontalHeaderLabels(["날짜", "적립", "메모"]) + self.earned_table.setHorizontalHeaderLabels([ + tr('view.overtime.col_date'), + tr('view.overtime.col_earned'), + tr('view.overtime.col_memo'), + ]) self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) @@ -64,7 +68,7 @@ class OvertimeView(QDialog): self.earned_table.setSelectionBehavior(QTableWidget.SelectRows) earned_layout.addWidget(self.earned_table) - add_earned_button = QPushButton("➕ 수동 적립") + add_earned_button = QPushButton(tr('view.overtime.btn_add_earned')) add_earned_button.clicked.connect(self.add_earned_record) earned_layout.addWidget(add_earned_button) @@ -72,14 +76,18 @@ class OvertimeView(QDialog): layout.addWidget(earned_group) # 사용 내역 - used_group = QGroupBox("📤 사용 내역") + used_group = QGroupBox(tr('view.overtime.used_group')) used_layout = QVBoxLayout() used_layout.setSpacing(4) used_layout.setContentsMargins(8, 20, 8, 6) self.used_table = QTableWidget() self.used_table.setColumnCount(3) - self.used_table.setHorizontalHeaderLabels(["날짜", "사용", "사유"]) + self.used_table.setHorizontalHeaderLabels([ + tr('view.overtime.col_date'), + tr('view.overtime.col_used'), + tr('view.overtime.col_reason'), + ]) self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) @@ -90,7 +98,7 @@ class OvertimeView(QDialog): self.used_table.customContextMenuRequested.connect(self.show_used_context_menu) used_layout.addWidget(self.used_table) - add_used_button = QPushButton("➕ 수동 사용") + add_used_button = QPushButton(tr('view.overtime.btn_add_used')) add_used_button.clicked.connect(self.add_used_record) used_layout.addWidget(add_used_button) @@ -98,7 +106,7 @@ class OvertimeView(QDialog): layout.addWidget(used_group) # 닫기 버튼 - close_button = QPushButton("닫기") + close_button = QPushButton(tr('btn.close')) close_button.clicked.connect(self.close) layout.addWidget(close_button) @@ -110,23 +118,21 @@ class OvertimeView(QDialog): balance = self.db.get_total_overtime_balance() hours = balance // 60 minutes = balance % 60 - self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)") + self.balance_label.setText(tr('view.overtime.balance_fmt', + h=hours, m=minutes, total=balance)) # 적립 내역 로드 conn = self.db.get_connection() cursor = conn.cursor() cursor.execute(''' - SELECT ob.date, ob.earned_minutes, - CASE - WHEN ob.work_record_id IS NULL THEN '수동 추가' - ELSE COALESCE(wr.memo, '') - END as memo + SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo FROM overtime_bank ob LEFT JOIN work_records wr ON ob.work_record_id = wr.id ORDER BY ob.date DESC ''') earned_records = cursor.fetchall() + manual_label = tr('msg.manual_added') self.earned_table.setRowCount(len(earned_records)) for i, record in enumerate(earned_records): @@ -136,12 +142,17 @@ class OvertimeView(QDialog): minutes = record[1] hours = minutes // 60 mins = minutes % 60 - time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분" + if hours > 0: + time_str = tr('view.break.duration_fmt', h=hours, m=mins) + else: + time_str = tr('view.break.duration_min_only', m=mins) time_item = QTableWidgetItem(time_str) time_item.setTextAlignment(Qt.AlignCenter) time_item.setForeground(QColor(39, 174, 96)) # 초록색 - memo_item = QTableWidgetItem(record[2] or "") + # work_record_id NULL이면 "수동 추가", 아니면 wr.memo + memo_text = manual_label if record[2] is None else (record[3] or "") + memo_item = QTableWidgetItem(memo_text) self.earned_table.setItem(i, 0, date_item) self.earned_table.setItem(i, 1, time_item) @@ -166,7 +177,10 @@ class OvertimeView(QDialog): minutes = record[2] hours = minutes // 60 mins = minutes % 60 - time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분" + if hours > 0: + time_str = tr('view.break.duration_fmt', h=hours, m=mins) + else: + time_str = tr('view.break.duration_min_only', m=mins) time_item = QTableWidgetItem(time_str) time_item.setTextAlignment(Qt.AlignCenter) time_item.setForeground(QColor(231, 76, 60)) # 빨간색 @@ -188,7 +202,7 @@ class OvertimeView(QDialog): # 컨텍스트 메뉴 생성 menu = QMenu(self) - delete_action = QAction("❌ 삭제", self) + delete_action = QAction(tr('view.overtime.menu_delete'), self) delete_action.triggered.connect(self.delete_used_record) menu.addAction(delete_action) @@ -213,11 +227,10 @@ class OvertimeView(QDialog): # 확인 메시지 reply = QMessageBox.question( self, - "삭제 확인", - f"다음 사용 기록을 삭제하시겠습니까?\n\n" - f"날짜: {date_item.text()}\n" - f"시간: {time_item.text()}\n" - f"사유: {reason_item.text()}", + tr('msg.confirm_delete.title'), + tr('view.overtime.delete_confirm_body', + date=date_item.text(), time=time_item.text(), + reason=reason_item.text()), QMessageBox.Yes | QMessageBox.No ) @@ -266,7 +279,7 @@ class AddOvertimeEarnedDialog(QDialog): def init_ui(self): """UI 초기화""" - self.setWindowTitle("추가근무 수동 적립") + self.setWindowTitle(tr('view.overtime.manual_earned_title')) self.setModal(True) self.setMinimumWidth(360) @@ -275,14 +288,14 @@ class AddOvertimeEarnedDialog(QDialog): layout.setContentsMargins(12, 10, 12, 10) # 제목 - title = QLabel("추가근무 수동 적립") + title = QLabel(tr('view.overtime.manual_earned_title')) title.setObjectName("dialog_subtitle") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) # 날짜 date_layout = QHBoxLayout() - date_label = QLabel("날짜:") + date_label = QLabel(tr('view.overtime.field_date')) date_label.setObjectName("field_label") date_label.setFixedWidth(60) self.date_edit = QDateEdit() @@ -294,14 +307,15 @@ class AddOvertimeEarnedDialog(QDialog): # 시간 (30분 단위) time_layout = QHBoxLayout() - time_label = QLabel("시간:") + time_label = QLabel(tr('view.overtime.field_time')) time_label.setObjectName("field_label") time_label.setFixedWidth(60) self.hour_spin = QSpinBox() self.hour_spin.setRange(0, 23) - self.hour_spin.setSuffix("시간") + self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix')) self.minute_combo = QComboBox() - self.minute_combo.addItems(["0분", "30분"]) + self.minute_combo.addItems([tr('view.overtime.minute_0'), + tr('view.overtime.minute_30')]) time_layout.addWidget(time_label) time_layout.addWidget(self.hour_spin) time_layout.addWidget(self.minute_combo) @@ -309,21 +323,21 @@ class AddOvertimeEarnedDialog(QDialog): # 메모 memo_layout = QHBoxLayout() - memo_label = QLabel("메모:") + memo_label = QLabel(tr('view.overtime.field_memo')) memo_label.setObjectName("field_label") memo_label.setFixedWidth(60) self.memo_edit = QLineEdit() - self.memo_edit.setPlaceholderText("선택사항") + self.memo_edit.setPlaceholderText(tr('view.overtime.placeholder_memo')) memo_layout.addWidget(memo_label) memo_layout.addWidget(self.memo_edit) layout.addLayout(memo_layout) # 버튼 button_layout = QHBoxLayout() - save_button = QPushButton("저장") + save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.clicked.connect(self.save) - cancel_button = QPushButton("취소") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.clicked.connect(self.reject) button_layout.addWidget(save_button) button_layout.addWidget(cancel_button) @@ -335,11 +349,12 @@ class AddOvertimeEarnedDialog(QDialog): """저장""" # 시간 계산 (30분 단위) hours = self.hour_spin.value() - minutes = 0 if self.minute_combo.currentText() == "0분" else 30 + minutes = 0 if self.minute_combo.currentIndex() == 0 else 30 total_minutes = hours * 60 + minutes if total_minutes == 0: - QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.") + QMessageBox.warning(self, tr('msg.input_error.title'), + tr('view.overtime.zero_add_error')) return date = self.date_edit.date().toString("yyyy-MM-dd") @@ -371,8 +386,8 @@ class AddOvertimeEarnedDialog(QDialog): QMessageBox.information( self, - "저장 완료", - f"{hours}시간 {minutes}분이 적립되었습니다." + tr('msg.save_success.title'), + tr('view.overtime.saved_earned', h=hours, m=minutes) ) self.accept() @@ -388,7 +403,7 @@ class AddOvertimeUsedDialog(QDialog): def init_ui(self): """UI 초기화""" - self.setWindowTitle("추가근무 수동 사용") + self.setWindowTitle(tr('view.overtime.manual_used_title')) self.setModal(True) self.setMinimumWidth(360) @@ -398,21 +413,25 @@ class AddOvertimeUsedDialog(QDialog): # 제목 + 잔액 한 줄 header_layout = QHBoxLayout() - title = QLabel("추가근무 수동 사용") + title = QLabel(tr('view.overtime.manual_used_title')) title.setObjectName("dialog_subtitle") header_layout.addWidget(title) header_layout.addStretch() balance = self.db.get_total_overtime_balance() hours = balance // 60 minutes = balance % 60 - balance_label = QLabel(f"잔액: {hours}시간 {minutes}분") + if hours > 0: + balance_text = tr('view.break.duration_fmt', h=hours, m=minutes) + else: + balance_text = tr('view.break.duration_min_only', m=minutes) + balance_label = QLabel(f"{tr('view.overtime.balance_zero').split(':')[0]}: {balance_text}") balance_label.setObjectName("badge_balance") header_layout.addWidget(balance_label) layout.addLayout(header_layout) # 날짜 date_layout = QHBoxLayout() - date_label = QLabel("날짜:") + date_label = QLabel(tr('view.overtime.field_date')) date_label.setObjectName("field_label") date_label.setFixedWidth(60) self.date_edit = QDateEdit() @@ -424,14 +443,15 @@ class AddOvertimeUsedDialog(QDialog): # 시간 (30분 단위) time_layout = QHBoxLayout() - time_label = QLabel("시간:") + time_label = QLabel(tr('view.overtime.field_time')) time_label.setObjectName("field_label") time_label.setFixedWidth(60) self.hour_spin = QSpinBox() self.hour_spin.setRange(0, 23) - self.hour_spin.setSuffix("시간") + self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix')) self.minute_combo = QComboBox() - self.minute_combo.addItems(["0분", "30분"]) + self.minute_combo.addItems([tr('view.overtime.minute_0'), + tr('view.overtime.minute_30')]) time_layout.addWidget(time_label) time_layout.addWidget(self.hour_spin) time_layout.addWidget(self.minute_combo) @@ -439,21 +459,21 @@ class AddOvertimeUsedDialog(QDialog): # 사유 reason_layout = QHBoxLayout() - reason_label = QLabel("사유:") + reason_label = QLabel(tr('view.overtime.field_reason')) reason_label.setObjectName("field_label") reason_label.setFixedWidth(60) self.reason_edit = QLineEdit() - self.reason_edit.setPlaceholderText("예: 개인 사유") + self.reason_edit.setPlaceholderText(tr('view.overtime.placeholder_reason')) reason_layout.addWidget(reason_label) reason_layout.addWidget(self.reason_edit) layout.addLayout(reason_layout) # 버튼 button_layout = QHBoxLayout() - save_button = QPushButton("저장") + save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.clicked.connect(self.save) - cancel_button = QPushButton("취소") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.clicked.connect(self.reject) button_layout.addWidget(save_button) button_layout.addWidget(cancel_button) @@ -465,11 +485,12 @@ class AddOvertimeUsedDialog(QDialog): """저장""" # 시간 계산 (30분 단위) hours = self.hour_spin.value() - minutes = 0 if self.minute_combo.currentText() == "0분" else 30 + minutes = 0 if self.minute_combo.currentIndex() == 0 else 30 total_minutes = hours * 60 + minutes if total_minutes == 0: - QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.") + QMessageBox.warning(self, tr('msg.input_error.title'), + tr('view.overtime.zero_use_error')) return # 잔액 확인 @@ -477,23 +498,23 @@ class AddOvertimeUsedDialog(QDialog): if total_minutes > balance: QMessageBox.warning( self, - "잔액 부족", - f"사용 가능한 시간이 부족합니다.\n\n" - f"요청: {hours}시간 {minutes}분\n" - f"잔액: {balance // 60}시간 {balance % 60}분" + tr('view.overtime.balance_short_title'), + tr('view.overtime.balance_short_body', + req_h=hours, req_m=minutes, + bal_h=balance // 60, bal_m=balance % 60) ) return date = self.date_edit.date().toString("yyyy-MM-dd") - reason = self.reason_edit.text().strip() or "수동 사용" + reason = self.reason_edit.text().strip() or tr('msg.manual_added') # DB에 저장 self.db.add_overtime_usage(None, total_minutes, date, reason) QMessageBox.information( self, - "저장 완료", - f"{hours}시간 {minutes}분이 사용 처리되었습니다." + tr('msg.save_success.title'), + tr('view.overtime.saved_used', h=hours, m=minutes) ) self.accept() diff --git a/ui/settings_view.py b/ui/settings_view.py index eb8cfed..176e3bb 100644 --- a/ui/settings_view.py +++ b/ui/settings_view.py @@ -1114,16 +1114,18 @@ class SettingsView(QDialog): if self.parent_window and hasattr(self.parent_window, 'reload_settings'): self.parent_window.reload_settings() - # 언어 변경 감지 → 재시작 제안 + # 언어 변경 감지 → 등록된 위젯 즉시 재번역, 아직 미등록 영역은 재시작 권장 if hasattr(self, 'language_combo'): from core.i18n import get_language + from ui.i18n_runtime import set_language_and_retranslate new_lang = self.language_combo.currentData() if new_lang and new_lang != get_language(): + set_language_and_retranslate(new_lang) reply = QMessageBox.question( self, - "재시작 필요 / Restart required", - "언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n" - "Restart now to fully apply the language change?", + "재시작 / Restart", + "주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n" + "Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?", QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: