27730704ae
Snapshot Admin Web Validation / validate-snapshot-admin-smoke (push) Has been cancelled
Snapshot Admin Web Validation / validate-snapshot-admin-full (push) Has been cancelled
Quant Engine CI/CD Pipeline / validate-core (pull_request) Has been cancelled
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been cancelled
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Has been cancelled
467 lines
21 KiB
Python
467 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
import tools.validate_snapshot_admin_web_v1 as validator
|
|
from src.quant_engine.snapshot_admin_server_v1 import (
|
|
build_ui_state,
|
|
fetch_domain_rows,
|
|
fetch_table_rows,
|
|
list_browsable_tables,
|
|
render_collection_html,
|
|
render_index_html,
|
|
render_tables_html,
|
|
)
|
|
from src.quant_engine.snapshot_admin_store_v1 import import_seed_json
|
|
|
|
|
|
def _write_valid_seed(path: Path) -> None:
|
|
payload = {
|
|
"data": {
|
|
"settings": [
|
|
{"ordinal": 1, "key": "total_asset_krw", "value": 500000000, "note": "seed"},
|
|
{"ordinal": 2, "key": "settlement_cash_d2_krw", "value": 250000000, "note": "seed"},
|
|
],
|
|
"account_snapshot": [
|
|
{
|
|
"captured_at": "2026-06-22T11:15:47+09:00",
|
|
"account": "demo",
|
|
"account_type": "일반계좌",
|
|
"ticker": "005930",
|
|
"name": "삼성전자",
|
|
"holding_quantity": 10,
|
|
"average_cost": 70000,
|
|
"parse_status": "NOT_PROVIDED",
|
|
"position_type": "core",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
class TestSnapshotAdminWebV1(unittest.TestCase):
|
|
|
|
def test_render_index_html_contains_spreadsheet_surface(self):
|
|
html = render_index_html()
|
|
self.assertIn("Snapshot Admin", html)
|
|
self.assertIn("contenteditable", html)
|
|
self.assertIn("/api/settings/save", html)
|
|
self.assertIn("/api/account_snapshot/save", html)
|
|
self.assertIn("opsBanner", html)
|
|
self.assertIn("bannerApprovalSummary", html)
|
|
self.assertIn("heroNextAction", html)
|
|
self.assertIn("heroPrimaryAction", html)
|
|
self.assertIn("heroCollection", html)
|
|
self.assertIn("approvalPanel", html)
|
|
self.assertIn("collectionPanel", html)
|
|
self.assertIn("selectionPanel", html)
|
|
self.assertIn("open when you need row-level operations", html)
|
|
self.assertIn("open", html)
|
|
self.assertIn("snapshot-panel", html)
|
|
self.assertIn("selected-field", html)
|
|
self.assertIn("settingsCountChip", html)
|
|
self.assertIn("snapshotCountChip", html)
|
|
self.assertIn("Lock target", html)
|
|
self.assertIn("Lock row", html)
|
|
self.assertIn("Approve pending", html)
|
|
self.assertIn("Refresh diff", html)
|
|
self.assertIn("Export approval packet", html)
|
|
self.assertIn("Selection Inspector", html)
|
|
self.assertIn("Recent row history", html)
|
|
self.assertIn("Recent change summary", html)
|
|
self.assertIn("Save view", html)
|
|
self.assertIn("Apply TSV to selection", html)
|
|
self.assertIn("Ctrl+S", html)
|
|
self.assertIn("KIS Collection", html)
|
|
self.assertIn("Recent collector snapshots", html)
|
|
self.assertIn("Collection detail", html)
|
|
self.assertIn("Filter runs / snapshots / errors", html)
|
|
self.assertIn("Filter change log", html)
|
|
self.assertIn("Timeline", html)
|
|
self.assertIn("/collection", html)
|
|
self.assertIn("Open collection dashboard", html)
|
|
|
|
def test_render_home_html_contains_role_based_entrances(self):
|
|
from src.quant_engine.snapshot_admin_server_v1 import render_home_html
|
|
|
|
html = render_home_html()
|
|
self.assertIn("Snapshot Admin Home", html)
|
|
self.assertIn("1. Workspace", html)
|
|
self.assertIn("2. Collection", html)
|
|
self.assertIn("3. Tables", html)
|
|
self.assertIn("Open workspace", html)
|
|
self.assertIn("Open collection", html)
|
|
self.assertIn("Open tables", html)
|
|
self.assertIn("/workspace", html)
|
|
self.assertIn("/tables", html)
|
|
|
|
def test_render_tables_html_contains_table_group_summary(self):
|
|
html = render_tables_html()
|
|
self.assertIn("Snapshot Admin — Table Browser", html)
|
|
self.assertIn("DB별 수정, JSON별 검토, 수집 증빙 확인", html)
|
|
self.assertIn("DB 먼저 / JSON은 증빙", html)
|
|
self.assertIn("tablePurposeNavigator", html)
|
|
self.assertIn("DB 먼저", html)
|
|
self.assertIn("JSON은 증빙", html)
|
|
self.assertIn("Edit only canonical rows", html)
|
|
self.assertIn("DB tables", html)
|
|
self.assertIn("Editable source of truth.", html)
|
|
self.assertIn("Workbook sheets", html)
|
|
self.assertIn("Derived report evidence.", html)
|
|
self.assertIn("tablesVersionTop", html)
|
|
self.assertIn("tablesVersionBottom", html)
|
|
self.assertIn("final_updated_at=", html)
|
|
self.assertIn("tablesCoverageWarning", html)
|
|
self.assertIn("workbookSheetSelect", html)
|
|
self.assertIn("Workbook sheets only", html)
|
|
self.assertIn("workbookSheetMeta", html)
|
|
self.assertIn("workbookSheetSurfaceMeta", html)
|
|
self.assertIn("workbookSheetDetail", html)
|
|
self.assertIn("workbookSheetPreview", html)
|
|
self.assertIn("tableSelect", html)
|
|
self.assertIn("DB Table", html)
|
|
self.assertIn("tableGroupSummary", html)
|
|
self.assertIn("tableSourceSummary", html)
|
|
self.assertIn("Registry details", html)
|
|
self.assertIn("History details", html)
|
|
self.assertIn("Workspace", html)
|
|
self.assertIn("Collection", html)
|
|
self.assertIn("Strategy", html)
|
|
self.assertIn("JSON", html)
|
|
self.assertIn("tableWorkspaceSection", html)
|
|
self.assertIn("tableJsonSection", html)
|
|
self.assertIn("tableEditState", html)
|
|
self.assertIn("bg-danger-lt", html)
|
|
self.assertIn("Save applies only to the canonical workspace DB.", html)
|
|
self.assertIn("Derived JSON Evidence Preview", html)
|
|
self.assertIn("jsonReportStatus", html)
|
|
self.assertIn("jsonReportFocus", html)
|
|
self.assertIn("jsonReportStats", html)
|
|
self.assertIn("jsonReportDetail", html)
|
|
self.assertIn("Purpose: edit canonical workspace rows only.", html)
|
|
self.assertIn("Collection purpose: monitor collector runs and snapshots.", html)
|
|
self.assertIn("Latest run summary loads from `/api/state`.", html)
|
|
self.assertIn("Purpose: inspect DB-backed JSON evidence and row payloads.", html)
|
|
self.assertIn("Workspace tables are editable only when the table is in the canonical workspace DB.", html)
|
|
self.assertIn("DB별 / JSON별 조회 기준", html)
|
|
self.assertIn("This page is intentionally a triage surface, not a generic table dump.", html)
|
|
self.assertIn("read-only because this table belongs to collector or strategy storage", html)
|
|
self.assertIn("Table Browser", html)
|
|
self.assertIn("Save changes", html)
|
|
self.assertIn("Clear filters", html)
|
|
self.assertIn("• current", html)
|
|
self.assertIn("workbookRegistrySummary", html)
|
|
self.assertIn("dbRegistryBody", html)
|
|
self.assertIn("Derived report registry", html)
|
|
self.assertIn("DB tables", html)
|
|
self.assertIn("workbookPurposeFilters", html)
|
|
self.assertIn("Recorded surface", html)
|
|
self.assertIn("History details", html)
|
|
self.assertIn("historyChangeBody", html)
|
|
self.assertIn("historyRunBody", html)
|
|
self.assertIn("Derived JSON evidence view", html)
|
|
self.assertIn("Derived JSON Evidence Preview", html)
|
|
self.assertIn("JSON evidence", html)
|
|
|
|
def test_render_tables_html_exposes_workbook_registry_surface(self):
|
|
html = render_tables_html()
|
|
self.assertIn("Workbook sheets", html)
|
|
self.assertIn("XLSX:", html)
|
|
self.assertIn("JSON evidence:", html)
|
|
self.assertIn("JSON role:", html)
|
|
self.assertIn("Unmapped:", html)
|
|
self.assertIn("Purpose filter:", html)
|
|
self.assertIn("destination:", html)
|
|
self.assertIn("recorded surface:", html)
|
|
self.assertIn("Selected sheet:", html)
|
|
self.assertIn("surface:", html)
|
|
self.assertIn("version=", html)
|
|
self.assertIn("No workbook sheet selected.", html)
|
|
self.assertIn("tableSelect", html)
|
|
self.assertIn("DB tables only", html)
|
|
self.assertIn("Registry details", html)
|
|
|
|
def test_workbook_registry_maps_xlsx_sheets_to_recording_surfaces(self):
|
|
from src.quant_engine.snapshot_admin_server_v1 import load_workbook_sheet_registry
|
|
|
|
registry = load_workbook_sheet_registry()
|
|
self.assertEqual(registry["sheet_count"], 19)
|
|
entries = {row["sheet"]: row for row in registry["entries"]}
|
|
self.assertEqual(entries["settings"]["kind"], "workspace_db")
|
|
self.assertEqual(entries["account_snapshot"]["kind"], "workspace_db")
|
|
self.assertEqual(entries["data_feed"]["kind"], "collector_db")
|
|
self.assertEqual(entries["settings"]["purpose"], "workspace_edit")
|
|
self.assertEqual(entries["data_feed"]["purpose"], "collector_run")
|
|
self.assertEqual(registry["json_role"], "derived_report_evidence")
|
|
for sheet in [
|
|
"sector_universe_refresh_audit",
|
|
"daily_history",
|
|
"event_calendar",
|
|
"pa1_feedback",
|
|
"alpha_history",
|
|
"backdata_feature_bank",
|
|
"sell_priority",
|
|
"harness_context",
|
|
"monthly_history",
|
|
"sector_flow_history",
|
|
"sector_universe",
|
|
"core_satellite",
|
|
"universe",
|
|
"event_risk",
|
|
"macro",
|
|
"sector_flow",
|
|
]:
|
|
self.assertEqual(entries[sheet]["kind"], "json_payload")
|
|
self.assertEqual(entries[sheet]["destination"], "GatherTradingData.json")
|
|
self.assertEqual(entries[sheet]["source_role"], "derived_report_evidence")
|
|
self.assertEqual(entries["sector_universe_refresh_audit"]["kind"], "json_payload")
|
|
self.assertEqual(entries["daily_history"]["kind"], "json_payload")
|
|
self.assertEqual(entries["sector_universe_refresh_audit"]["purpose"], "refresh_audit")
|
|
self.assertEqual(entries["daily_history"]["purpose"], "history_ledger")
|
|
self.assertEqual(entries["event_calendar"]["purpose"], "audit_history")
|
|
self.assertEqual(entries["alpha_history"]["purpose"], "analysis_report")
|
|
self.assertEqual(entries["harness_context"]["purpose"], "execution_context")
|
|
self.assertEqual(entries["monthly_history"]["purpose"], "history_ledger")
|
|
self.assertEqual(entries["sector_universe"]["purpose"], "universe_registry")
|
|
self.assertEqual(entries["event_risk"]["purpose"], "macro_risk_context")
|
|
self.assertEqual(entries["sector_flow"]["purpose"], "flow_leadership")
|
|
self.assertNotIn("sector_universe_refresh_audit", registry["unmapped_sheets"])
|
|
self.assertNotIn("daily_history", registry["unmapped_sheets"])
|
|
|
|
def test_render_collection_html_contains_dashboard_surface(self):
|
|
html = render_collection_html()
|
|
self.assertIn("KIS Collection Dashboard", html)
|
|
self.assertIn("/api/state", html)
|
|
self.assertIn("/api/collection/run", html)
|
|
self.assertIn("Collect now", html)
|
|
self.assertIn("collectionModeInput", html)
|
|
self.assertIn("collectionAccountInput", html)
|
|
self.assertIn("collectionRunLog", html)
|
|
self.assertIn("collectionRunBanner", html)
|
|
self.assertIn("collectionModeBadge", html)
|
|
self.assertIn("collectionLiveStatus", html)
|
|
self.assertIn("collectionProgressChip", html)
|
|
self.assertIn("collectionStageChip", html)
|
|
self.assertIn("collectionResultChip", html)
|
|
self.assertIn("collectionTrendSummary", html)
|
|
self.assertIn("collectionTrendChart", html)
|
|
self.assertIn("Auto refreshes every 15 seconds", html)
|
|
self.assertIn("older → newer", html)
|
|
self.assertIn("Snapshots / run", html)
|
|
self.assertIn("Errors / run", html)
|
|
self.assertIn("[RUN]", html)
|
|
self.assertIn("[SNAPSHOT]", html)
|
|
self.assertIn("[ERROR]", html)
|
|
self.assertIn("live source: active | KIS rows=", html)
|
|
self.assertIn("collectionDetailAnchor", html)
|
|
self.assertIn("collectionRunLogAnchor", html)
|
|
self.assertIn("Live KIS on", html)
|
|
self.assertIn("live source: unknown", html)
|
|
self.assertNotIn('value="mock"', html)
|
|
self.assertIn("Download raw JSON", html)
|
|
self.assertIn("Download CSV", html)
|
|
self.assertIn("Filter runs / snapshots / errors", html)
|
|
self.assertIn("Unified activity timeline", html)
|
|
self.assertIn("date", html)
|
|
self.assertIn("Ticker quick search", html)
|
|
self.assertIn("Date quick search", html)
|
|
|
|
def test_run_collection_job_returns_progress_payload(self):
|
|
import tempfile
|
|
import shutil
|
|
from src.quant_engine.snapshot_admin_server_v1 import KIS_COLLECTION_DB, KIS_COLLECTION_REPORT, run_collection_job
|
|
|
|
tmp_dir = tempfile.mkdtemp()
|
|
try:
|
|
sqlite_db = Path(tmp_dir) / "kis_data_collection.db"
|
|
output_json = Path(tmp_dir) / "kis_data_collection_v1.json"
|
|
output_json.write_text(
|
|
json.dumps(
|
|
{
|
|
"generated_at": "2026-06-24T10:00:00+09:00",
|
|
"row_count": 1,
|
|
"source_counts": {"kis_open_api": 1},
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
fake_proc = Mock(returncode=0, stdout="ok", stderr="")
|
|
with patch("src.quant_engine.snapshot_admin_server_v1.subprocess.run", return_value=fake_proc) as run_mock:
|
|
payload = run_collection_job(
|
|
sqlite_db=sqlite_db,
|
|
input_json=Path(tmp_dir) / "GatherTradingData.json",
|
|
output_json=output_json,
|
|
kis_account="real",
|
|
include_live_kis=True,
|
|
allow_naver_fallback=False,
|
|
)
|
|
|
|
self.assertEqual(payload["status"], "PASS")
|
|
self.assertEqual(payload["returncode"], 0)
|
|
self.assertEqual(payload["stdout"], "ok")
|
|
self.assertIn("started_at", payload)
|
|
self.assertIn("finished_at", payload)
|
|
self.assertIn("elapsed_ms", payload)
|
|
self.assertEqual(payload["summary"]["row_count"], 1)
|
|
self.assertEqual(payload["summary"]["source_counts"]["kis_open_api"], 1)
|
|
self.assertEqual(payload["state"]["db_path"], str(sqlite_db))
|
|
run_mock.assert_called_once()
|
|
self.assertTrue(str(KIS_COLLECTION_DB).endswith("kis_data_collection.db"))
|
|
self.assertTrue(str(KIS_COLLECTION_REPORT).endswith("kis_data_collection_v1.json"))
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
def test_build_ui_state_exposes_expected_columns(self):
|
|
import tempfile
|
|
import shutil
|
|
tmp_dir = tempfile.mkdtemp()
|
|
try:
|
|
db_path = Path(tmp_dir) / "snapshot_admin.db"
|
|
seed_path = Path(tmp_dir) / "valid_seed.json"
|
|
_write_valid_seed(seed_path)
|
|
import_seed_json(db_path, seed_path)
|
|
|
|
state = build_ui_state(db_path)
|
|
self.assertTrue(state["summary"]["settings_rows"] > 0)
|
|
self.assertTrue(state["summary"]["account_snapshot_rows"] > 0)
|
|
self.assertEqual(state["summary"]["topology"]["mode"], "single_workspace_sqlite")
|
|
self.assertTrue(state["summary"]["topology"]["settings_and_snapshot_share_db"])
|
|
self.assertTrue(state["summary"]["topology"]["collector_separate_db"])
|
|
self.assertEqual(state["account_snapshot_columns"][0], "captured_at")
|
|
self.assertIn("settings", state["validation"])
|
|
self.assertTrue(state["version"]["app"])
|
|
self.assertIn("fingerprint", state["version"]["source"])
|
|
self.assertIn("collection", state)
|
|
self.assertIn("counts", state["collection"])
|
|
self.assertIn("latest_report", state["collection"])
|
|
self.assertEqual(state["summary"]["topology"]["mode"], "single_workspace_sqlite")
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
def test_snapshot_admin_workflow_and_script_exist(self):
|
|
workflow = ROOT / ".gitea" / "workflows" / "snapshot_admin.yml"
|
|
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
|
|
self.assertTrue(workflow.exists())
|
|
self.assertIn("--reload", package["scripts"]["ops:snapshot-web"])
|
|
self.assertIn("--reload", package["scripts"]["ops:snapshot-web-watch"])
|
|
self.assertIn("ops:snapshot-validate", package["scripts"])
|
|
self.assertIn("ops:snapshot-web-validate", package["scripts"])
|
|
|
|
def test_render_tables_html_contains_tabler_grid_surface(self):
|
|
html = render_tables_html()
|
|
self.assertIn("tabler", html.lower())
|
|
self.assertIn("tableSelect", html)
|
|
self.assertIn("/api/tables", html)
|
|
self.assertIn("/api/table_rows", html)
|
|
self.assertIn("/api/domain_rows", html)
|
|
self.assertIn("saveCurrentTable", html)
|
|
self.assertIn("gridTable", html)
|
|
self.assertIn("gridFilter", html)
|
|
self.assertIn("gridFilterRow", html)
|
|
self.assertIn("Clear filters", html)
|
|
self.assertIn("tableBannerDetail", html)
|
|
|
|
def test_list_browsable_tables_covers_all_three_databases(self):
|
|
import tempfile
|
|
import shutil
|
|
tmp_dir = tempfile.mkdtemp()
|
|
try:
|
|
db_path = Path(tmp_dir) / "snapshot_admin.db"
|
|
seed_path = Path(tmp_dir) / "valid_seed.json"
|
|
_write_valid_seed(seed_path)
|
|
import_seed_json(db_path, seed_path)
|
|
|
|
tables = list_browsable_tables(db_path)
|
|
names = {row["table"] for row in tables}
|
|
self.assertEqual(tables[0]["table"], "account_snapshot")
|
|
self.assertEqual(tables[1]["table"], "settings")
|
|
self.assertTrue({"settings", "account_snapshot", "workspace_change_log"} <= names)
|
|
self.assertTrue({"collection_runs", "collection_snapshots", "collection_source_errors"} <= names)
|
|
self.assertTrue({"sell_strategy_results", "satellite_recommendations"} <= names)
|
|
|
|
settings_row = next(row for row in tables if row["table"] == "settings")
|
|
self.assertTrue(settings_row["exists"])
|
|
self.assertTrue(settings_row["row_count"] > 0)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
def test_fetch_table_rows_paginates_and_rejects_unknown_table(self):
|
|
import tempfile
|
|
import shutil
|
|
tmp_dir = tempfile.mkdtemp()
|
|
try:
|
|
db_path = Path(tmp_dir) / "snapshot_admin.db"
|
|
seed_path = Path(tmp_dir) / "valid_seed.json"
|
|
_write_valid_seed(seed_path)
|
|
import_seed_json(db_path, seed_path)
|
|
|
|
page1 = fetch_table_rows("settings", db_path, limit=2, offset=0)
|
|
self.assertTrue(page1["columns"])
|
|
self.assertEqual(len(page1["rows"]), 2)
|
|
self.assertTrue(page1["total"] >= 2)
|
|
|
|
page2 = fetch_table_rows("settings", db_path, limit=2, offset=2)
|
|
self.assertNotEqual(page1["rows"], page2["rows"])
|
|
|
|
filtered = fetch_table_rows("settings", db_path, limit=50, offset=0, filter_text="total_asset_krw")
|
|
self.assertEqual(filtered["total"], 1)
|
|
self.assertEqual(len(filtered["rows"]), 1)
|
|
|
|
with self.assertRaises(ValueError):
|
|
fetch_table_rows("settings; DROP TABLE settings;--", db_path)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
def test_fetch_domain_rows_exposes_editable_tables(self):
|
|
import tempfile
|
|
import shutil
|
|
tmp_dir = tempfile.mkdtemp()
|
|
try:
|
|
db_path = Path(tmp_dir) / "snapshot_admin.db"
|
|
seed_path = Path(tmp_dir) / "valid_seed.json"
|
|
_write_valid_seed(seed_path)
|
|
import_seed_json(db_path, seed_path)
|
|
|
|
settings = fetch_domain_rows("settings", db_path)
|
|
snapshot = fetch_domain_rows("account_snapshot", db_path)
|
|
self.assertEqual(settings["domain"], "settings")
|
|
self.assertTrue(settings["rows"])
|
|
self.assertEqual(snapshot["domain"], "account_snapshot")
|
|
self.assertTrue(snapshot["rows"])
|
|
|
|
with self.assertRaises(ValueError):
|
|
fetch_domain_rows("workspace_change_log", db_path)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
|
|
def test_snapshot_admin_web_validation_script_passes(self):
|
|
out = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json"
|
|
if out.exists():
|
|
out.unlink()
|
|
|
|
rc = validator.main()
|
|
payload = json.loads(out.read_text(encoding="utf-8"))
|
|
|
|
self.assertEqual(rc, 0)
|
|
self.assertEqual(payload["gate"], "PASS")
|
|
self.assertEqual(payload["formula_id"], "SNAPSHOT_ADMIN_WEB_VALIDATION_V1")
|
|
self.assertTrue(payload["settings_rows"] > 0)
|
|
self.assertTrue(payload["account_snapshot_rows"] > 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|