승인·보류는 A/H 한 키에 자동 다음 케이스 이동까지 되는데 반려만 메모가
필수라 흐름이 끊겼다. R 은 메모 필드에 포커스만 줬고, 입력 중에는 전역
단축키가 막히므로(J/K 가 글자로 들어가면 안 되니 의도된 동작) 거기서부터는
마우스로만 제출할 수 있었다. 저작권 심사에서 법적으로 가장 중요한 판정이
가장 느린 경로였다.
R 사유 선택 모드 (프리셋에 1~9 번호가 보인다)
1~9 그 사유로 즉시 반려 + 다음 미심사 케이스로
M 직접 입력으로 전환
Esc 취소
메모 안에서 Ctrl+Enter 자유 서술 반려 제출
화면의 번호가 곧 눌러야 할 키다. 프리셋 칩에 키 배지를 붙여 단축키를 외우지
않아도 되게 했다.
숫자는 사유 선택 모드 안에서만 의미를 갖는다. 모드 밖에서 숫자에 의미를 주면
잘못 누른 키 하나로 반려가 나간다 — 되돌릴 수 있는 동작이지만 주의목록 항목을
만들고 감사에 남으므로 사고로 일어나서는 안 된다. 가드가 숫자 분기보다 먼저
오는지를 테스트가 위치로 확인한다.
프리셋이 하나도 없으면 고를 것이 없으므로 모드를 열지 않고 예전처럼 메모
필드로 보낸다.
실렌더(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
R -> 2 {"decision":"rejected","memo":"라이선스 확인 불가로 반려"}
메모 + Ctrl+Enter {"decision":"rejected","memo":"자유 서술 사유"}
콘솔 에러 0.
검증: ruff clean, pytest 645 passed (정적 3건 추가).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
1075 lines
40 KiB
Python
1075 lines
40 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
|
||
def test_operator_gui_can_mark_a_whole_evidence_group_at_once():
|
||
# 실 DB 제출물 하나를 100 에서 35 로 내리려면 google 증거 51행을 개별로
|
||
# 눌러야 했다. 되먹임이 노동보다 비싸면 아무도 되먹이지 않는다.
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "setEvidenceGroupStatus" in script
|
||
assert "/api/evidence/status-batch" in script
|
||
assert "data-evidence-group-status" in script
|
||
assert "전부 무관" in script
|
||
assert ".evidence-group-actions" in styles
|
||
|
||
# 그룹 전체를 "오탐"으로 단정하지 않는다 - 무관이 정직한 주장이다.
|
||
assert 'data-evidence-group-status="irrelevant"' in script
|
||
def test_operator_gui_explains_how_the_score_was_reached():
|
||
# 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와
|
||
# 점수의 간극이 벌어졌다. 증거 51행이 35점이 되는 이유가 화면에 없으면
|
||
# 운영자는 숫자를 믿을 수 없고, 믿지 못하는 우선순위는 우선순위가 아니다.
|
||
script = _read_app()
|
||
markup = _read(INDEX)
|
||
styles = _read(STYLES)
|
||
|
||
assert "setScoreBreakdown" in script
|
||
assert "riskBreakdown" in script
|
||
assert 'id="score-breakdown"' in markup
|
||
assert ".score-breakdown" in styles
|
||
|
||
# 상한에 걸린 항목은 원래 몇 점이었는지가 핵심 정보다.
|
||
assert "원래" in script and "상한" in script
|
||
# 100 포화는 그 자체로 알려야 한다 - 그 구간은 서로 구별되지 않는다.
|
||
assert "score-breakdown-clamped" in script
|
||
def test_operator_gui_completes_the_rejection_path_from_the_keyboard():
|
||
# 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다 — 법적으로 가장
|
||
# 중요한 경로가 가장 느렸다. R 로 사유를 고르면 두 키로 끝난다.
|
||
script = _read_app()
|
||
markup = _read(INDEX)
|
||
styles = _read(STYLES)
|
||
|
||
assert "openRejectPrompt" in script
|
||
assert "rejectWithPreset" in script
|
||
assert "closeRejectPrompt" in script
|
||
assert 'id="reject-prompt"' in markup
|
||
assert ".reject-prompt" in styles
|
||
|
||
# 자유 서술은 메모 안에서 끝나야 한다 — 입력 중에는 전역 단축키가 막힌다.
|
||
assert 'event.key !== "Enter"' in script
|
||
assert "R : 반려 사유 선택" in script
|
||
|
||
|
||
def test_operator_gui_only_treats_digits_as_reject_reasons_inside_the_prompt():
|
||
# 모드 밖에서 숫자에 의미를 주면 잘못 누른 키 하나로 반려가 나간다.
|
||
script = _read_app()
|
||
|
||
assert "isRejectPromptOpen" in script
|
||
prompt_guard = script.index("if (isRejectPromptOpen) {")
|
||
digit_branch = script.index('if (key >= "1" && key <= "9") {')
|
||
assert prompt_guard < digit_branch, "숫자 처리는 반드시 모드 가드 안에 있어야 한다"
|
||
|
||
|
||
def test_operator_gui_numbers_the_memo_presets_to_match_the_shortcuts():
|
||
# 화면의 번호와 눌러야 할 키가 다르면 단축키는 외워야 하는 것이 된다.
|
||
script = _read_app()
|
||
styles = _read(STYLES)
|
||
|
||
assert "memo-preset-key" in script
|
||
assert ".memo-preset-key" in styles
|
||
assert "index + 1" in script
|