From c675b8c2971d256d5c583b5f2765b1b37a95318f Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Mon, 27 Apr 2026 19:42:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=5Fempty=5Fstate=20=E2=80=94=20current?= =?UTF-8?q?=5Fstage=20+=20topics=20=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++- tests/e2e.py | 31 +++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index dada87b..78dc0f9 100644 --- a/app.py +++ b/app.py @@ -33,10 +33,11 @@ _lock = threading.RLock() def _empty_state(): return { "people": [], - "settings": {"voting_open": True}, + "settings": {"voting_open": True, "current_stage": "intro"}, "titles": {}, "tie_breaks": {}, "votes": [], + "topics": {"categories": []}, } @@ -55,6 +56,10 @@ def load_data(): base.update(data) for k, v in _empty_state().items(): base.setdefault(k, v) + # 한 단계 deep merge — 기존 데이터에 누락된 nested 키 보강 + for nested_key in ("settings", "topics"): + for k, default_v in _empty_state()[nested_key].items(): + base[nested_key].setdefault(k, default_v) return base @@ -360,6 +365,7 @@ def render_admin(): f""" - 👥 **참가자 투표**: [/](/) - 🎉 **시상식 (큰 화면)**: [/?mode=ceremony&token=...](?mode=ceremony&token={ADMIN_TOKEN}) +- 📦 **JSON 원본 조회**: [/?mode=raw&token=...](?mode=raw&token={ADMIN_TOKEN}) 호스트에서 LAN IP 포함 모든 URL 보기: ```bash @@ -368,6 +374,20 @@ def render_admin(): """ ) + with st.expander("💾 데이터 백업 (hackathon.json 다운로드)"): + try: + raw_bytes = Path(DATA_PATH).read_bytes() + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + st.download_button( + "📥 hackathon.json 다운로드", + raw_bytes, + file_name=f"hackathon_{ts}.json", + mime="application/json", + ) + st.caption(f"파일 경로: `{DATA_PATH}` ({len(raw_bytes):,} bytes)") + except FileNotFoundError: + st.warning(f"파일 없음: {DATA_PATH}") + voting_open = is_voting_open() cur_label = "🟢 투표 진행 중" if voting_open else "🔴 투표 마감됨" st.markdown(f"### 투표 상태: {cur_label}") @@ -692,6 +712,39 @@ def render_ceremony(): st.rerun() +def render_raw(): + """JSON 원본 조회 — admin token 필요.""" + token = st.query_params.get("token", "") + if token != ADMIN_TOKEN: + st.error("권한 없음. ?mode=raw&token=... 형식 필요.") + return + + st.title("📦 hackathon.json 원본") + try: + raw_text = Path(DATA_PATH).read_text(encoding="utf-8") + except FileNotFoundError: + st.error(f"파일 없음: {DATA_PATH}") + return + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + col1, col2 = st.columns(2) + with col1: + st.download_button( + "📥 다운로드", + raw_text, + file_name=f"hackathon_{ts}.json", + mime="application/json", + use_container_width=True, + ) + with col2: + st.caption(f"`{DATA_PATH}` — {len(raw_text):,} bytes") + + try: + st.json(json.loads(raw_text)) + except json.JSONDecodeError: + st.code(raw_text, language="json") + + def main(): st.set_page_config(page_title="해커톤 투표", page_icon="🗳", layout="wide") mode = st.query_params.get("mode", "vote") @@ -699,6 +752,8 @@ def main(): render_admin() elif mode == "ceremony": render_ceremony() + elif mode == "raw": + render_raw() else: render_voter() diff --git a/tests/e2e.py b/tests/e2e.py index cf166e6..2d5019e 100644 --- a/tests/e2e.py +++ b/tests/e2e.py @@ -9,7 +9,11 @@ import shutil # 격리 데이터 파일 (실제 hackathon.json과 분리) TEST_DATA = tempfile.mktemp(suffix=".json") -shutil.copy("/app/hackathon.json", TEST_DATA) +SEED_CANDIDATES = ["/app/data/hackathon.json", "/app/hackathon.json"] +seed = next((p for p in SEED_CANDIDATES if os.path.exists(p)), None) +if seed is None: + raise SystemExit(f"seed 파일 없음: {SEED_CANDIDATES}") +shutil.copy(seed, TEST_DATA) os.environ["DATA_PATH"] = TEST_DATA os.environ["ADMIN_TOKEN"] = "test" @@ -198,6 +202,29 @@ def t_vote_count(): assert len(list_votes()) == 0 +def t_empty_state_has_topics_and_stage(): + from app import _empty_state + s = _empty_state() + assert s["settings"]["current_stage"] == "intro" + assert s["topics"] == {"categories": []} + + +def t_load_data_backfills_nested_settings(): + """기존 settings에 voting_open만 있을 때 current_stage 자동 보강.""" + legacy = { + "people": [], + "settings": {"voting_open": True}, + "titles": {}, + "tie_breaks": {}, + "votes": [], + } + save_data(legacy) + fresh = load_data() + assert fresh["settings"]["voting_open"] is True + assert fresh["settings"]["current_stage"] == "intro" + assert fresh["topics"] == {"categories": []} + + if __name__ == "__main__": print(f"# E2E (data={TEST_DATA})\n") test("hackathon.json 로드 (34명, 7팀)", t_load) @@ -212,6 +239,8 @@ if __name__ == "__main__": test("archive 동률 skip", t_archive_skip_pending) test("atomic write", t_atomic_write) test("clear_votes", t_vote_count) + test("empty_state 신규 키", t_empty_state_has_topics_and_stage) + test("load_data nested 키 backfill", t_load_data_backfills_nested_settings) fails = sum(1 for r, _ in results if r == FAIL) print(f"\n# {len(results)} 중 통과 {len(results) - fails}, 실패 {fails}")