chore: 저장소 구조 정리 및 문서화, 첫 커밋

- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes)
- 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에
  안내용 README.md 추가
- CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가
- docs/README.md 문서 지도 갱신
This commit is contained in:
Yun Chan 2026-09-04 09:25:44 +09:00
commit 56a6e2da93
159 changed files with 145825 additions and 0 deletions

View file

@ -0,0 +1,173 @@
from __future__ import annotations
"""Windows 온보딩 앱 디자인 RED.
근거:
- designpaca: 상태는 단독이 아니라 텍스트·기호·다음 행동으로 전달한다.
- Microsoft Windows/Fluent guidance: 설정 화면은 단순 그룹, 상태와 다음 행동, 접근 가능한 컨트롤을 제공한다.
- 사용자 제약: 비개발자가 더블클릭 막힌 항목을 직접 복구해야 한다.
"""
import time
from typing import Iterator
import pytest
from dmf_crawler.models import CheckResult, Severity
def _walk_widgets(widget: object) -> Iterator[object]:
children = getattr(widget, "winfo_children", lambda: [])()
for child in children:
yield child
yield from _walk_widgets(child)
def _all_texts(widget: object) -> list[str]:
texts: list[str] = []
for child in _walk_widgets(widget):
cget = getattr(child, "cget", None)
if cget is None:
continue
try:
text = str(cget("text") or "")
except Exception: # noqa: BLE001 - Tk 위젯 종류별 cget 차이 방어
continue
if text:
texts.append(text)
return texts
def _new_onboarding_app(gui_app: object, *, mode: str = "inspect") -> object:
"""Windows Python 3.14 의 Tk 초기화가 연속 root 생성에서 흔들리면 짧게 재시도한다."""
import tkinter as tk
last: tk.TclError | None = None
for _ in range(3):
try:
return gui_app.OnboardingApp(mode=mode)
except tk.TclError as exc:
last = exc
time.sleep(0.08)
assert last is not None
raise last
def _result(
key: str,
title: str,
*,
ok: bool,
severity: Severity,
detail: str,
fix_hint: str = "다음 행동 안내",
fix_action: str | None = "enter_api_key",
) -> CheckResult:
return CheckResult(
key=key,
title=title,
ok=ok,
detail=detail,
fix_hint=fix_hint,
fix_action=fix_action,
severity=severity,
)
def test_onboarding_app_groups_checks_into_status_summary_and_task_sections(monkeypatch: pytest.MonkeyPatch) -> None:
"""12개 체크를 평평하게 나열하면 비개발자는 무엇부터 고칠지 모른다."""
tkinter = pytest.importorskip("tkinter")
from dmf_crawler.gui import app as gui_app
monkeypatch.setattr(gui_app.OnboardingApp, "refresh", lambda self: None)
win = _new_onboarding_app(gui_app, mode="inspect")
try:
win._render(
[
_result(
"api_key_present",
"공공데이터포털 인증키",
ok=False,
severity=Severity.CRITICAL,
detail="키가 없어 오늘 수집을 시작할 수 없습니다.",
fix_hint="키 입력 버튼을 누르세요.",
fix_action="enter_api_key",
),
_result(
"agy_auth",
"AI 요약 로그인(선택)",
ok=False,
severity=Severity.WARN,
detail="AI 요약을 켠 경우에만 로그인하면 됩니다.",
fix_hint="기본 수집과 xlsx 생성은 계속 가능합니다.",
fix_action="login_agy",
),
_result(
"database",
"자료 보관소",
ok=True,
severity=Severity.CRITICAL,
detail="DB가 준비됐습니다.",
fix_action=None,
),
_result(
"recent_runs",
"최근 실행 상태",
ok=True,
severity=Severity.WARN,
detail="최근 기준선 리포트가 있습니다.",
fix_action=None,
),
]
)
win.update_idletasks()
text_blob = "\n".join(_all_texts(win))
assert "필수 1" in text_blob, "필수 실패 개수는 상단에서 바로 보여야 한다"
assert "권장 1" in text_blob, "선택/권장 실패 개수도 실패와 구분돼야 한다"
assert "정상 2" in text_blob, "통과 항목도 사용자가 안심할 수 있게 요약돼야 한다"
assert "필수 설정" in text_blob
assert "선택 기능" in text_blob
assert "운영 상태" in text_blob
assert "AI 요약은 선택" in text_blob, "Google/agy가 기본 수집을 막지 않는다는 문구가 필요하다"
finally:
win.destroy()
def test_onboarding_check_rows_use_badges_and_readable_action_buttons(monkeypatch: pytest.MonkeyPatch) -> None:
"""행 내부 액션도 clipped/색 단독/좁은 버튼 회귀를 막는다."""
tkinter = pytest.importorskip("tkinter")
from tkinter import ttk
from dmf_crawler.gui import app as gui_app
from dmf_crawler.gui.widgets import StatusBadge
monkeypatch.setattr(gui_app.OnboardingApp, "refresh", lambda self: None)
win = _new_onboarding_app(gui_app, mode="inspect")
try:
win._render(
[
_result(
"api_key_present",
"공공데이터포털 인증키",
ok=False,
severity=Severity.CRITICAL,
detail="키가 없어 오늘 수집을 시작할 수 없습니다.",
fix_hint="키 입력 버튼을 누르세요.",
fix_action="enter_api_key",
)
]
)
win.update_idletasks()
widgets = list(_walk_widgets(win))
badges = [w for w in widgets if isinstance(w, StatusBadge)]
assert badges, "상태는 텍스트 옆의 전용 badge로도 보여야 색 단독 회귀를 막을 수 있다"
buttons = [w for w in widgets if isinstance(w, ttk.Button)]
labels = {str(btn.cget("text")): btn for btn in buttons}
assert "키 입력" in labels
action = labels["키 입력"]
assert int(str(action.cget("width") or "0")) >= 14
assert int(action.winfo_reqwidth()) >= 104
assert int(action.winfo_reqheight()) >= 34
finally:
win.destroy()