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,611 @@
from __future__ import annotations
"""사용자가 직접 겪은 UX 회귀 RED.
근거:
- docs/design/07-tdd-red-system.md R0/R5
- 2026-09-03 사용자 제보: cp949 콘솔 UnicodeEncodeError, Google 로그인 강요 UX,
하단 버튼 텍스트 찌그러짐 스크린샷.
"""
import io
import os
import subprocess
import sys
import time
from typing import Iterator
import pytest
from pathlib import Path
from dmf_crawler.config import AgyCfg, Config, SourceCfg
from dmf_crawler.models import CheckResult, RunResult, RunStatus, Severity, StageOutcome, StageStatusValue
ROOT = Path(__file__).resolve().parents[1]
PYTHON = ROOT / ".venv" / "Scripts" / "python.exe"
def _python() -> str:
return str(PYTHON if PYTHON.exists() else sys.executable)
def test_cli_result_output_survives_cp949_console(monkeypatch: pytest.MonkeyPatch) -> None:
"""cp949 콘솔/리다이렉션에서도 결과 출력이 UnicodeEncodeError 로 다시 죽으면 안 된다."""
from dmf_crawler import cli
raw = io.BytesIO()
cp949_stdout = io.TextIOWrapper(raw, encoding="cp949", errors="strict", newline="")
monkeypatch.setattr(sys, "stdout", cp949_stdout)
result = RunResult(
run_id="run_20260903_cp949",
run_date="2026-09-03",
status=RunStatus.BLOCKED,
exit_code=2,
stages=(
StageOutcome(
stage="preflight",
status=StageStatusValue.FAILED,
error="공공데이터포털 serviceKey 가 없습니다 — 키 입력이 필요합니다…",
),
),
)
cli._print_result(result) # 사용자가 본 장애: 여기서 UnicodeEncodeError 발생
cp949_stdout.flush()
rendered = raw.getvalue().decode("cp949")
assert "preflight" in rendered
assert "serviceKey" in rendered
assert "UnicodeEncodeError" not in rendered
def test_doctor_json_survives_cp949_console() -> None:
"""JSON 출력도 cp949에서 em dash 때문에 죽으면 안 된다."""
env = os.environ.copy()
env["PYTHONIOENCODING"] = "cp949"
proc = subprocess.run(
[_python(), "-m", "dmf_crawler", "doctor", "--json"],
cwd=ROOT,
env=env,
capture_output=True,
timeout=60,
)
assert proc.returncode in {0, 1, 2}, proc.stderr.decode("cp949", errors="replace")
combined = proc.stdout + proc.stderr
assert b"UnicodeEncodeError" not in combined
assert proc.stdout.strip().startswith(b"[")
def test_public_data_api_key_is_optional_in_doctor(monkeypatch: pytest.MonkeyPatch) -> None:
"""공공데이터포털 인증키 없음은 공식 API 선택 기능 경고일 뿐, 실행 버튼을 막는 필수 실패가 아니다."""
from dmf_crawler import checks
from dmf_crawler.models import CheckResult, Severity
monkeypatch.setattr(checks, "key_fingerprint", lambda: None)
monkeypatch.setattr(checks, "load_service_key", lambda: None)
present = checks.run_one("api_key_present", Config())
valid = checks.run_one("api_key_valid", Config())
assert present.ok is False
assert valid.ok is False
assert present.severity is Severity.WARN
assert valid.severity is Severity.WARN
assert "선택" in present.detail + present.fix_hint
assert "리포트" in present.detail + present.fix_hint
critical_ok = CheckResult(
key="python_venv",
title="실행 환경",
ok=True,
detail="정상",
fix_hint="",
fix_action=None,
severity=Severity.CRITICAL,
)
assert checks.can_run([critical_ok, present, valid]) is True
assert checks.verdict([critical_ok, present, valid]) == 1
def test_public_data_api_key_is_grouped_as_optional_in_onboarding() -> None:
"""온보딩 화면에서도 인증키는 '필수 설정'이 아니라 선택 기능으로 묶여야 한다."""
from dmf_crawler.gui import app as gui_app
groups = {title: set(keys) for title, _description, keys in gui_app.CHECK_GROUPS}
assert "api_key_present" not in groups["필수 설정"]
assert "api_key_valid" not in groups["필수 설정"]
assert {"api_key_present", "api_key_valid"}.issubset(groups["선택 기능"])
assert "공식 API" in next(
description for title, description, _keys in gui_app.CHECK_GROUPS if title == "선택 기능"
)
def test_agy_google_login_is_optional_when_ai_disabled_for_api_mode() -> None:
"""공식 API만 쓰고 AI 요약을 끈 상태에서는 Google 로그인을 요구하거나 버튼을 띄우지 않는다."""
from dmf_crawler import checks
cfg = Config().with_overrides(source=SourceCfg(mode="api"), agy=AgyCfg(enabled=False))
installed = checks.run_one("agy_installed", cfg)
auth = checks.run_one("agy_auth", cfg)
assert installed.ok is True
assert auth.ok is True
assert installed.fix_action is None
assert auth.fix_action is None
assert "필요 없음" in installed.detail
assert "Google 로그인 필요 없음" in auth.detail
assert "로그인" not in auth.fix_hint
def test_agy_is_prompted_for_headful_collection_when_auto_has_no_api_key(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""AI 요약을 꺼도 auto+키 없음이면 최신 수집용 headful AGY 준비를 안내해야 한다."""
from dmf_crawler import checks, secrets_dpapi
monkeypatch.setattr(secrets_dpapi, "load_service_key", lambda: None)
monkeypatch.setattr(checks, "AGY_TOKEN", tmp_path / "missing-token")
cfg = Config().with_overrides(
source=SourceCfg(mode="auto"),
agy=AgyCfg(enabled=False, binary_path=str(tmp_path / "missing-agy.exe")),
)
installed = checks.run_one("agy_installed", cfg)
auth = checks.run_one("agy_auth", cfg)
assert installed.ok is False
assert auth.ok is False
assert installed.fix_action == "install_agy"
assert auth.fix_action == "login_agy"
assert "headful" in installed.fix_hint.lower() or "브라우저" in installed.fix_hint
assert "headful" in auth.fix_hint.lower() or "브라우저" in auth.fix_hint
def test_agy_is_prompted_for_forced_browser_mode_even_with_api_key(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""browser 모드는 API 키가 있어도 수집용 headful AGY가 필요하다."""
from dmf_crawler import checks, secrets_dpapi
monkeypatch.setattr(secrets_dpapi, "load_service_key", lambda: "dummy-key")
cfg = Config().with_overrides(
source=SourceCfg(mode="browser"),
agy=AgyCfg(enabled=False, binary_path=str(tmp_path / "missing-agy.exe")),
)
installed = checks.run_one("agy_installed", cfg)
assert installed.ok is False
assert installed.fix_action == "install_agy"
assert "브라우저" in installed.fix_hint
def test_google_login_optional_policy_is_documented_in_ssot() -> None:
"""문서/agent 지침도 Google 로그인 강요 금지를 정본으로 박아야 한다."""
files = [
ROOT / "docs" / "00-REQUIREMENTS.md",
ROOT / "docs" / "design" / "04-onboarding-wizard.md",
ROOT / "AGENTS.md",
]
for path in files:
text = path.read_text(encoding="utf-8")
assert "Google 로그인은 선택" in text or "Google 로그인을 먼저 요구하지 않는다" in text, path
def test_public_data_api_key_optional_policy_is_documented_in_ssot() -> None:
"""문서/agent 지침도 공공데이터포털 인증키를 필수 전제조건으로 되돌리면 안 된다."""
files = [
ROOT / "docs" / "00-REQUIREMENTS.md",
ROOT / "docs" / "design" / "04-onboarding-wizard.md",
ROOT / "AGENTS.md",
]
for path in files:
text = path.read_text(encoding="utf-8")
assert "공공데이터포털 인증키는 선택" in text, path
assert "없어도 기존 자료 리포트" in text or "키가 없어도" in text, path
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 test_onboarding_footer_buttons_have_readable_minimum_size(monkeypatch: pytest.MonkeyPatch) -> None:
"""하단 주요 버튼은 한글 텍스트가 찌그러지지 않는 최소 폭/패딩을 가져야 한다."""
tkinter = pytest.importorskip("tkinter")
from tkinter import ttk
from dmf_crawler.gui import app as gui_app
# 네트워크/DB 진단을 돌리지 않고 순수 레이아웃만 검사한다.
monkeypatch.setattr(gui_app.OnboardingApp, "refresh", lambda self: None)
win = gui_app.OnboardingApp(mode="inspect")
try:
win.update_idletasks()
buttons = [w for w in _walk_widgets(win) if isinstance(w, ttk.Button)]
labels = {str(btn.cget("text")): btn for btn in buttons}
for label in ("다시 검사", "지금 실행", "로그 열기", "닫기"):
assert label in labels, f"필수 버튼 누락: {label}"
button = labels[label]
width = int(str(button.cget("width") or "0"))
req_width = int(button.winfo_reqwidth())
req_height = int(button.winfo_reqheight())
assert width >= 12, f"{label}: width={width}, 최소 12 이상이어야 텍스트가 읽힌다"
assert req_width >= 96, f"{label}: 요청 폭 {req_width}px 은 너무 좁다"
assert req_height >= 34, f"{label}: 요청 높이 {req_height}px 은 텍스트가 세로로 잘릴 위험"
style = ttk.Style(win)
padding = str(style.lookup("TButton", "padding") or style.lookup("Primary.TButton", "padding") or "")
assert padding, "TButton padding 이 명시돼야 Windows 배율에서 텍스트가 찌그러지지 않는다"
finally:
win.destroy()
def _check_result(
key: str,
title: str,
*,
ok: bool,
severity: Severity,
detail: str = "정상",
fix_hint: str = "버튼을 눌러 다음 작업을 진행하세요.",
fix_action: str | None = "enter_api_key",
) -> CheckResult:
from dmf_crawler.models import CheckResult, Severity
return CheckResult(
key=key,
title=title,
ok=ok,
detail=detail,
fix_hint=fix_hint,
fix_action=fix_action,
severity=severity,
)
def _screenshot_regression_results() -> list[CheckResult]:
from dmf_crawler.models import Severity
return [
_check_result(
"python_venv",
"실행 환경",
ok=True,
severity=Severity.CRITICAL,
detail="Python 3.14.6 · 전용 환경 사용 중",
fix_action="open_bootstrap_help",
),
_check_result(
"dependencies",
"필요한 부품",
ok=True,
severity=Severity.CRITICAL,
detail="모두 설치되어 있습니다",
fix_action="install_deps",
),
_check_result(
"config_valid",
"설정 파일",
ok=True,
severity=Severity.CRITICAL,
detail="정상 · 실행 시각 06:00",
fix_action="open_config",
),
_check_result(
"database",
"자료 보관소",
ok=False,
severity=Severity.CRITICAL,
detail="자료 형식 갱신이 필요합니다. 현재 3 → 필요 4",
fix_hint="[복구하기] 를 누르면 백업을 먼저 뜬 뒤 갱신합니다.",
fix_action="repair_db",
),
_check_result(
"disk_space",
"저장 공간",
ok=True,
severity=Severity.CRITICAL,
detail="여유 100.0 GB",
fix_action="open_cleanmgr",
),
_check_result(
"report_writable",
"리포트 저장 폴더",
ok=False,
severity=Severity.CRITICAL,
detail="오늘 자 리포트가 Excel 에서 열려 있습니다. reports\\DMF_리포트_최신.xlsx",
fix_hint="Excel 을 닫지 않아도 실행은 되지만 다른 이름으로 저장됩니다.",
fix_action="open_reports_dir",
),
_check_result(
"api_key_present",
"공식 API 인증키(선택)",
ok=False,
severity=Severity.WARN,
detail="선택 항목입니다. 아직 등록되지 않아 공식 API 자동 수집만 건너뜁니다.",
fix_hint="공식 API 경로를 쓰고 싶을 때만 등록하세요.",
fix_action="enter_api_key",
),
_check_result(
"api_key_valid",
"공식 API 사용 가능 여부(선택)",
ok=False,
severity=Severity.WARN,
detail="선택 항목입니다. 키가 없어서 공식 API 실호출 확인을 건너뜁니다.",
fix_hint="공식 API 자동 수집이 필요할 때만 인증키를 등록해 주세요.",
fix_action="enter_api_key",
),
_check_result(
"agy_installed",
"AI 요약(선택)",
ok=True,
severity=Severity.WARN,
detail="AI 요약 사용 안 함 — 설치 필요 없음",
fix_action=None,
),
_check_result(
"agy_auth",
"AI 요약 로그인(선택)",
ok=True,
severity=Severity.WARN,
detail="AI 요약 사용 안 함 — Google 로그인 필요 없음",
fix_action=None,
),
_check_result(
"tasks_registered",
"자동 실행 등록",
ok=False,
severity=Severity.WARN,
detail="등록되지 않았습니다. (빠진 것: Daily, Agent)",
fix_hint="[등록하기] 를 누르면 바로 설정됩니다.",
fix_action="install_tasks",
),
_check_result(
"recent_runs",
"최근 실행 상태",
ok=True,
severity=Severity.WARN,
detail="아직 한 번도 실행하지 않았습니다.",
fix_action="open_last_log",
),
]
def _buttons_by_text(root: object) -> dict[str, object]:
from tkinter import ttk
buttons = [w for w in _walk_widgets(root) if isinstance(w, ttk.Button)]
return {str(btn.cget("text")): btn for btn in buttons}
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 _new_tk_root() -> object:
"""ScrollFrame 단위 테스트용 Tk root 를 만든다."""
import tkinter as tk
last: tk.TclError | None = None
for _ in range(3):
try:
root = tk.Tk()
root.geometry("360x220")
return root
except tk.TclError as exc:
last = exc
time.sleep(0.08)
assert last is not None
raise last
def _assert_button_text_is_rendered(win: object, label: str) -> None:
labels = _buttons_by_text(win)
assert label in labels, f"필수 버튼 누락: {label}"
button = labels[label]
assert str(button.cget("text")).strip() == label, f"{label}: 버튼 text 속성이 비면 안 된다"
assert button.winfo_ismapped(), f"{label}: 화면에 실제 배치되지 않아 빈 버튼처럼 보인다"
assert button.winfo_width() >= min(button.winfo_reqwidth(), 96), (
f"{label}: 실제 폭 {button.winfo_width()}px < 요청 폭 {button.winfo_reqwidth()}px"
)
assert button.winfo_height() >= min(button.winfo_reqheight(), 34), (
f"{label}: 실제 높이 {button.winfo_height()}px < 요청 높이 {button.winfo_reqheight()}px"
)
top = button.winfo_rooty() - win.winfo_rooty()
bottom = top + button.winfo_height()
assert 0 <= top < bottom <= win.winfo_height(), (
f"{label}: 창 밖으로 잘림(top={top}, bottom={bottom}, window={win.winfo_height()})"
)
def test_onboarding_footer_buttons_render_visible_text_after_refresh(monkeypatch: pytest.MonkeyPatch) -> None:
"""refresh() 이후 하단 footer 버튼이 스크린샷처럼 빈 버튼으로 렌더링되면 안 된다."""
pytest.importorskip("tkinter")
from dmf_crawler.gui import app as gui_app
results = _screenshot_regression_results()
def fake_refresh(self: object) -> None:
self._render(results)
monkeypatch.setattr(gui_app.OnboardingApp, "refresh", fake_refresh)
win = _new_onboarding_app(gui_app, mode="inspect")
try:
win.refresh()
win.update_idletasks()
for label in ("다시 검사", "지금 실행", "로그 열기", "닫기"):
_assert_button_text_is_rendered(win, label)
finally:
win.destroy()
def test_onboarding_footer_buttons_keep_text_when_required_items_exist(monkeypatch: pytest.MonkeyPatch) -> None:
"""필수 조치가 있는 상태에서도 footer 버튼 텍스트가 높이 부족으로 사라지면 안 된다."""
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(_screenshot_regression_results())
win.update_idletasks()
assert win.winfo_reqheight() <= win.winfo_height(), (
f"창 요청 높이 {win.winfo_reqheight()}px 이 실제 높이 {win.winfo_height()}px 을 넘어 footer가 잘린다"
)
for label in ("다시 검사", "지금 실행", "로그 열기", "닫기"):
_assert_button_text_is_rendered(win, label)
finally:
win.destroy()
def test_onboarding_row_action_buttons_are_not_clipped_inside_scroll_view(monkeypatch: pytest.MonkeyPatch) -> None:
"""긴 한글 제목/경로가 들어와도 행 액션 버튼이 스크롤 영역 밖으로 밀리면 안 된다."""
pytest.importorskip("tkinter")
from tkinter import ttk
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(
[
_check_result(
"report_writable",
"리포트 저장 폴더 D:/매우긴경로/" + ("하위폴더" * 20),
ok=False,
severity=Severity.CRITICAL,
detail="긴 한글 오류 설명입니다. " * 20,
fix_hint="긴 한글 복구 안내입니다. " * 12,
fix_action="open_reports_dir",
)
]
)
win.update_idletasks()
canvas = win.scroll.canvas
assert win.scroll.inner.winfo_reqwidth() <= canvas.winfo_width(), (
f"스크롤 내부 요청 폭 {win.scroll.inner.winfo_reqwidth()}px 이 viewport {canvas.winfo_width()}px 을 넘으면 텍스트/버튼이 잘린다"
)
action = _buttons_by_text(win)["폴더 열기"]
left = action.winfo_rootx() - canvas.winfo_rootx()
right = left + action.winfo_width()
assert action.winfo_ismapped()
assert 0 <= left < right <= canvas.winfo_width(), (
f"행 액션 버튼이 viewport 밖으로 밀림(left={left}, right={right}, viewport={canvas.winfo_width()})"
)
assert action.winfo_width() >= min(action.winfo_reqwidth(), 104)
assert action.winfo_height() >= min(action.winfo_reqheight(), 34)
finally:
win.destroy()
def test_onboarding_new_grouped_layout_keeps_primary_actions_visible(monkeypatch: pytest.MonkeyPatch) -> None:
"""그룹형 새 레이아웃에서도 주요 액션이 창 하단 밖으로 사라지면 안 된다."""
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(_screenshot_regression_results())
win.update_idletasks()
texts = []
for child in _walk_widgets(win):
cget = getattr(child, "cget", None)
if cget is None:
continue
try:
text = str(cget("text") or "")
except Exception: # noqa: BLE001 - Frame 등 text 옵션이 없는 위젯은 건너뛴다
continue
if text:
texts.append(text)
text_blob = "\n".join(texts)
assert "필수 설정" in text_blob
assert "선택 기능" in text_blob
assert "운영 상태" in text_blob
for label in ("다시 검사", "지금 실행", "로그 열기", "닫기"):
_assert_button_text_is_rendered(win, label)
finally:
win.destroy()
def test_scroll_frame_mousewheel_scrolls_when_pointer_is_over_content() -> None:
"""스크롤 영역 안의 카드/텍스트 위에서도 마우스 휠이 실제로 스크롤되어야 한다."""
tkinter = pytest.importorskip("tkinter")
from dmf_crawler.gui.widgets import ScrollFrame, init_style
root = _new_tk_root()
try:
init_style(root)
frame = ScrollFrame(root, height=95)
frame.pack(fill="both", expand=True)
target = None
for index in range(40):
label = tkinter.ttk.Label(
frame.inner,
text=f"복구 항목 {index:02d} — 이 텍스트 위에서 휠을 굴려도 스크롤되어야 합니다.",
)
label.pack(fill="x", padx=8, pady=3)
if index == 5:
target = label
assert target is not None
root.update_idletasks()
frame.canvas.yview_moveto(0.0)
root.update()
before = frame.canvas.yview()[0]
target.event_generate("<Enter>")
target.event_generate("<MouseWheel>", delta=-120)
root.update()
after = frame.canvas.yview()[0]
assert after > before, (
"MouseWheel 이 canvas 바깥의 실제 콘텐츠 위젯에서 발생해도 "
f"스크롤해야 한다(before={before}, after={after})"
)
finally:
root.destroy()
def test_onboarding_status_panel_names_blockers_instead_of_generic_yellow_info_box(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""상단 인포 박스는 추상 문구가 아니라 현재 상태/막힌 항목/다음 행동을 구체적으로 말해야 한다."""
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(_screenshot_regression_results())
win.update_idletasks()
banner_text = str(win.banner.cget("text"))
for forbidden in ("무엇:", "왜:", "어떻게:", "다음:", "다음 행동"):
assert forbidden not in banner_text, f"AI식 라벨 문구 제거 필요: {forbidden}"
assert "먼저" in banner_text or "확인" in banner_text
assert "자료 보관소" in banner_text
assert "리포트 저장 폴더" in banner_text
assert "복구하기" in banner_text or "폴더 열기" in banner_text
assert "공식 API 인증키" in banner_text and "선택" in banner_text
assert len([line for line in banner_text.splitlines() if line.strip()]) <= 4, (
"상단 안내문은 짧은 한국어 문장으로 끝나야 한다"
)
finally:
win.destroy()