P1 3번("매칭 호스트 기반 신뢰출처 구분")을 착수하며 전제를 검증했더니 틀렸다.
호스트는 "이미 저장돼 있고 점수만 안 쓰는" 게 아니라 저장 시점에 파괴되고
있었다.
google_custom_search.py:240,296 이 실호스트(displayLink)를 data["domain"] 에
넣는데, store_serialization.py:298 이 data["provider"] 를 우선하므로 그 값이
API 이름으로 덮어써진다. Vision·Naver 는 호스트를 아예 안 넣고 URL 만 준다.
결과적으로 저장된 모든 행의 domain 이 "google" 이고, LLM 프롬프트 12줄이 전부
domain=google 이었다 — 저작권 심사에서 가장 결정적인 "어디에 떠 있나"를 모델도
운영자도 볼 수 없었다. 같은 케이스의 payload 55행 중 41행이 url 을 갖고 있는데도.
domain 필드는 재사용할 수 없다. provider 커버리지 동기화와 CSE 쿼리 시그니처
매칭이 그 값을 키로 쓰고(store_serialization.py:195, 599) 콘솔도 provider
라벨로 렌더한다. 그래서 host 를 별도 키로 신설했다.
_result_host(): data["domain"] 이 provider 와 다르면 그것을, 아니면 결과/페이지
URL 에서 호스트를 파싱한다. www 제거·소문자화. 파싱 실패는 "호스트 없음"이지
영속화 실패가 아니다.
점수에는 영향이 없다 — RiskScorer 는 domain 도 host 도 읽지 않는다. 증거 id 도
안 바뀐다: _evidence_id 는 도메인 객체의 data 를 해싱하고 payload 를 해싱하지
않으므로 payload 키 추가가 id 를 움직이지 않는다.
PROMPT_DATA_FIELDS 의 domain -> host. 화이트리스트 크기는 그대로.
콘솔은 증거 카드 제목 줄에 monospace host-chip 을 붙인다. 기존 저장 행에는 host
키가 없으므로 evidenceHost() 가 저장된 URL 에서 파생한다 — 292행 마이그레이션도,
bootstrap 마다 행당 파싱도 필요 없다(렌더되는 행만 지나간다).
실렌더 검증(127.0.0.1:9512, 실 DB)에서만 드러난 레이아웃 결함 2건을 고쳤다.
코드 리딩으로는 보이지 않았다.
1) 긴 CDN 호스트(cloudfront-ap-northeast-1.images.arcpublishing.com)가 제목 줄
폭을 다 먹어, evidence-title 의 overflow-wrap:anywhere 와 결합해 제목이 한
글자씩 세로로 깨졌다. 칩을 max-width 20ch 로 묶고 제목에 flex:1 1 auto 를 줘
남는 폭을 갖게 했다.
2) 그 수정 후에는 반대로 biz.chosun.com(14자)까지 잘렸다. 칩을 flex:0 0 auto 로
고정했다 — 제목("구글 full image match found")은 전 행이 동일한 상용구이고
호스트가 그 행의 실제 내용이므로, 들어갈 수 있는 호스트가 잘리는 쪽이 잘못이다.
최종: 칩 82개 전부 유효 호스트 형태(TLD com/edu/hk/jp/kr/net/org), 가시 칩 41개 중
잘리는 것 4개(모두 20자 초과 CDN), 콘솔 에러 0.
PROVENANCE 배점은 보류한다. 두 조건이 다 안 맞는다. (a) detect_screen_capture 의
카테고리가 geometry 와 subtitle_band 둘뿐인데 2개를 요구하므로 라벨의 실제 의미는
"geometry AND 자막"이고, 자막 없는 방송 캡처는 구조적으로 도달 불가다 — 임계를
낮추는 게 아니라 독립적인 세 번째 신호가 필요하고 그건 새 탐지기다. 한계를 실측과
함께 상수 옆에 기록했다. (b) "새 탐지 신호는 0점 라벨로 출시한 뒤 decision_stats
상관을 보고 가중치 부여"가 2026-07-25 결정이고, 판정 0건인 지금 배점하면 그 결정을
위반한다.
신뢰출처 판정 자체는 "어느 도메인이 우리 것/라이선스 계약처인가"라는 운영자
데이터가 있어야 한다. 호스트가 살아났으므로 목록만 정해지면 cleared_reference 와
같은 패턴(점수 0 + 사유 노출)으로 붙는다.
테스트 4건 추가. pytest 574 passed(착수 전 570), 린트 클린.
948 lines
34 KiB
Python
948 lines
34 KiB
Python
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
APP_DIR = ROOT / "web" / "operator-gui"
|
||
INDEX = APP_DIR / "index.html"
|
||
STYLES = APP_DIR / "styles.css"
|
||
# The former single app.js, split by concern. Order is load-bearing (classic
|
||
# scripts sharing one global scope), so this list doubles as the expectation the
|
||
# index.html ordering test asserts against.
|
||
APP_SCRIPT_NAMES = [
|
||
"app-state.js",
|
||
"app-api.js",
|
||
"app-format.js",
|
||
"app-selectors.js",
|
||
"app-render-queue.js",
|
||
"app-render-evidence.js",
|
||
"app-render-panels.js",
|
||
"app-navigation.js",
|
||
"app-keyboard-batch.js",
|
||
"app-decision-tools.js",
|
||
"app-stats.js",
|
||
"app-submissions.js",
|
||
"app-collections.js",
|
||
"app-admin.js",
|
||
"app-bootstrap.js",
|
||
]
|
||
APP_SCRIPTS = [APP_DIR / name for name in APP_SCRIPT_NAMES]
|
||
OPERATOR_LABELS_JS = APP_DIR / "operator-labels.js"
|
||
SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js"
|
||
EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.js"
|
||
OPERATOR_SEARCH_JS = APP_DIR / "operator-search.js"
|
||
PITCH = APP_DIR / "pitch.html"
|
||
PITCH_STYLES = APP_DIR / "pitch.css"
|
||
PITCH_JS = APP_DIR / "pitch.js"
|
||
UI_OVERHAUL_FINAL_RESULTS = ROOT / "data" / "logs" / "ui-overhaul-final-results.json"
|
||
UI_OVERHAUL_FINAL_SCREENSHOTS = [
|
||
"ui-overhaul-desktop-final-queue.png",
|
||
"ui-overhaul-desktop-final-workbench-queries.png",
|
||
"ui-overhaul-desktop-final-knowledge-db.png",
|
||
"ui-overhaul-desktop-final-knowledge-corrections.png",
|
||
"ui-overhaul-mobile-final-queue.png",
|
||
"ui-overhaul-mobile-final-workbench-queries.png",
|
||
"ui-overhaul-mobile-final-knowledge-db.png",
|
||
"ui-overhaul-mobile-final-knowledge-corrections.png",
|
||
]
|
||
|
||
|
||
def _read(path: Path) -> str:
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _read_app() -> str:
|
||
"""The whole operator app script, whichever part a given snippet now lives in.
|
||
|
||
These specs assert that a behavior is present in the console, not which file
|
||
holds it, so they read the parts as one text in load order.
|
||
"""
|
||
return "\n".join(_read(path) for path in APP_SCRIPTS)
|
||
|
||
|
||
def test_operator_workbench_files_exist():
|
||
assert INDEX.exists()
|
||
assert STYLES.exists()
|
||
for path in APP_SCRIPTS:
|
||
assert path.exists(), path.name
|
||
assert OPERATOR_LABELS_JS.exists()
|
||
assert SUBMISSION_IMPORT_JS.exists()
|
||
assert EVIDENCE_GUIDANCE_JS.exists()
|
||
assert OPERATOR_SEARCH_JS.exists()
|
||
|
||
|
||
def test_operator_gui_text_does_not_include_mojibake_fragments():
|
||
suspect_fragments = [
|
||
"Â",
|
||
"Ã",
|
||
"<EFBFBD>",
|
||
"ê",
|
||
"ë",
|
||
"ì",
|
||
"í",
|
||
"?",
|
||
"?¤",
|
||
"?",
|
||
"?´",
|
||
"?¸",
|
||
"?",
|
||
"?¬",
|
||
"?",
|
||
"?",
|
||
]
|
||
|
||
for path in [INDEX, *APP_SCRIPTS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
|
||
text = _read(path)
|
||
for fragment in suspect_fragments:
|
||
assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}"
|
||
|
||
|
||
def test_pitch_page_presents_copyright_review_flow_with_real_captures():
|
||
html = _read(PITCH)
|
||
styles = _read(PITCH_STYLES)
|
||
script = _read(PITCH_JS)
|
||
|
||
for path in [PITCH, PITCH_STYLES, PITCH_JS]:
|
||
assert path.exists()
|
||
|
||
for required_text in [
|
||
"이미지 저작권 심사를",
|
||
"판별 방식",
|
||
"운영 화면",
|
||
"DB 성장",
|
||
"거버넌스",
|
||
"Mermaid 원본 보기",
|
||
"실제 9500 운영 콘솔",
|
||
"Google",
|
||
"Naver",
|
||
"Ollama",
|
||
]:
|
||
assert required_text in html
|
||
|
||
for asset_name in [
|
||
"case-review.png",
|
||
"evidence-search.png",
|
||
"knowledge-db.png",
|
||
"provider-controls.png",
|
||
"risk-pipeline.svg",
|
||
"decision-loop.svg",
|
||
]:
|
||
assert (APP_DIR / "pitch-assets" / asset_name).exists()
|
||
assert f"pitch-assets/{asset_name}" in html
|
||
|
||
assert "hydrateMetrics" in script
|
||
assert "overflow-x: hidden" in styles
|
||
assert "@media (max-width: 640px)" in styles
|
||
|
||
|
||
def test_workbench_shell_exposes_all_internal_operator_views():
|
||
html = _read(INDEX)
|
||
styles = _read(STYLES)
|
||
|
||
assert "<nav" in html
|
||
assert "<main" in html
|
||
assert 'data-internal-only="true"' in html
|
||
assert 'class="product-purpose"' in html
|
||
assert 'aria-label="제품 목적"' in html
|
||
assert "이미지 저작권 위험 심사" in html
|
||
assert "제출 이미지, 외부 검색 근거, 내부 기준 DB를 한 화면에서 검토합니다." in html
|
||
assert ".product-purpose" in styles
|
||
assert 'class="operator-workflow"' in html
|
||
assert 'aria-label="운영 흐름"' in html
|
||
assert "심사 건 추가" in html
|
||
assert "근거 보강" in html
|
||
assert "운영 결정" in html
|
||
assert "사진 추가 또는 제출 폴더 불러오기" in html
|
||
assert "추천 쿼리로 외부 검색 결과를 보강합니다." in html
|
||
assert "기준 DB에 반영합니다." in html
|
||
assert ".operator-workflow" in styles
|
||
# 2026-07-24: the v1 "no bulk approve/reject" restriction was lifted (see
|
||
# docs/superpowers/specs/2026-05-25-image-rights-operator-gui-design.md).
|
||
# Batch decisions are allowed, but only with the guardrails that made lifting
|
||
# it acceptable — assert those are present rather than that the controls are.
|
||
assert 'id="bulk-approve"' in html
|
||
assert 'id="bulk-reject"' in html
|
||
assert 'id="bulk-decision-count"' in html # selection size is always visible
|
||
assert 'id="bulk-decision-status"' in html # per-case outcome is reported back
|
||
|
||
assert 'data-view="evidence"' not in html
|
||
assert 'id="evidence-view"' not in html
|
||
assert 'data-view="corrections"' not in html
|
||
assert 'id="corrections-view"' not in html
|
||
|
||
for view in [
|
||
"queue",
|
||
"workbench",
|
||
"knowledge",
|
||
"providers",
|
||
"audit",
|
||
]:
|
||
assert f'data-view="{view}"' in html
|
||
assert f'id="{view}-view"' in html
|
||
|
||
|
||
def test_static_app_models_core_review_operations():
|
||
script = _read_app()
|
||
|
||
for state_name in [
|
||
"submissions",
|
||
"providers",
|
||
"knowledgeEntries",
|
||
"collectionCandidates",
|
||
"corrections",
|
||
"auditEvents",
|
||
]:
|
||
assert state_name in script
|
||
|
||
for renderer in [
|
||
"renderQueue",
|
||
"renderCaseReview",
|
||
"renderEvidenceSearch",
|
||
"renderKnowledgeBase",
|
||
"renderCollectionCandidates",
|
||
"renderCorrections",
|
||
"renderProviderControls",
|
||
"renderAuditLog",
|
||
"renderCoverageTabs",
|
||
]:
|
||
assert f"function {renderer}" in script
|
||
|
||
for operation in [
|
||
"selectCase",
|
||
"setDecision",
|
||
"runManualSearch",
|
||
"rerunEnrichment",
|
||
"addKnowledgeEntry",
|
||
"runCandidateCollection",
|
||
"promoteCollectionCandidate",
|
||
"deactivateCorrectionEntry",
|
||
"toggleProvider",
|
||
]:
|
||
assert f"function {operation}" in script
|
||
|
||
|
||
def test_index_loads_every_app_part_exactly_once_in_declared_order():
|
||
"""The split is only safe while the load order holds.
|
||
|
||
The parts are classic scripts sharing one global scope: app-state.js declares
|
||
the state the rest read, and app-bootstrap.js is the only part that executes
|
||
anything at load. Reordering, dropping, or adding a part without updating
|
||
index.html would break the console at runtime but leave every content-level
|
||
assertion in this module green, so pin the sequence itself.
|
||
"""
|
||
html = _read(INDEX)
|
||
|
||
positions = []
|
||
for name in APP_SCRIPT_NAMES:
|
||
tag = f'<script src="{name}" defer></script>'
|
||
assert html.count(tag) == 1, f"{name} must be loaded exactly once"
|
||
positions.append(html.index(tag))
|
||
|
||
assert positions == sorted(positions), "app-*.js tags are out of declared order"
|
||
assert APP_SCRIPT_NAMES[-1] == "app-bootstrap.js", "the part that starts the app must load last"
|
||
|
||
# No part may be orphaned: every app-*.js on disk has to be wired into the page.
|
||
on_disk = sorted(path.name for path in APP_DIR.glob("app-*.js"))
|
||
assert on_disk == sorted(APP_SCRIPT_NAMES)
|
||
|
||
# The pre-split single file must be gone, or a stale copy would silently
|
||
# double-define everything if anyone re-added a tag for it.
|
||
assert not (APP_DIR / "app.js").exists()
|
||
|
||
|
||
def test_app_parts_stay_within_the_file_size_budget():
|
||
"""800 lines per file is the project's stated ceiling (coding-style rules)."""
|
||
oversized = {
|
||
path.name: len(_read(path).splitlines()) for path in APP_SCRIPTS if len(_read(path).splitlines()) > 800
|
||
}
|
||
assert not oversized, f"app parts over the 800-line budget: {oversized}"
|
||
|
||
|
||
def test_operator_gui_extracts_submission_import_helpers_from_main_app():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
labels = _read(OPERATOR_LABELS_JS)
|
||
helpers = _read(SUBMISSION_IMPORT_JS)
|
||
guidance = _read(EVIDENCE_GUIDANCE_JS)
|
||
search = _read(OPERATOR_SEARCH_JS)
|
||
|
||
first_app_part = f'src="{APP_SCRIPT_NAMES[0]}"'
|
||
assert html.index('src="operator-labels.js"') < html.index('src="submission-import.js"')
|
||
assert html.index('src="submission-import.js"') < html.index(first_app_part)
|
||
assert html.index('src="evidence-guidance.js"') < html.index(first_app_part)
|
||
assert html.index('src="operator-search.js"') < html.index(first_app_part)
|
||
assert "OperatorLabels" in labels
|
||
assert "riskLabels" in labels
|
||
assert "providerLabels" in labels
|
||
assert "} = window.OperatorLabels;" in script
|
||
assert "OperatorSubmissionImport" in helpers
|
||
assert "fileToImagePayload" in helpers
|
||
assert "importedFolderStatusMessage" in helpers
|
||
assert "importedSubmissionStatusMessage" in helpers
|
||
assert "window.OperatorSubmissionImport.fileToImagePayload(file)" in script
|
||
assert "window.OperatorSubmissionImport.importedFolderStatusMessage" in script
|
||
assert "window.OperatorSubmissionImport.importedSubmissionStatusMessage" in script
|
||
assert "OperatorEvidenceGuidance" in guidance
|
||
assert "suggestedEvidenceQueries" in guidance
|
||
assert "evidenceFollowupReasons" in guidance
|
||
assert "window.OperatorEvidenceGuidance.suggestedEvidenceQueries" in script
|
||
assert "window.OperatorEvidenceGuidance.evidenceFollowupReasons" in script
|
||
assert "OperatorSearch" in search
|
||
assert "formatQueryStatus" in search
|
||
assert "formatQueryStrategy" in search
|
||
assert "normalizeManualSearchProvider" in search
|
||
assert "window.OperatorSearch.formatQueryStatus" in script
|
||
assert "window.OperatorSearch.formatQueryStrategy" in script
|
||
assert "window.OperatorSearch.normalizeManualSearchProvider" in script
|
||
|
||
|
||
def test_operator_gui_does_not_boot_with_demo_review_data():
|
||
script = _read_app()
|
||
|
||
for collection in [
|
||
"submissions",
|
||
"providers",
|
||
"knowledgeEntries",
|
||
"collectionCandidates",
|
||
"corrections",
|
||
"auditEvents",
|
||
]:
|
||
assert f"const {collection} = [];" in script
|
||
|
||
for demo_token in [
|
||
"SUB-1007",
|
||
"ev-1007",
|
||
"kb-iu",
|
||
"DEC-0992",
|
||
]:
|
||
assert demo_token not in script
|
||
|
||
assert "clearRuntimeData()" in script
|
||
assert "renderNoSelectedCase" in script
|
||
assert "state.apiError" in script
|
||
|
||
|
||
def test_design_contract_has_accessibility_risk_states_and_responsive_layouts():
|
||
styles = _read(STYLES)
|
||
|
||
assert ":focus-visible" in styles
|
||
assert "@media (max-width: 980px)" in styles
|
||
assert "@media (max-width: 680px)" in styles
|
||
assert "linear-gradient" not in styles.lower()
|
||
assert "orb" not in styles.lower()
|
||
|
||
for class_name in [
|
||
".risk-high",
|
||
".risk-medium",
|
||
".risk-low",
|
||
".risk-failed",
|
||
".source-naver",
|
||
".source-google",
|
||
".source-llm",
|
||
".source-internal",
|
||
]:
|
||
assert class_name in styles
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
not UI_OVERHAUL_FINAL_RESULTS.exists(),
|
||
reason="local-only audit artifacts: data/ is gitignored, so fresh clones lack them; "
|
||
"regenerate with the live-server Playwright audit before release sign-off",
|
||
)
|
||
def test_ui_overhaul_final_audit_has_no_overflow_and_required_screenshots():
|
||
results = json.loads(_read(UI_OVERHAUL_FINAL_RESULTS))
|
||
|
||
for breakpoint_results in results.values():
|
||
for view_name, view_result in breakpoint_results.items():
|
||
if not isinstance(view_result, dict) or "overflow" not in view_result:
|
||
continue
|
||
|
||
assert view_result["docW"] <= view_result["vw"], view_name
|
||
assert view_result["overflow"] == [], view_name
|
||
|
||
for screenshot_name in UI_OVERHAUL_FINAL_SCREENSHOTS:
|
||
screenshot_path = UI_OVERHAUL_FINAL_RESULTS.parent / screenshot_name
|
||
assert screenshot_path.exists(), screenshot_name
|
||
assert screenshot_path.stat().st_size > 0, screenshot_name
|
||
|
||
|
||
def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
|
||
html = _read(INDEX)
|
||
styles = _read(STYLES)
|
||
script = _read_app()
|
||
|
||
assert 'id="coverage-tabs"' in html
|
||
assert 'id="search-coverage"' not in html
|
||
assert "function renderCoverageTabs" in script
|
||
assert "function applyCoverageFilter" in script
|
||
assert "data-coverage-filter" in script
|
||
assert ".coverage-tabs" in styles
|
||
assert ".coverage-tab" in styles
|
||
assert ".search-coverage" not in styles
|
||
|
||
|
||
def test_queue_uses_grid_row_contract_for_dense_evidence_and_provider_columns():
|
||
html = _read(INDEX)
|
||
styles = _read(STYLES)
|
||
script = _read_app()
|
||
|
||
assert "queue-grid" in html
|
||
assert "queue-row" in script
|
||
assert ".queue-grid" in styles
|
||
assert ".queue-row" in styles
|
||
assert "grid-template-columns: 28px 64px minmax(104px, 0.68fr) 72px minmax(126px, 0.58fr) minmax(360px, 1.5fr) 82px 76px 90px" in styles
|
||
assert "queue-submission-cell" in script
|
||
assert "queue-risk-cell" in script
|
||
assert "queue-time-cell" in script
|
||
assert "queue-title" not in script
|
||
|
||
|
||
def test_visual_overhaul_removes_obsolete_header_cards_and_fixed_table_widths():
|
||
styles = _read(STYLES)
|
||
|
||
for obsolete_selector in [
|
||
".coverage-main",
|
||
".coverage-badges",
|
||
".coverage-provider-grid",
|
||
".coverage-mini-line",
|
||
]:
|
||
assert obsolete_selector not in styles
|
||
|
||
assert ".queue-table th:nth-child" not in styles
|
||
assert ".queue-row td:nth-child(5)" in styles
|
||
assert ".queue-row td:nth-child(6)" in styles
|
||
|
||
|
||
def test_case_workbench_owns_evidence_and_query_history_as_internal_tabs():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert 'id="workbench-view"' in html
|
||
assert 'data-workbench-tab="summary"' not in html
|
||
assert 'data-workbench-tab="evidence"' in html
|
||
assert 'data-workbench-tab="queries"' in html
|
||
assert 'data-workbench-tab="decision"' not in html
|
||
assert 'data-workbench-panel="evidence"' in html
|
||
assert 'data-workbench-panel="queries"' in html
|
||
assert 'data-workbench-panel="decision"' not in html
|
||
assert "근거 및 판단" in html
|
||
assert 'id="query-history"' in html
|
||
assert 'id="search-results"' in html
|
||
assert "workbenchTab" in script
|
||
assert 'workbenchTab: "evidence"' in script
|
||
assert "function switchWorkbenchTab" in script
|
||
assert 'switchView("workbench")' in script
|
||
assert 'panelName === nextTab ||' not in script
|
||
assert ".workbench-tabs" in styles
|
||
assert ".workbench-panel" in styles
|
||
|
||
|
||
def test_case_workbench_tabs_do_not_share_the_same_review_layout():
|
||
html = _read(INDEX)
|
||
|
||
evidence_panel = html.split('data-workbench-panel="evidence"', 1)[1].split('data-workbench-panel="queries"', 1)[0]
|
||
query_panel = html.split('data-workbench-panel="queries"', 1)[1].split('id="knowledge-view"', 1)[0]
|
||
|
||
assert 'id="case-image"' in evidence_panel
|
||
assert 'id="evidence-groups"' in evidence_panel
|
||
assert evidence_panel.index('id="recommendation-box"') < evidence_panel.index('id="case-image"')
|
||
assert evidence_panel.index('id="decision-memo"') < evidence_panel.index('id="case-image"')
|
||
assert 'id="query-history"' in query_panel
|
||
|
||
|
||
def test_case_decision_controls_float_while_reviewing_evidence():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "floating-decision-panel" in html
|
||
assert 'id="floating-case-score"' in html
|
||
assert "floating-case-score" in script
|
||
assert ".floating-decision-panel" in styles
|
||
assert "position: fixed" in styles
|
||
assert "bottom: 24px" in styles
|
||
assert 'data-workbench-panel="evidence"' in styles
|
||
assert "padding-right: 334px" in styles
|
||
assert ".evidence-layout" in styles
|
||
assert "padding-bottom: 260px" in styles
|
||
assert ".floating-decision-panel .decision-actions" in styles
|
||
|
||
|
||
def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "knowledge-main" in script
|
||
assert "knowledge-chip-row" in script
|
||
assert "knowledge-detail-line" in script
|
||
assert "memoInline" in script
|
||
assert "memoBlock" in script
|
||
assert "knowledge-meta" in script
|
||
assert ".knowledge-main" in styles
|
||
assert ".knowledge-chip-row" in styles
|
||
assert ".knowledge-detail-line" in styles
|
||
assert ".knowledge-meta" in styles
|
||
assert ".knowledge-actions" in styles
|
||
assert "grid-template-areas" in styles
|
||
|
||
|
||
def test_correction_history_lives_inside_knowledge_database_panel():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert 'data-knowledge-tab="collect"' in html
|
||
assert 'data-knowledge-tab="registered"' in html
|
||
assert 'data-knowledge-tab="manual"' in html
|
||
assert 'data-knowledge-tab="corrections"' in html
|
||
assert 'data-knowledge-panel="collect"' in html
|
||
assert 'data-knowledge-panel="registered"' in html
|
||
assert 'data-knowledge-panel="manual"' in html
|
||
assert 'data-knowledge-panel="corrections"' in html
|
||
assert 'id="corrections-list"' in html
|
||
assert "knowledgeTab" in script
|
||
assert "function switchKnowledgeTab" in script
|
||
assert 'switchView("knowledge")' in script
|
||
assert 'switchKnowledgeTab("corrections")' in script
|
||
assert ".knowledge-tabs" in styles
|
||
assert ".knowledge-panel" in styles
|
||
|
||
|
||
def test_knowledge_collection_and_registered_entries_are_separate_tabs():
|
||
html = _read(INDEX)
|
||
|
||
collect_panel = html.split('data-knowledge-panel="collect"', 1)[1].split('data-knowledge-panel="registered"', 1)[0]
|
||
registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0]
|
||
manual_panel = html.split('data-knowledge-panel="manual"', 1)[1].split('data-knowledge-panel="corrections"', 1)[0]
|
||
|
||
assert 'id="candidate-collection-form"' in collect_panel
|
||
assert 'id="collection-candidates"' in collect_panel
|
||
assert 'id="knowledge-list"' not in collect_panel
|
||
assert 'id="knowledge-list"' in registered_panel
|
||
assert 'id="candidate-collection-form"' not in registered_panel
|
||
assert 'id="knowledge-form"' in manual_panel
|
||
|
||
|
||
def test_safety_rules_are_visible_in_ui_contract():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
|
||
assert 'id="decision-memo"' in html
|
||
assert 'id="manual-query-provider"' in html
|
||
# 2026-06-12 안전 규칙 개정(설계 승인 2026-06-11): 운영자 수동 검색에
|
||
# 구글 근거 검색(텍스트 쿼리)을 동적 옵션으로 노출한다. 정적 HTML 기본값은
|
||
# 네이버만 유지하고, 이미지 업로드 역검색 금지는 그대로다.
|
||
assert '{ id: "google_search", label: providerLabels.google_search }' in script
|
||
assert "네이버" in html
|
||
assert "reverse search" not in html.lower()
|
||
assert "sourceEvidenceIds" in script
|
||
assert "requiresMemo" in script
|
||
assert "automatic" in script
|
||
|
||
|
||
def test_provider_quota_zero_does_not_render_nan_meter():
|
||
script = _read_app()
|
||
|
||
assert "provider.quota ? Math.min" in script
|
||
assert "configuredEnv" in script
|
||
assert "requiredEnv" in script
|
||
|
||
|
||
def test_query_history_rerun_preserves_original_search_provider():
|
||
script = _read_app()
|
||
search = _read(OPERATOR_SEARCH_JS)
|
||
|
||
assert "data-rerun-provider" in script
|
||
assert "rerunHistoricalQuery" in script
|
||
assert "normalizeManualSearchProvider" in search
|
||
assert "window.OperatorSearch.normalizeManualSearchProvider" in script
|
||
assert 'providerSelect.value = provider' in script
|
||
assert "NaN" not in script
|
||
|
||
|
||
def test_operator_gui_exposes_submission_reload_action():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
|
||
assert 'id="submission-folder"' in html
|
||
assert 'id="submission-image"' in html
|
||
assert 'id="submission-image-name"' in html
|
||
assert 'id="queue-upload-guidance"' in html
|
||
assert 'id="upload-submission-image"' in html
|
||
assert 'id="reload-submissions"' in html
|
||
assert 'id="submission-import-status"' in html
|
||
assert "사진을 추가하면 현재 큐에 새 심사 건으로 들어가고" in html
|
||
assert "/api/submissions/reload" in script
|
||
assert "/api/submissions/import-folder" in script
|
||
assert "/api/submissions/upload-image" in script
|
||
assert "uploadSubmissionImage" in script
|
||
assert "updateSubmissionImageName" in script
|
||
assert 'switchView("workbench")' in script
|
||
assert 'switchWorkbenchTab("evidence")' in script
|
||
assert "새 심사 건으로 바로 선택했습니다." in script
|
||
assert "reloadSubmissions" in script
|
||
|
||
|
||
def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
|
||
script = _read_app() + _read(SUBMISSION_IMPORT_JS)
|
||
|
||
for required_copy in [
|
||
"제출 폴더를 읽는 중입니다.",
|
||
"추가된 제출 없음",
|
||
"건 가져옴",
|
||
"건 추가됨",
|
||
"선택됨",
|
||
"재분석할 제출이 없습니다.",
|
||
"건 재분석을 시작합니다.",
|
||
"재분석 중",
|
||
"건 재분석 완료",
|
||
"사진을 현재 제출 폴더에 넣는 중입니다.",
|
||
"사진이 추가되었습니다.",
|
||
]:
|
||
assert required_copy in script
|
||
|
||
|
||
def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
|
||
script = _read_app()
|
||
|
||
assert "function selectedBulkSubmissionIds" in script
|
||
assert 'input[data-bulk-id]:checked' in script
|
||
assert "function rerunSelectedEnrichment" in script
|
||
assert 'document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment)' in script
|
||
|
||
|
||
def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
|
||
assert 'id="knowledge-image"' in html
|
||
assert 'id="knowledge-image-name"' in html
|
||
assert 'type="file"' in html
|
||
assert 'accept="image/*"' in html
|
||
assert "updateKnowledgeImageName" in script
|
||
assert "readKnowledgeImage" in script
|
||
assert "/api/knowledge/manual" in script
|
||
assert "imageAsset" in script
|
||
assert "sampleFingerprints" in script
|
||
|
||
|
||
def test_operator_gui_exposes_keyword_candidate_collection_workflow():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
search = _read(OPERATOR_SEARCH_JS)
|
||
|
||
assert 'id="candidate-collection-form"' in html
|
||
assert 'id="collection-query"' in html
|
||
assert 'id="collection-provider"' in html
|
||
assert 'option value="google_search"' not in html
|
||
assert 'id="collection-candidates"' in html
|
||
assert 'id="collection-status"' in html
|
||
assert 'id="select-all-candidates"' in html
|
||
assert 'id="clear-selected-candidates"' in html
|
||
assert "/api/collections/keyword" in script
|
||
assert "/api/collections/candidates/" in script
|
||
assert "/api/collections/candidates/promote-batch" in script
|
||
assert "바로 편입" in script
|
||
assert "선택 후보 묶어서 편입" in html
|
||
assert 'id="collection-promotion-form"' in html
|
||
assert 'id="collection-promotion-name"' in html
|
||
assert 'id="collection-promotion-type"' in html
|
||
assert 'id="collection-promotion-aliases"' in html
|
||
assert 'id="collection-promotion-keywords"' in html
|
||
assert 'id="collection-promotion-memo"' in html
|
||
assert 'id="promote-selected-candidates"' in html
|
||
assert "selectedCollectionCandidateIds" in script
|
||
assert "setAllCollectionCandidateSelection" in script
|
||
assert "promoteSelectedCollectionCandidates" in script
|
||
assert "clearCollectionCandidatesForSearch" in script
|
||
assert "clearCollectionCandidatesAfterAction" in script
|
||
assert "dismissCollectionCandidate" in script
|
||
assert "currentCollectionQuery" in script
|
||
assert "visibleCollectionCandidates" in script
|
||
assert "data-collection-candidate-id" in script
|
||
assert "data-promote-candidate" in script
|
||
assert "data-dismiss-candidate" in script
|
||
assert "candidate-actions" in script
|
||
assert ".candidate-actions" in _read(STYLES)
|
||
assert "candidate-card" in script
|
||
assert "sourceClass(candidate.provider)" in script
|
||
assert "formatCandidateSourceType" in script
|
||
assert "검색 이미지 결과" in script
|
||
assert "페이지 대표 이미지" in script
|
||
assert "formatQueryStrategy" in search
|
||
assert "window.OperatorSearch.formatQueryStrategy" in script
|
||
assert "구글 페이지 제목 기반" in search
|
||
assert "제출 제목/파일명 기반" in search
|
||
|
||
|
||
def test_google_custom_search_is_exposed_as_operator_text_query_choice():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
search = _read(OPERATOR_SEARCH_JS)
|
||
labels = _read(OPERATOR_LABELS_JS)
|
||
|
||
# 정적 HTML은 네이버 기본값만 두고 부트스트랩 후 동적으로 채운다.
|
||
assert 'value="google_search"' not in html
|
||
assert '{ id: "google_search", label: providerLabels.google_search }' in script
|
||
assert "구글 근거 검색" in labels
|
||
assert 'provider === "google_search" ? "google_search" : "naver"' in search
|
||
assert "reverse search" not in html.lower()
|
||
assert "operatorSearchProviders" in script
|
||
assert "visibleProviderControls" in script
|
||
# 검색 선택지뿐 아니라 제공자 카드/상태 칩에서도 google_search가 보여야 한다.
|
||
assert "retiredProviderIds = new Set()" in script
|
||
|
||
|
||
def test_audit_target_and_change_columns_have_equal_widths():
|
||
styles = _read(STYLES)
|
||
|
||
assert "--audit-object-width: 24%" in styles
|
||
assert ".audit-table th:nth-child(4)" in styles
|
||
assert ".audit-table th:nth-child(5)" in styles
|
||
assert "width: var(--audit-object-width)" in styles
|
||
|
||
|
||
def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column():
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "provider-strip" in script
|
||
assert "queue-provider-chip" in script
|
||
assert "formatQueueProviderStatus" in script
|
||
assert "근거 있음" in script
|
||
assert "결과 없음" in script
|
||
assert "미실행" in script
|
||
assert ".queue-provider-strip" in styles
|
||
assert "flex-wrap: nowrap" in styles
|
||
assert ".queue-row td:nth-child(6)" in styles
|
||
assert "white-space: nowrap" in styles
|
||
assert "grid-template-columns: 28px 64px minmax(104px, 0.68fr) 72px minmax(126px, 0.58fr) minmax(360px, 1.5fr) 82px 76px 90px" in styles
|
||
|
||
|
||
def test_evidence_operator_status_actions_are_binary_use_or_ignore():
|
||
script = _read_app()
|
||
|
||
assert '["used_for_judgment", "사용"]' in script
|
||
assert '["ignored", "미사용"]' in script
|
||
assert '["irrelevant",' not in script
|
||
assert '["false_positive",' not in script
|
||
assert '["pending",' not in script
|
||
assert "normalizeEvidenceOperatorStatus" in script
|
||
|
||
|
||
def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
|
||
for english_label in [
|
||
"Image Rights Operator Console",
|
||
"Review Queue",
|
||
"Case Review",
|
||
"Evidence Search",
|
||
"Knowledge Base",
|
||
"Provider Controls",
|
||
"Audit Log",
|
||
"Automated Recommendation",
|
||
"Fingerprint match",
|
||
"Face/person",
|
||
"LLM summary",
|
||
"Provider failure",
|
||
"Google Web Detection</option>",
|
||
]:
|
||
assert english_label not in html
|
||
|
||
assert "권리 검수 콘솔" in html
|
||
assert "심사 대기열" in html
|
||
assert "얼굴/인물 감지" in html
|
||
assert "공급자" not in html
|
||
assert "외부 검색 tool 활용" in html
|
||
assert "공급자" not in script
|
||
assert "외부 검색 tool" in script
|
||
assert "formatEvidenceTitle" in script
|
||
assert "formatReason" in script
|
||
assert "동일인 판정이 아닙니다" in script
|
||
assert "신뢰도" in script
|
||
assert "근거 ID" in script
|
||
assert "샘플 지문" in script
|
||
|
||
|
||
def test_operator_gui_shows_the_publishing_host_on_every_evidence_row():
|
||
# "Where is this published?" is the fact an operator scans an evidence list
|
||
# for. It was unavailable: the only host-ish field on the row rendered the
|
||
# API name ("google") for every result.
|
||
script = _read_app()
|
||
styles = _read(APP_DIR / "styles.css")
|
||
|
||
assert "evidenceHost" in script
|
||
assert "host-chip" in script
|
||
assert ".host-chip" in styles
|
||
|
||
|
||
def test_operator_gui_renders_search_result_links_and_thumbnails():
|
||
script = _read_app()
|
||
|
||
assert "renderEvidenceLink" in script
|
||
assert "thumbnailUrl" in script
|
||
assert "matchType" in script
|
||
assert "evidence-preview" in script
|
||
assert "search_result_image" in script
|
||
assert "search_result_page_image" in script
|
||
assert "Naver blog search result found" in script
|
||
assert "naver_blog" in script
|
||
assert "Naver web search result found" in script
|
||
assert "naver_web" in script
|
||
assert "Google custom image search result found" in script
|
||
assert "Google custom web search result found" in script
|
||
assert "google_search" in script
|
||
assert "google_best_guess" in script
|
||
assert "google_face_crop_page" in script
|
||
assert "google_face_crop_entity" in script
|
||
assert "searchType" in script
|
||
assert "이미지 유사도" in script
|
||
|
||
|
||
def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "renderEvidenceSummary" in script
|
||
assert "renderEvidenceGroup" in script
|
||
assert "topItems" in script
|
||
assert "자세히 보기" in script
|
||
assert "renderEvidenceGroups({ ...submission, evidence: searchableEvidence })" in script
|
||
assert "evidence-summary-board" in script
|
||
assert ".evidence-card-grid" in styles
|
||
assert ".evidence-details" in styles
|
||
|
||
|
||
def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
guidance = _read(EVIDENCE_GUIDANCE_JS)
|
||
combined_script = script + guidance
|
||
styles = _read(STYLES)
|
||
|
||
assert 'id="evidence-next-actions"' in html
|
||
assert "function evidenceNeedsFollowup" in combined_script
|
||
assert "function evidenceFollowupReasons" in combined_script
|
||
assert "function suggestedEvidenceQueries" in combined_script
|
||
assert "function renderEvidenceNextActions" in script
|
||
assert "function applySuggestedQuery" in script
|
||
assert "data-suggested-query" in script
|
||
assert "evidence-followup-reasons" in script
|
||
assert "직접 매칭 또는 원문 페이지 근거가 없습니다." in guidance
|
||
assert "검색 근거가 2건 미만입니다." in guidance
|
||
assert "외부 검색 tool이 빈 결과를 반환했습니다." in guidance
|
||
assert "외부 검색 tool 실패 이력이 있습니다." in guidance
|
||
assert "switchWorkbenchTab(\"queries\")" in script
|
||
assert "/api/search/manual" in script
|
||
assert "추천 쿼리를 입력했습니다" in script
|
||
assert ".evidence-next-action-panel" in styles
|
||
assert ".evidence-followup-reasons" in styles
|
||
assert ".suggested-query-list" in styles
|
||
|
||
|
||
def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
|
||
script = _read_app()
|
||
|
||
assert "faceCropSearch" in script
|
||
assert "face_web" in script
|
||
assert "얼굴 영역 웹 근거" in script
|
||
assert "동일인 판정이 아닙니다" in script
|
||
|
||
|
||
def test_operator_gui_exposes_evidence_status_and_watchlist_controls():
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "/api/evidence/" in script
|
||
assert "data-evidence-status" in script
|
||
assert "판단에 사용" in script
|
||
assert "오탐" in script
|
||
assert "주의 후보 근거" in script
|
||
assert "knowledgeEntryStatus" in script
|
||
assert "/promote-watchlist" in script
|
||
assert "/exclude-watchlist" in script
|
||
assert "data-promote-watchlist" in script
|
||
assert "data-exclude-watchlist" in script
|
||
assert ".watchlist-chip" in styles
|
||
|
||
|
||
def test_visual_assets_are_referenced_for_review_images():
|
||
html = _read(INDEX)
|
||
assets_dir = APP_DIR / "assets"
|
||
|
||
assert (assets_dir / "case-portrait.svg").exists()
|
||
assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read_app()
|
||
|
||
for orphan_asset in [
|
||
"case-character.svg",
|
||
"case-emblem.svg",
|
||
"match-web.svg",
|
||
]:
|
||
assert not (assets_dir / orphan_asset).exists(), f"{orphan_asset} is unused; do not reintroduce it"
|
||
|
||
|
||
def test_suggested_queries_support_one_click_and_batch_execution():
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "executeSuggestedQueries" in script
|
||
assert "data-run-suggested-query" in script
|
||
assert 'id="run-all-suggested-queries"' in script
|
||
assert "모두 실행" in script
|
||
assert "바로 실행" in script
|
||
assert "suggested-query-run-status" in script
|
||
# 기존 "입력칸 채우기" 버튼은 수정용으로 유지된다.
|
||
assert "data-suggested-query" in script
|
||
assert ".suggested-query-item" in styles
|
||
|
||
|
||
def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
|
||
registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0]
|
||
assert 'id="knowledge-search"' in registered_panel
|
||
assert 'id="knowledge-type-filter"' in registered_panel
|
||
assert 'id="knowledge-status-filter"' in registered_panel
|
||
assert 'id="knowledge-active-filter"' in registered_panel
|
||
|
||
assert "filteredKnowledgeEntries" in script
|
||
assert "data-edit-kb" in script
|
||
assert "data-knowledge-edit-form" in script
|
||
assert "saveKnowledgeEntryEdit" in script
|
||
assert "data-deactivate-kb" in script
|
||
assert "data-reactivate-kb" in script
|
||
assert "재활성 사유 메모" in script
|
||
assert "data-toggle-kb" not in script
|
||
|
||
|
||
def test_workbench_shows_detected_face_crop_strip():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
|
||
assert 'id="face-crop-strip"' in html
|
||
assert "faceCrops" in script
|
||
assert "얼굴 영역" in script
|
||
# 면책 문구는 기존 증거 행과 동일한 표현을 재사용한다.
|
||
assert "동일 인물 판정은 아닙니다" in script
|
||
# 크롭 썸네일은 클릭해 원본 크롭을 크게 볼 수 있어야 한다.
|
||
assert "face-crop-link" in script
|
||
|
||
|
||
def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():
|
||
html = _read(INDEX)
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert 'id="rerun-diff-summary"' in html
|
||
assert "renderRerunDiffSummary" in script
|
||
assert "lastRerunDiff" in script
|
||
assert "evidence-new-chip" in script
|
||
assert "이번 재분석에서 제거됨" in script
|
||
assert ".evidence-new-chip" in styles
|
||
assert ".evidence-row-new" in styles
|