DB(sqlite + WAL) 제거. 모든 state를 단일 JSON 파일로 통합. 일회용/내부용이라 유지보수성/확장성보다 단순성 우선. 변경: - app.py: sqlite3 import 제거. load_data/save_data + threading.RLock + atomic write - votes: list of dict - titles, tie_breaks, settings: dict - people: roster (assign_teams가 채움) - 누락 키 자동 보강 - assign_teams.py: hackathon.json 단일 출력. 기존 votes/titles 보존 - Dockerfile/compose: votes.db volume 제거. hackathon.json read-write mount - tests/e2e.py: 12개 (12/12 통과). load/save/insert_vote/clear_votes/atomic 추가 - README: 새 데이터 구조 문서화 - roster.json/participants.json 제거 (hackathon.json으로 통합) 호스트 편집 워크플로: - jq/vi로 hackathon.json 직접 편집 - 앱 매 요청 reload — 컨테이너 재시작 불필요 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
6.3 KiB
Python
220 lines
6.3 KiB
Python
"""
|
|
E2E 테스트 — JSON 단일 파일 기반.
|
|
실행: docker exec hackathon-vote python3 /tmp/e2e.py
|
|
"""
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import shutil
|
|
|
|
# 격리 데이터 파일 (실제 hackathon.json과 분리)
|
|
TEST_DATA = tempfile.mktemp(suffix=".json")
|
|
shutil.copy("/app/hackathon.json", TEST_DATA)
|
|
os.environ["DATA_PATH"] = TEST_DATA
|
|
os.environ["ADMIN_TOKEN"] = "test"
|
|
|
|
sys.path.insert(0, "/app")
|
|
|
|
from app import ( # noqa: E402
|
|
load_data,
|
|
save_data,
|
|
insert_vote,
|
|
list_votes,
|
|
clear_votes,
|
|
get_participants,
|
|
get_teams,
|
|
compute_winners,
|
|
save_tie_break,
|
|
clear_tie_break,
|
|
set_voting_open,
|
|
is_voting_open,
|
|
set_title,
|
|
get_titles,
|
|
archive_results,
|
|
)
|
|
|
|
|
|
def reset_votes():
|
|
"""투표만 초기화 (people, settings 보존)."""
|
|
data = load_data()
|
|
data["votes"] = []
|
|
data["tie_breaks"] = {}
|
|
data["titles"] = {}
|
|
data["settings"]["voting_open"] = True
|
|
save_data(data)
|
|
|
|
|
|
def add_vote(name, emp, team, fun, polish, utility):
|
|
insert_vote(name, emp, team, {
|
|
"fun_team": fun,
|
|
"polish_team": polish,
|
|
"utility_team": utility,
|
|
})
|
|
|
|
|
|
PASS = "✅"
|
|
FAIL = "❌"
|
|
results = []
|
|
|
|
|
|
def test(name, fn):
|
|
try:
|
|
fn()
|
|
results.append((PASS, name))
|
|
print(f"{PASS} {name}")
|
|
except AssertionError as e:
|
|
results.append((FAIL, f"{name}: {e}"))
|
|
print(f"{FAIL} {name}: {e}")
|
|
except Exception as e:
|
|
results.append((FAIL, f"{name}: UNEXPECTED {type(e).__name__}: {e}"))
|
|
print(f"{FAIL} {name}: UNEXPECTED {type(e).__name__}: {e}")
|
|
|
|
|
|
def t_load():
|
|
parts = get_participants()
|
|
teams = get_teams()
|
|
assert len(parts) == 34, f"기대 34명, 실제 {len(parts)}"
|
|
assert len(teams) == 7
|
|
assert "한지승" in parts
|
|
assert "김태현" not in parts
|
|
|
|
|
|
def t_voting_toggle():
|
|
reset_votes()
|
|
assert is_voting_open() is True
|
|
set_voting_open(False)
|
|
assert is_voting_open() is False
|
|
set_voting_open(True)
|
|
assert is_voting_open() is True
|
|
|
|
|
|
def t_simple_winners():
|
|
reset_votes()
|
|
for i in range(5):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀2", "팀3")
|
|
winners, _ = compute_winners()
|
|
assert winners["fun_team"]["status"] == "ok"
|
|
assert winners["fun_team"]["team"] == "팀1"
|
|
assert winners["polish_team"]["team"] == "팀2"
|
|
assert winners["utility_team"]["team"] == "팀3"
|
|
|
|
|
|
def t_priority_one_team_all_first():
|
|
reset_votes()
|
|
for i in range(5):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀1", "팀1")
|
|
for i in range(5, 8):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀2", "팀2", "팀1")
|
|
for i in range(8, 10):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀3", "팀3", "팀2")
|
|
winners, _ = compute_winners()
|
|
assert winners["utility_team"]["team"] == "팀1"
|
|
assert winners["polish_team"]["team"] == "팀2"
|
|
assert winners["fun_team"]["team"] == "팀3"
|
|
|
|
|
|
def t_tie_pending():
|
|
reset_votes()
|
|
for i in range(3):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1")
|
|
for i in range(3, 6):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2")
|
|
winners, _ = compute_winners()
|
|
assert winners["utility_team"]["status"] == "tie"
|
|
assert set(winners["utility_team"]["tied"]) == {"팀1", "팀2"}
|
|
|
|
|
|
def t_tie_break_random():
|
|
reset_votes()
|
|
for i in range(3):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1")
|
|
for i in range(3, 6):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2")
|
|
save_tie_break("utility_team", "팀1", "random")
|
|
winners, _ = compute_winners()
|
|
assert winners["utility_team"]["status"] == "ok"
|
|
assert winners["utility_team"]["team"] == "팀1"
|
|
assert winners["utility_team"]["method"] == "random"
|
|
clear_tie_break("utility_team")
|
|
|
|
|
|
def t_unique_voter():
|
|
reset_votes()
|
|
add_vote("홍길동", "E1", "팀1", "팀2", "팀3", "팀4")
|
|
try:
|
|
add_vote("홍길동", "E2", "팀1", "팀5", "팀6", "팀7")
|
|
raise AssertionError("중복 INSERT 통과")
|
|
except ValueError as e:
|
|
assert str(e) == "DUPLICATE_VOTER"
|
|
|
|
|
|
def t_titles_persist():
|
|
reset_votes()
|
|
set_title("팀1", "Slack 자동 분류기")
|
|
titles = get_titles()
|
|
assert titles["팀1"] == "Slack 자동 분류기"
|
|
|
|
|
|
def t_archive_writes_file():
|
|
reset_votes()
|
|
for i in range(5):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀2", "팀3")
|
|
archive_dir = os.path.dirname(TEST_DATA) or "."
|
|
before = set(f for f in os.listdir(archive_dir) if f.startswith("results_"))
|
|
path = archive_results()
|
|
assert path is not None, "archive None 반환"
|
|
assert os.path.exists(path)
|
|
after = set(f for f in os.listdir(archive_dir) if f.startswith("results_"))
|
|
assert len(after) > len(before)
|
|
os.unlink(path)
|
|
|
|
|
|
def t_archive_skip_pending():
|
|
reset_votes()
|
|
for i in range(3):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1")
|
|
for i in range(3, 6):
|
|
add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2")
|
|
path = archive_results()
|
|
assert path is None
|
|
|
|
|
|
def t_atomic_write():
|
|
"""save_data 후 load_data가 동일 데이터 반환."""
|
|
reset_votes()
|
|
data = load_data()
|
|
data["titles"]["팀1"] = "test atomic"
|
|
save_data(data)
|
|
fresh = load_data()
|
|
assert fresh["titles"]["팀1"] == "test atomic"
|
|
|
|
|
|
def t_vote_count():
|
|
reset_votes()
|
|
assert len(list_votes()) == 0
|
|
add_vote("A", "1", "팀1", "팀2", "팀3", "팀4")
|
|
assert len(list_votes()) == 1
|
|
clear_votes()
|
|
assert len(list_votes()) == 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f"# E2E (data={TEST_DATA})\n")
|
|
test("hackathon.json 로드 (34명, 7팀)", t_load)
|
|
test("투표 마감 토글", t_voting_toggle)
|
|
test("단순 winner", t_simple_winners)
|
|
test("priority 1팀 모든 분야 1위", t_priority_one_team_all_first)
|
|
test("동률 status='tie'", t_tie_pending)
|
|
test("tie_break 적용", t_tie_break_random)
|
|
test("voter_name UNIQUE 강제", t_unique_voter)
|
|
test("팀 제목 영속", t_titles_persist)
|
|
test("archive 파일 생성", t_archive_writes_file)
|
|
test("archive 동률 skip", t_archive_skip_pending)
|
|
test("atomic write", t_atomic_write)
|
|
test("clear_votes", t_vote_count)
|
|
|
|
fails = sum(1 for r, _ in results if r == FAIL)
|
|
print(f"\n# {len(results)} 중 통과 {len(results) - fails}, 실패 {fails}")
|
|
os.unlink(TEST_DATA)
|
|
sys.exit(0 if fails == 0 else 1)
|