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}")