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:
commit
56a6e2da93
159 changed files with 145825 additions and 0 deletions
611
tests/test_ux_regressions.py
Normal file
611
tests/test_ux_regressions.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue