181 lines
5.9 KiB
Python
181 lines
5.9 KiB
Python
"""
|
|
utils.csv_importer 단위 테스트.
|
|
"""
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime
|
|
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, import_records
|
|
from core.database import Database
|
|
|
|
|
|
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)
|
|
|
|
|
|
class TestImportRecords:
|
|
def _db(self):
|
|
p = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
|
p.close()
|
|
db = Database(p.name)
|
|
db.save_settings({
|
|
'work_minutes': '480',
|
|
'lunch_duration_minutes': '60',
|
|
'dinner_duration_minutes': '60',
|
|
})
|
|
return db, p.name
|
|
|
|
def test_overwrite_clears_overtime_usage(self):
|
|
"""CSV 덮어쓰기 시 overtime_usage도 삭제되어 잔액이 일관성을 유지해야 함."""
|
|
db, path = self._db()
|
|
try:
|
|
date_str = '2026-04-01'
|
|
# 기존 기록 + 연장근무 사용 기록 생성
|
|
wid = db.add_work_record(date_str, '09:00:00')
|
|
db.update_clock_out(date_str, '20:00:00', 11.0, 120, 120)
|
|
db.add_overtime_earned(wid, 120, date_str)
|
|
db.add_overtime_usage(wid, 30, date_str, '테스트')
|
|
|
|
# 덮어쓰기 전 잔액
|
|
balance_before = db.get_total_overtime_balance()
|
|
assert balance_before == 90 # 120 적립 - 30 사용
|
|
|
|
rows = [{
|
|
'date': date_str,
|
|
'clock_in': '09:00:00',
|
|
'clock_out': '18:00:00',
|
|
'lunch_minutes': 60,
|
|
'dinner_minutes': 0,
|
|
'memo': '',
|
|
}]
|
|
import_records(db, rows, on_conflict='overwrite')
|
|
|
|
# 덮어쓰기 후 연장근무 사용 기록은 삭제되어야 함
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COUNT(*) FROM overtime_usage WHERE date = ?", (date_str,))
|
|
assert cur.fetchone()[0] == 0
|
|
balance_after = db.get_total_overtime_balance()
|
|
assert balance_after == 0 # 새 기록은 연장근무 없음
|
|
finally:
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|