- 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 문서 지도 갱신
611 lines
23 KiB
Python
611 lines
23 KiB
Python
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()
|