POSA_Copyrighter/tests/operator_gui/test_static_workbench.py
changukyu 847f7a852d fix: 게시 호스트를 영속화에서 복구하고 콘솔·LLM 에 노출
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), 린트 클린.
2026-08-09 08:51:12 +09:00

948 lines
34 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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