Refactor unit tests to remove pytest dependency and use unittest.TestCase

This commit is contained in:
2026-06-22 11:18:57 +09:00
parent dcd73de05f
commit 84df6e1f7e
9 changed files with 446 additions and 386 deletions
+1
View File
@@ -1203,6 +1203,7 @@ python tools/update_sector_universe_from_naver.py --limit 10 --apply # 원본
[x] WBS-7.11: PostgreSQL 다형적 스토어 계약 레이어 구현 (2026-06-22 완료, sqlite/psycopg2 쿼리 플레이스홀더 분기 및 트랜잭션 동적 처리 반영) [x] WBS-7.11: PostgreSQL 다형적 스토어 계약 레이어 구현 (2026-06-22 완료, sqlite/psycopg2 쿼리 플레이스홀더 분기 및 트랜잭션 동적 처리 반영)
[x] WBS-7.12: 스톱로스 정책(stop_loss_gate) Parity 단위 테스트 구축 (2026-06-22 완료, ATR 변동성 배수 및 상대약세 트리거 동등성 실증 완료) [x] WBS-7.12: 스톱로스 정책(stop_loss_gate) Parity 단위 테스트 구축 (2026-06-22 완료, ATR 변동성 배수 및 상대약세 트리거 동등성 실증 완료)
[x] WBS-7.13: 추격매수 리스크(late_chase_risk_score) Parity 단위 테스트 구축 (2026-06-22 완료, 이평선 이격도 및 거래량 미확인 돌파 동등성 실증 완료) [x] WBS-7.13: 추격매수 리스크(late_chase_risk_score) Parity 단위 테스트 구축 (2026-06-22 완료, 이평선 이격도 및 거래량 미확인 돌파 동등성 실증 완료)
[x] WBS-7.14: 결정 라우팅(routing_decision_v1) Parity 단위 테스트 구축 (2026-06-22 완료, 장중 락 다운그레이드 및 MRG 이격 차단 동등성 실증 완료)
``` ```
--- ---
@@ -123,7 +123,11 @@ findings:
classification: decision_logic classification: decision_logic
migration_action: MIGRATE_DECISIONS_ROUTING migration_action: MIGRATE_DECISIONS_ROUTING
target_file: formulas/routing_decision_v1.py target_file: formulas/routing_decision_v1.py
status: TODO status: DONE
resolved_2026_06_22: >
tests/parity/test_routing_decision_parity.py를 작성하여, GAS runRouteFlow_의
STOP_BREACH, INTRADAY_LOCK, HEAT_GATE, MEAN_REVERSION_GATE, CASH_FLOOR 등
5개 핵심 의사결정 필터링 게이트의 Python 결정 라우팅 동등성을 검증 완료함.
- id: F11 - id: F11
file: src/gas_adapter_parts/gdf_03_portfolio_gates.gs file: src/gas_adapter_parts/gdf_03_portfolio_gates.gs
+34 -31
View File
@@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
import pytest
from src.quant_engine.kis_api_client_v1 import ( from src.quant_engine.kis_api_client_v1 import (
KisCredentials, KisCredentials,
OrderEndpointBlockedError, OrderEndpointBlockedError,
@@ -30,25 +30,24 @@ FORBIDDEN_ORDER_TR_IDS = (
) )
@pytest.mark.parametrize("path", FORBIDDEN_ORDER_PATHS) class TestKisApiClientV1(unittest.TestCase):
def test_order_path_is_blocked(path: str):
with pytest.raises(OrderEndpointBlockedError): def test_order_path_is_blocked(self):
for path in FORBIDDEN_ORDER_PATHS:
with self.assertRaises(OrderEndpointBlockedError):
_assert_read_only(path, "FHKST01010100") _assert_read_only(path, "FHKST01010100")
def test_order_tr_id_is_blocked(self):
@pytest.mark.parametrize("tr_id", FORBIDDEN_ORDER_TR_IDS) for tr_id in FORBIDDEN_ORDER_TR_IDS:
def test_order_tr_id_is_blocked(tr_id: str): with self.assertRaises(OrderEndpointBlockedError):
with pytest.raises(OrderEndpointBlockedError):
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", tr_id) _assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", tr_id)
def test_known_readonly_endpoints_pass(self):
def test_known_readonly_endpoints_pass():
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100") _assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100")
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200") _assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200")
_assert_read_only("/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000") _assert_read_only("/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000")
def test_no_order_endpoint_substring_anywhere_in_kis_client_source(self):
def test_no_order_endpoint_substring_anywhere_in_kis_client_source():
"""정적 검증 — 누군가 향후 주문 함수를 추가하더라도 경로 문자열이 소스에 남으면 즉시 탐지. """정적 검증 — 누군가 향후 주문 함수를 추가하더라도 경로 문자열이 소스에 남으면 즉시 탐지.
TTTC8434R/VTTC8434R(주식잔고조회)는 FORBIDDEN_TR_ID_PREFIXES 차단목록 '데이터' TTTC8434R/VTTC8434R(주식잔고조회)는 FORBIDDEN_TR_ID_PREFIXES 차단목록 '데이터'
@@ -60,14 +59,13 @@ def test_no_order_endpoint_substring_anywhere_in_kis_client_source():
source = (ROOT / "src" / "quant_engine" / "kis_api_client_v1.py").read_text(encoding="utf-8") source = (ROOT / "src" / "quant_engine" / "kis_api_client_v1.py").read_text(encoding="utf-8")
blocklist_data_exceptions = {"TTTC8434R", "VTTC8434R"} blocklist_data_exceptions = {"TTTC8434R", "VTTC8434R"}
for forbidden_path in FORBIDDEN_ORDER_PATHS: for forbidden_path in FORBIDDEN_ORDER_PATHS:
assert forbidden_path not in source, f"주문 엔드포인트 경로가 소스에 존재함: {forbidden_path}" self.assertNotIn(forbidden_path, source, f"주문 엔드포인트 경로가 소스에 존재함: {forbidden_path}")
for forbidden_tr_id in FORBIDDEN_ORDER_TR_IDS: for forbidden_tr_id in FORBIDDEN_ORDER_TR_IDS:
if forbidden_tr_id in blocklist_data_exceptions: if forbidden_tr_id in blocklist_data_exceptions:
continue continue
assert forbidden_tr_id not in source, f"주문 TR_ID가 소스에 존재함: {forbidden_tr_id}" self.assertNotIn(forbidden_tr_id, source, f"주문 TR_ID가 소스에 존재함: {forbidden_tr_id}")
def test_kis_client_module_defines_no_order_submission_function(self):
def test_kis_client_module_defines_no_order_submission_function():
import src.quant_engine.kis_api_client_v1 as kis_module import src.quant_engine.kis_api_client_v1 as kis_module
public_names = [name for name in dir(kis_module) if not name.startswith("_")] public_names = [name for name in dir(kis_module) if not name.startswith("_")]
@@ -78,21 +76,26 @@ def test_kis_client_module_defines_no_order_submission_function():
for name in public_names: for name in public_names:
lowered = name.lower() lowered = name.lower()
for banned in banned_keywords: for banned in banned_keywords:
assert banned not in lowered, f"주문 제출/정정/취소로 의심되는 함수가 존재함: {name}" self.assertNotIn(banned, lowered, f"주문 제출/정정/취소로 의심되는 함수가 존재함: {name}")
def test_kis_credentials_load_uses_required_env_vars(monkeypatch):
monkeypatch.setenv("KIS_APP_Key", "real-key")
monkeypatch.setenv("KIS_APP_Secret", "real-secret")
monkeypatch.setenv("KIS_APP_Key_TEST", "mock-key")
monkeypatch.setenv("KIS_APP_Secret_TEST", "mock-secret")
def test_kis_credentials_load_uses_required_env_vars(self):
with patch.dict("os.environ", {
"KIS_APP_Key": "real-key",
"KIS_APP_Secret": "real-secret",
"KIS_APP_Key_TEST": "mock-key",
"KIS_APP_Secret_TEST": "mock-secret"
}):
real = KisCredentials.load("real") real = KisCredentials.load("real")
mock = KisCredentials.load("mock") mock = KisCredentials.load("mock")
assert real.app_key == "real-key" self.assertEqual(real.app_key, "real-key")
assert real.app_secret == "real-secret" self.assertEqual(real.app_secret, "real-secret")
assert real.account == "real" self.assertEqual(real.account, "real")
assert mock.app_key == "mock-key" self.assertEqual(mock.app_key, "mock-key")
assert mock.app_secret == "mock-secret" self.assertEqual(mock.app_secret, "mock-secret")
assert mock.account == "mock" self.assertEqual(mock.account, "mock")
if __name__ == "__main__":
unittest.main()
+103 -85
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import sys import sys
import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -20,116 +21,128 @@ from src.quant_engine.snapshot_admin_server_v1 import (
from src.quant_engine.snapshot_admin_store_v1 import import_seed_json from src.quant_engine.snapshot_admin_store_v1 import import_seed_json
def test_render_index_html_contains_spreadsheet_surface(): class TestSnapshotAdminWebV1(unittest.TestCase):
def test_render_index_html_contains_spreadsheet_surface(self):
html = render_index_html() html = render_index_html()
assert "Snapshot Admin" in html self.assertIn("Snapshot Admin", html)
assert "contenteditable" in html self.assertIn("contenteditable", html)
assert "/api/settings/save" in html self.assertIn("/api/settings/save", html)
assert "/api/account_snapshot/save" in html self.assertIn("/api/account_snapshot/save", html)
assert "Lock target" in html self.assertIn("Lock target", html)
assert "Lock row" in html self.assertIn("Lock row", html)
assert "Approve pending" in html self.assertIn("Approve pending", html)
assert "Refresh diff" in html self.assertIn("Refresh diff", html)
assert "Export approval packet" in html self.assertIn("Export approval packet", html)
assert "Selection Inspector" in html self.assertIn("Selection Inspector", html)
assert "Recent row history" in html self.assertIn("Recent row history", html)
assert "Save view" in html self.assertIn("Save view", html)
assert "Apply TSV to selection" in html self.assertIn("Apply TSV to selection", html)
assert "Ctrl+S" in html self.assertIn("Ctrl+S", html)
assert "KIS Collection" in html self.assertIn("KIS Collection", html)
assert "Recent collector snapshots" in html self.assertIn("Recent collector snapshots", html)
assert "Collection detail" in html self.assertIn("Collection detail", html)
assert "Filter runs / snapshots / errors" in html self.assertIn("Filter runs / snapshots / errors", html)
assert "Filter change log" in html self.assertIn("Filter change log", html)
assert "Timeline" in html self.assertIn("Timeline", html)
assert "/collection" in html self.assertIn("/collection", html)
assert "Open collection dashboard" in html self.assertIn("Open collection dashboard", html)
def test_render_collection_html_contains_dashboard_surface(self):
def test_render_collection_html_contains_dashboard_surface():
html = render_collection_html() html = render_collection_html()
assert "KIS Collection Dashboard" in html self.assertIn("KIS Collection Dashboard", html)
assert "/api/state" in html self.assertIn("/api/state", html)
assert "Download raw JSON" in html self.assertIn("Download raw JSON", html)
assert "Download CSV" in html self.assertIn("Download CSV", html)
assert "Filter runs / snapshots / errors" in html self.assertIn("Filter runs / snapshots / errors", html)
assert "Ticker quick search" in html self.assertIn("Ticker quick search", html)
assert "Date quick search" in html self.assertIn("Date quick search", html)
def test_build_ui_state_exposes_expected_columns(self):
def test_build_ui_state_exposes_expected_columns(tmp_path): import tempfile
db_path = tmp_path / "snapshot_admin.db" import shutil
tmp_dir = tempfile.mkdtemp()
try:
db_path = Path(tmp_dir) / "snapshot_admin.db"
seed_path = ROOT / "GatherTradingData.json" seed_path = ROOT / "GatherTradingData.json"
import_seed_json(db_path, seed_path) import_seed_json(db_path, seed_path)
state = build_ui_state(db_path) state = build_ui_state(db_path)
assert state["summary"]["settings_rows"] > 0 self.assertTrue(state["summary"]["settings_rows"] > 0)
assert state["summary"]["account_snapshot_rows"] > 0 self.assertTrue(state["summary"]["account_snapshot_rows"] > 0)
assert state["summary"]["topology"]["mode"] == "single_workspace_sqlite" self.assertEqual(state["summary"]["topology"]["mode"], "single_workspace_sqlite")
assert state["summary"]["topology"]["settings_and_snapshot_share_db"] is True self.assertTrue(state["summary"]["topology"]["settings_and_snapshot_share_db"])
assert state["summary"]["topology"]["collector_separate_db"] is True self.assertTrue(state["summary"]["topology"]["collector_separate_db"])
assert state["account_snapshot_columns"][0] == "captured_at" self.assertEqual(state["account_snapshot_columns"][0], "captured_at")
assert "settings" in state["validation"] self.assertIn("settings", state["validation"])
assert state["version"]["app"] self.assertTrue(state["version"]["app"])
assert "fingerprint" in state["version"]["source"] self.assertIn("fingerprint", state["version"]["source"])
assert "collection" in state self.assertIn("collection", state)
assert "counts" in state["collection"] self.assertIn("counts", state["collection"])
assert "latest_report" in state["collection"] self.assertIn("latest_report", state["collection"])
assert state["summary"]["topology"]["mode"] == "single_workspace_sqlite" 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):
def test_snapshot_admin_workflow_and_script_exist():
workflow = ROOT / ".gitea" / "workflows" / "snapshot_admin.yml" workflow = ROOT / ".gitea" / "workflows" / "snapshot_admin.yml"
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8")) package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
assert workflow.exists() self.assertTrue(workflow.exists())
assert "--reload" in package["scripts"]["ops:snapshot-web"] self.assertIn("--reload", package["scripts"]["ops:snapshot-web"])
assert "ops:snapshot-validate" in package["scripts"] self.assertIn("ops:snapshot-validate", package["scripts"])
assert "ops:snapshot-web-validate" in package["scripts"] self.assertIn("ops:snapshot-web-validate", package["scripts"])
def test_render_tables_html_contains_tabler_grid_surface(self):
def test_render_tables_html_contains_tabler_grid_surface():
html = render_tables_html() html = render_tables_html()
assert "tabler" in html.lower() self.assertIn("tabler", html.lower())
assert "tableSelect" in html self.assertIn("tableSelect", html)
assert "/api/tables" in html self.assertIn("/api/tables", html)
assert "/api/table_rows" in html self.assertIn("/api/table_rows", html)
assert "gridTable" in html self.assertIn("gridTable", html)
def test_list_browsable_tables_covers_all_three_databases(self):
def test_list_browsable_tables_covers_all_three_databases(tmp_path): import tempfile
db_path = tmp_path / "snapshot_admin.db" import shutil
tmp_dir = tempfile.mkdtemp()
try:
db_path = Path(tmp_dir) / "snapshot_admin.db"
import_seed_json(db_path, ROOT / "GatherTradingData.json") import_seed_json(db_path, ROOT / "GatherTradingData.json")
tables = list_browsable_tables(db_path) tables = list_browsable_tables(db_path)
names = {row["table"] for row in tables} names = {row["table"] for row in tables}
assert {"settings", "account_snapshot", "workspace_change_log"} <= names self.assertTrue({"settings", "account_snapshot", "workspace_change_log"} <= names)
assert {"collection_runs", "collection_snapshots", "collection_source_errors"} <= names self.assertTrue({"collection_runs", "collection_snapshots", "collection_source_errors"} <= names)
assert {"sell_strategy_results", "satellite_recommendations"} <= names self.assertTrue({"sell_strategy_results", "satellite_recommendations"} <= names)
settings_row = next(row for row in tables if row["table"] == "settings") settings_row = next(row for row in tables if row["table"] == "settings")
assert settings_row["exists"] is True self.assertTrue(settings_row["exists"])
assert settings_row["row_count"] > 0 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):
def test_fetch_table_rows_paginates_and_rejects_unknown_table(tmp_path): import tempfile
db_path = tmp_path / "snapshot_admin.db" import shutil
tmp_dir = tempfile.mkdtemp()
try:
db_path = Path(tmp_dir) / "snapshot_admin.db"
import_seed_json(db_path, ROOT / "GatherTradingData.json") import_seed_json(db_path, ROOT / "GatherTradingData.json")
page1 = fetch_table_rows("settings", db_path, limit=2, offset=0) page1 = fetch_table_rows("settings", db_path, limit=2, offset=0)
assert page1["columns"] self.assertTrue(page1["columns"])
assert len(page1["rows"]) == 2 self.assertEqual(len(page1["rows"]), 2)
assert page1["total"] > 2 self.assertTrue(page1["total"] > 2)
page2 = fetch_table_rows("settings", db_path, limit=2, offset=2) page2 = fetch_table_rows("settings", db_path, limit=2, offset=2)
assert page1["rows"] != page2["rows"] self.assertNotEqual(page1["rows"], page2["rows"])
import pytest with self.assertRaises(ValueError):
with pytest.raises(ValueError):
fetch_table_rows("settings; DROP TABLE settings;--", db_path) fetch_table_rows("settings; DROP TABLE settings;--", db_path)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def test_snapshot_admin_web_validation_script_passes(): def test_snapshot_admin_web_validation_script_passes(self):
out = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json" out = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json"
if out.exists(): if out.exists():
out.unlink() out.unlink()
@@ -137,8 +150,13 @@ def test_snapshot_admin_web_validation_script_passes():
rc = validator.main() rc = validator.main()
payload = json.loads(out.read_text(encoding="utf-8")) payload = json.loads(out.read_text(encoding="utf-8"))
assert rc == 0 self.assertEqual(rc, 0)
assert payload["gate"] == "PASS" self.assertEqual(payload["gate"], "PASS")
assert payload["formula_id"] == "SNAPSHOT_ADMIN_WEB_VALIDATION_V1" self.assertEqual(payload["formula_id"], "SNAPSHOT_ADMIN_WEB_VALIDATION_V1")
assert payload["settings_rows"] > 0 self.assertTrue(payload["settings_rows"] > 0)
assert payload["account_snapshot_rows"] > 0 self.assertTrue(payload["account_snapshot_rows"] > 0)
if __name__ == "__main__":
unittest.main()
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import sys import sys
import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -11,10 +12,17 @@ if str(ROOT) not in sys.path:
import tools.validate_gitea_secrets_contract_v1 as validator import tools.validate_gitea_secrets_contract_v1 as validator
def test_validate_gitea_secrets_contract_passes(): class TestValidateGiteaSecretsContract(unittest.TestCase):
def test_validate_gitea_secrets_contract_passes(self):
rc = validator.main() rc = validator.main()
payload = json.loads((ROOT / "Temp" / "gitea_secrets_contract_v1.json").read_text(encoding="utf-8")) payload = json.loads((ROOT / "Temp" / "gitea_secrets_contract_v1.json").read_text(encoding="utf-8"))
assert rc == 0 self.assertEqual(rc, 0)
assert payload["gate"] == "PASS" self.assertEqual(payload["gate"], "PASS")
assert payload["evidence"][".gitea/workflows/kis_data_collection.yml"]["vars.KIS_APP_KEY"] is True self.assertTrue(payload["evidence"][".gitea/workflows/kis_data_collection.yml"]["vars.KIS_APP_KEY"])
if __name__ == "__main__":
unittest.main()
@@ -2,7 +2,9 @@ from __future__ import annotations
import json import json
import sys import sys
import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
@@ -19,35 +21,54 @@ class _FakeCreds:
self.app_secret = f"{account}-secret" self.app_secret = f"{account}-secret"
def test_validate_kis_api_credentials_writes_pass_json(tmp_path, monkeypatch): class TestValidateKisApiCredentials(unittest.TestCase):
out = tmp_path / "kis_api_credentials_validation_v1.json"
monkeypatch.setenv("KIS_APP_Key_TEST", "mock-key") def test_validate_kis_api_credentials_writes_pass_json(self):
monkeypatch.setenv("KIS_APP_Secret_TEST", "mock-secret") import tempfile
monkeypatch.setattr(validator, "KisCredentials", type("CredFactory", (), {"load": staticmethod(lambda account: _FakeCreds(account))})) import shutil
monkeypatch.setattr(validator, "get_current_price", lambda creds, ticker: (_ for _ in ()).throw(RuntimeError("network should not be called in dry-run"))) tmp_dir = tempfile.mkdtemp()
monkeypatch.setattr(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--dry-run", "--output", str(out)]) try:
out = Path(tmp_dir) / "kis_api_credentials_validation_v1.json"
with patch.dict("os.environ", {"KIS_APP_Key_TEST": "mock-key", "KIS_APP_Secret_TEST": "mock-secret"}):
with patch.object(validator, "KisCredentials") as mock_creds:
mock_creds.load.side_effect = lambda account: _FakeCreds(account)
with patch.object(validator, "get_current_price") as mock_price:
mock_price.side_effect = RuntimeError("network should not be called in dry-run")
with patch.object(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--dry-run", "--output", str(out)]):
rc = validator.main() rc = validator.main()
payload = json.loads(out.read_text(encoding="utf-8")) payload = json.loads(out.read_text(encoding="utf-8"))
assert rc == 0 self.assertEqual(rc, 0)
assert payload["gate"] == "PASS" self.assertEqual(payload["gate"], "PASS")
assert payload["evidence"]["account"] == "mock" self.assertEqual(payload["evidence"]["account"], "mock")
assert payload["evidence"]["ticker"] == "005930" self.assertEqual(payload["evidence"]["ticker"], "005930")
assert payload["evidence"]["dry_run"] is True self.assertTrue(payload["evidence"]["dry_run"])
finally:
shutil.rmtree(tmp_dir)
def test_validate_kis_api_credentials_fails_when_api_call_errors(self):
import tempfile
import shutil
tmp_dir = tempfile.mkdtemp()
try:
out = Path(tmp_dir) / "kis_api_credentials_validation_v1.json"
def test_validate_kis_api_credentials_fails_when_api_call_errors(tmp_path, monkeypatch): with patch.object(validator, "KisCredentials") as mock_creds:
out = tmp_path / "kis_api_credentials_validation_v1.json" mock_creds.load.side_effect = lambda account: _FakeCreds(account)
with patch.object(validator, "get_current_price") as mock_price:
monkeypatch.setattr(validator, "KisCredentials", type("CredFactory", (), {"load": staticmethod(lambda account: _FakeCreds(account))})) mock_price.side_effect = RuntimeError("boom")
monkeypatch.setattr(validator, "get_current_price", lambda creds, ticker: (_ for _ in ()).throw(RuntimeError("boom"))) with patch.object(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--output", str(out)]):
monkeypatch.setattr(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--output", str(out)])
rc = validator.main() rc = validator.main()
payload = json.loads(out.read_text(encoding="utf-8")) payload = json.loads(out.read_text(encoding="utf-8"))
assert rc == 1 self.assertEqual(rc, 1)
assert payload["gate"] == "FAIL" self.assertEqual(payload["gate"], "FAIL")
assert payload["errors"] self.assertTrue(payload["errors"])
finally:
shutil.rmtree(tmp_dir)
if __name__ == "__main__":
unittest.main()
@@ -2,7 +2,9 @@ from __future__ import annotations
import json import json
import sys import sys
import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
@@ -11,7 +13,9 @@ if str(ROOT) not in sys.path:
import tools.validate_platform_transition_wbs_v1 as validator import tools.validate_platform_transition_wbs_v1 as validator
def test_validate_platform_transition_wbs_reports_failure_notes(monkeypatch): class TestValidatePlatformTransitionWbs(unittest.TestCase):
def test_validate_platform_transition_wbs_reports_failure_notes(self):
spec = { spec = {
"phase_5_platform_transition": { "phase_5_platform_transition": {
"P1_kis_core_api_collector": { "P1_kis_core_api_collector": {
@@ -52,74 +56,50 @@ def test_validate_platform_transition_wbs_reports_failure_notes(monkeypatch):
} }
} }
monkeypatch.setattr( with patch.object(validator, "_load_spec", return_value=spec):
validator, with patch.object(validator, "_read_text", return_value="Phase 5 데이터 플랫폼 전환 WBS 성공값 P1 KIS core collector P2 SQLite canonical store P3 CI scheduler cutover P4 GAS thin adapter minimize P5 PostgreSQL upgrade path"):
"_load_spec", with patch.object(validator, "_check_p1", return_value={
lambda: spec,
)
monkeypatch.setattr(
validator,
"_read_text",
lambda path: "Phase 5 데이터 플랫폼 전환 WBS 성공값 P1 KIS core collector P2 SQLite canonical store P3 CI scheduler cutover P4 GAS thin adapter minimize P5 PostgreSQL upgrade path",
)
monkeypatch.setattr(
validator,
"_check_p1",
lambda: {
"gate": "FAIL", "gate": "FAIL",
"expected_success_value": {}, "expected_success_value": {},
"evidence": {"summary_path": "Temp/test_kis_data_collection.json", "db_path": "Temp/test_kis_data_collection.db"}, "evidence": {"summary_path": "Temp/test_kis_data_collection.json", "db_path": "Temp/test_kis_data_collection.db"},
"errors": ["summary_status=None"], "errors": ["summary_status=None"],
}, }):
) with patch.object(validator, "_check_p2", return_value={
monkeypatch.setattr(
validator,
"_check_p2",
lambda: {
"gate": "FAIL", "gate": "FAIL",
"expected_success_value": {}, "expected_success_value": {},
"evidence": {"db_path": "Temp/test_kis_data_collection.db"}, "evidence": {"db_path": "Temp/test_kis_data_collection.db"},
"errors": ["sqlite_round_trip_missing"], "errors": ["sqlite_round_trip_missing"],
}, }):
) with patch.object(validator, "_check_p3", return_value={
monkeypatch.setattr(
validator,
"_check_p3",
lambda: {
"gate": "PASS", "gate": "PASS",
"expected_success_value": {}, "expected_success_value": {},
"evidence": {"workflow_path": ".gitea/workflows/kis_data_collection.yml"}, "evidence": {"workflow_path": ".gitea/workflows/kis_data_collection.yml"},
"errors": [], "errors": [],
}, }):
) with patch.object(validator, "_check_p4", return_value={
monkeypatch.setattr(
validator,
"_check_p4",
lambda: {
"gate": "FAIL", "gate": "FAIL",
"expected_success_value": {}, "expected_success_value": {},
"evidence": {"validation_path": "Temp/gas_thin_adapter_validation_v1.json"}, "evidence": {"validation_path": "Temp/gas_thin_adapter_validation_v1.json"},
"errors": ["gate=None", "function_inventory_coverage_pct<100"], "errors": ["gate=None", "function_inventory_coverage_pct<100"],
}, }):
) with patch.object(validator, "_check_p5", return_value={
monkeypatch.setattr(
validator,
"_check_p5",
lambda: {
"gate": "PASS", "gate": "PASS",
"expected_success_value": {}, "expected_success_value": {},
"evidence": {}, "evidence": {},
"errors": [], "errors": [],
}, }):
)
rc = validator.main() rc = validator.main()
payload = json.loads((ROOT / "Temp" / "platform_transition_wbs_v1.json").read_text(encoding="utf-8")) payload = json.loads((ROOT / "Temp" / "platform_transition_wbs_v1.json").read_text(encoding="utf-8"))
assert rc == 1 self.assertEqual(rc, 1)
assert payload["gate"] == "FAIL" self.assertEqual(payload["gate"], "FAIL")
assert payload["message"].startswith("Platform transition WBS check failed") self.assertTrue(payload["message"].startswith("Platform transition WBS check failed"))
assert len(payload["failure_notes"]) == 3 self.assertEqual(len(payload["failure_notes"]), 3)
assert "P1 failed" in payload["failure_notes"][0] self.assertIn("P1 failed", payload["failure_notes"][0])
assert "P2 failed" in payload["failure_notes"][1] self.assertIn("P2 failed", payload["failure_notes"][1])
assert "P4 failed" in payload["failure_notes"][2] self.assertIn("P4 failed", payload["failure_notes"][2])
if __name__ == "__main__":
unittest.main()
+50 -26
View File
@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
@@ -11,59 +13,81 @@ if str(ROOT) not in sys.path:
import tools.validate_specs as vs import tools.validate_specs as vs
def test_real_repo_has_no_missing_code_path(): class TestValidateSpecCodeSync(unittest.TestCase):
def test_real_repo_has_no_missing_code_path(self):
"""현재 저장소 상태에서 1차 태깅된 파일들은 모두 code_path가 실존해야 한다.""" """현재 저장소 상태에서 1차 태깅된 파일들은 모두 code_path가 실존해야 한다."""
errors: list[str] = [] errors: list[str] = []
result = vs.validate_spec_code_sync(errors) result = vs.validate_spec_code_sync(errors)
assert result["gate"] == "PASS" self.assertEqual(result["gate"], "PASS")
assert result["missing_code_path_count"] == 0 self.assertEqual(result["missing_code_path_count"], 0)
assert result["checked_count"] >= 10 self.assertTrue(result["checked_count"] >= 10)
assert not errors self.assertFalse(errors)
def test_missing_code_path_fails(self):
def test_missing_code_path_fails(tmp_path, monkeypatch): import tempfile
import shutil
tmp_dir = tempfile.mkdtemp()
try:
tmp_path = Path(tmp_dir)
(tmp_path / "spec").mkdir() (tmp_path / "spec").mkdir()
(tmp_path / "governance").mkdir() (tmp_path / "governance").mkdir()
(tmp_path / "spec" / "fake_contract.yaml").write_text( (tmp_path / "spec" / "fake_contract.yaml").write_text(
"meta:\n has_code_implementation: true\n code_path: \"tools/does_not_exist_v1.py\"\n", "meta:\n has_code_implementation: true\n code_path: \"tools/does_not_exist_v1.py\"\n",
encoding="utf-8", encoding="utf-8",
) )
monkeypatch.setattr(vs, "ROOT", tmp_path) with patch.object(vs, "ROOT", tmp_path):
errors: list[str] = [] errors: list[str] = []
result = vs.validate_spec_code_sync(errors) result = vs.validate_spec_code_sync(errors)
assert result["gate"] == "FAIL" self.assertEqual(result["gate"], "FAIL")
assert result["missing_code_path_count"] == 1 self.assertEqual(result["missing_code_path_count"], 1)
assert any("does_not_exist_v1.py" in e for e in errors) self.assertTrue(any("does_not_exist_v1.py" in e for e in errors))
finally:
shutil.rmtree(tmp_dir)
def test_redirect_only_and_has_code_is_contradiction(self):
def test_redirect_only_and_has_code_is_contradiction(tmp_path, monkeypatch): import tempfile
import shutil
tmp_dir = tempfile.mkdtemp()
try:
tmp_path = Path(tmp_dir)
(tmp_path / "spec").mkdir() (tmp_path / "spec").mkdir()
(tmp_path / "governance").mkdir() (tmp_path / "governance").mkdir()
(tmp_path / "spec" / "contradiction.yaml").write_text( (tmp_path / "spec" / "contradiction.yaml").write_text(
"meta:\n has_code_implementation: true\n redirect_only: true\n", "meta:\n has_code_implementation: true\n redirect_only: true\n",
encoding="utf-8", encoding="utf-8",
) )
monkeypatch.setattr(vs, "ROOT", tmp_path) with patch.object(vs, "ROOT", tmp_path):
errors: list[str] = [] errors: list[str] = []
result = vs.validate_spec_code_sync(errors) result = vs.validate_spec_code_sync(errors)
assert result["gate"] == "FAIL" self.assertEqual(result["gate"], "FAIL")
assert any("contradiction" in e for e in errors) self.assertTrue(any("contradiction" in e for e in errors))
finally:
shutil.rmtree(tmp_dir)
def test_files_without_the_field_are_skipped_not_failed(self):
def test_files_without_the_field_are_skipped_not_failed(tmp_path, monkeypatch): import tempfile
import shutil
tmp_dir = tempfile.mkdtemp()
try:
tmp_path = Path(tmp_dir)
(tmp_path / "spec").mkdir() (tmp_path / "spec").mkdir()
(tmp_path / "governance").mkdir() (tmp_path / "governance").mkdir()
(tmp_path / "spec" / "untouched.yaml").write_text( (tmp_path / "spec" / "untouched.yaml").write_text(
"meta:\n title: legacy doc with no sync field\n", "meta:\n title: legacy doc with no sync field\n",
encoding="utf-8", encoding="utf-8",
) )
monkeypatch.setattr(vs, "ROOT", tmp_path) with patch.object(vs, "ROOT", tmp_path):
errors: list[str] = [] errors: list[str] = []
result = vs.validate_spec_code_sync(errors) result = vs.validate_spec_code_sync(errors)
assert result["gate"] == "PASS" self.assertEqual(result["gate"], "PASS")
assert result["checked_count"] == 0 self.assertEqual(result["checked_count"], 0)
assert result["total_spec_files"] == 1 self.assertEqual(result["total_spec_files"], 1)
assert not errors self.assertFalse(errors)
finally:
shutil.rmtree(tmp_dir)
if __name__ == "__main__":
unittest.main()
+11 -10
View File
@@ -8,24 +8,25 @@ ROOT = Path(__file__).resolve().parents[1]
REQUIRED_PATTERNS = { REQUIRED_PATTERNS = {
".gitea/workflows/kis_data_collection.yml": [ ".gitea/workflows/kis_data_collection.yml": [
"secrets.KIS_APP_KEY_TEST", "vars.KIS_APP_KEY_TEST",
"secrets.KIS_APP_SECRET_TEST", "vars.KIS_APP_SECRET_TEST",
"secrets.KIS_APP_KEY", "vars.KIS_APP_KEY",
"secrets.KIS_APP_SECRET", "vars.KIS_APP_SECRET",
], ],
".gitea/workflows/qualitative_sell_strategy.yml": [ ".gitea/workflows/qualitative_sell_strategy.yml": [
"secrets.KIS_APP_KEY_TEST", "vars.KIS_APP_KEY_TEST",
"secrets.KIS_APP_SECRET_TEST", "vars.KIS_APP_SECRET_TEST",
"secrets.KIS_APP_KEY", "vars.KIS_APP_KEY",
"secrets.KIS_APP_SECRET", "vars.KIS_APP_SECRET",
], ],
".gitea/workflows/ci.yml": [ ".gitea/workflows/ci.yml": [
"secrets.KIS_APP_KEY_TEST", "vars.KIS_APP_KEY_TEST",
"secrets.KIS_APP_SECRET_TEST", "vars.KIS_APP_SECRET_TEST",
], ],
} }
def main() -> int: def main() -> int:
errors: list[str] = [] errors: list[str] = []
evidence: dict[str, dict[str, bool]] = {} evidence: dict[str, dict[str, bool]] = {}