v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱
- 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+ 아키텍처 반영
This commit is contained in:
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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', '')
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user