8/8 실효성 평가의 결론은 "검색·기록 노동은 줄여주나 심사 건수는 전혀 못 줄인다"
였다. 이 커밋은 그 축을 직접 건드리는 첫 기능이다.
중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능이다 — 재저장(JPEG q90)
/80%축소/50%축소/밝기+10% 변형 24건 중 24건 그룹핑, 같은 24건에서 SHA-256
완전일치는 0건. 그런데 그 결과를 쓰는 곳이 없었다. 서버는 store_duplicates.py
에서 priorDecisions 와 conflicting 을 계산해 내려보내는데, 콘솔은 그것을 큐
배지의 title 속성에만 넣었다. 마우스를 올린 사람만 "이거 지난주에 반려한
이미지"임을 알 수 있었고, 재제출은 처음부터 다시 심사됐다.
서버는 건드리지 않았다. 자리만 옮겼다.
큐 배지 "동일 이미지 3건 · 선례 반려" (title 이 아니라 본문에)
판정 패널 결정 메모 바로 위에 선례 배너 + [선례 승계] 1타
승계는 별도 판정 경로를 만들지 않는다. 메모를 채워 기존 setDecision 을 그대로
타므로 반려 메모 필수 검사 · 판정 시점 스냅샷 · 감사 이벤트가 유지되고, 승계
사실은 메모로 감사에 남는다("선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은
판정").
판정 불일치는 승계 대상이 아니다. 같은 이미지에 서로 다른 판정이 있다는 것은
자동 처리할 상황이 아니라 사람이 봐야 하는 신호이므로, conflicting 이면 버튼을
아예 내지 않고 색을 갈라 표시한다.
실렌더 확인(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
세 상태(승계 가능 / 판정 불일치 / 선례 없음)의 배너 문구와 버튼 유무가 의도대로,
승계 클릭 시 POST 본문이 위 메모 그대로, 콘솔 에러 0, 가로 오버플로 없음.
검증: ruff clean, pytest 622 passed (정적 3건 추가).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
1008 lines
37 KiB
Python
1008 lines
37 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
|
||
# The chip is capped at 20ch, so the full host has to be legible somewhere:
|
||
# two CDN hosts sharing a 20-character prefix are identical on screen.
|
||
assert 'title="게시 호스트 ${escapeHtml(hostName)}"' in script
|
||
# The same candidate list as _result_host in store_serialization.py, so a
|
||
# legacy row whose only host-bearing field is bloggerLink still resolves.
|
||
assert "evidence.bloggerLink," in script
|
||
# The chips beside the title do not shrink, so the row wraps instead of
|
||
# breaking the headline one character per line at phone width.
|
||
assert "min-width: 14ch;" 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
|
||
|
||
|
||
def test_operator_gui_explains_a_retained_case_whose_source_file_is_gone():
|
||
# The reload sweep keeps a decided case after its image has been archived out
|
||
# of the queue folder. Nothing told the operator: the console rendered a
|
||
# broken <img> and "file archived after the decision" was indistinguishable
|
||
# from "upload corrupted".
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "assetMissing" in script
|
||
assert "MISSING_ASSET_NOTE" in script
|
||
assert "원본 파일 없음" in script
|
||
assert ".thumb-missing" in styles
|
||
def test_operator_gui_surfaces_a_prior_decision_on_the_same_image():
|
||
# 중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능인데, 그 결과인
|
||
# priorDecisions 는 큐 배지의 title 속성에만 있었다 — 마우스를 올린 사람만
|
||
# 볼 수 있는 자리라 재제출을 처음부터 다시 심사하는 것을 막지 못했다.
|
||
script = _read_app()
|
||
markup = _read(INDEX)
|
||
styles = _read(STYLES)
|
||
|
||
assert "priorDecisionSummary" in script
|
||
assert "setPriorDecisionBox" in script
|
||
assert 'id="prior-decision-box"' in markup
|
||
assert ".prior-decision" in styles
|
||
|
||
# 배지 본문에도 선례가 실려야 한다(title 에만 있으면 훑는 동안 안 보인다).
|
||
assert "priorNote" in script
|
||
|
||
|
||
def test_operator_gui_inherits_a_prior_decision_through_the_normal_decision_path():
|
||
# 승계가 별도 판정 경로를 만들면 반려 메모 필수 검사·판정 스냅샷·감사 이벤트를
|
||
# 우회하게 된다. setDecision 을 그대로 타야 한다.
|
||
script = _read_app()
|
||
|
||
assert "async function inheritPriorDecision" in script
|
||
assert "data-inherit-decision" in script
|
||
assert "await setDecision(decision" in script
|
||
assert "선례 승계" in script
|
||
|
||
|
||
def test_operator_gui_does_not_offer_inheritance_when_prior_decisions_conflict():
|
||
# 같은 이미지에 서로 다른 판정이 있으면 자동 제안 대상이 아니다 —
|
||
# 그때가 바로 사람이 봐야 하는 상황이다.
|
||
script = _read_app()
|
||
|
||
assert "conflicting" in script
|
||
assert "prior-decision-conflict" in script
|
||
# 만장일치일 때만 inheritable 이 채워진다.
|
||
assert "unanimous ? priors[0].decision : null" in script
|