"""E04 3D 해석 길(PLAN 533-3) — 지문 같으면 안 셈 · force 면 다시 · 끊기 · 진행률 · 저장본 · 공유 403. ./venv/Scripts/python.exe -m pytest resources/tester/test_533_3_e04_router.py -q """ import asyncio import json import threading import time import numpy as np import pytest from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from common_util import common_util_auth as auth from E04_Stab3D import E04_Stab3D_Router as router_mod from E04_Stab3D import E04_Stab3D_Run as run PID = "4b1f6f0c-0c52-4a49-9d2b-6c0c3f3e0a11" STATIONS = [ {"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 100.0 + 20.0 * i, "center_y": 200.0} for i in range(11) ] def _fake_engine(root, stations, ground, settings, on_progress, should_stop): """엔진 자리 — 길 시험은 셈 내용이 아니라 흐름(지문 · 끊기 · 저장)을 봄(엔진 = test_533_3_e04_engine).""" on_progress(50, "가짜") f = np.array([[1.4, 1.2, np.nan], [1.1, 0.9, 1.6]]) return { "grid": {"x0": 100.0, "y0": 200.0, "dx": 1.0, "nx": 3, "ny": 2}, "z": np.full(f.shape, 500.0), "f": f, "v": np.where(np.isfinite(f), 120.0, np.nan), "m": np.where(np.isfinite(f), 0.0, np.nan), "best": { "x": 101.0, "y": 201.0, "z": 505.0, "r": 6.0, "ratio": 1.0, "azimuth_deg": 0.0, "f": 0.9, "volume": 120.0, "method": 0, "hovland": 0.85, "bishop": 0.9, "janbu_corrected": 0.95, "truncated_ratio": 0.0, "floor": [], }, "trials": 7, "reference_fs": 1.5, } # fmt: skip @pytest.fixture def project(tmp_path, monkeypatch): src = tmp_path / "E01_StabRegister" / "source" models = src / "B04_PreProcess" / "models" models.mkdir(parents=True) (src / "copy_manifest.json").write_text(json.dumps({"source_fingerprint": "a" * 40})) xs = np.arange(0.0, 400.0, 2.0) ys = np.arange(100.0, 300.0, 2.0) xx, yy = np.meshgrid(xs, ys) z = 500.0 + 0.4 * (yy - 200.0) + 0.002 * (xx - 200.0) ** 2 # 노선 옆 비탈 np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool)) monkeypatch.setattr(run.source, "stations", lambda root: STATIONS) async def fake_root(project_id): return tmp_path monkeypatch.setattr(run, "run_engine", _fake_engine) monkeypatch.setattr(router_mod, "_project_root", fake_root) router_mod._JOBS.clear() return tmp_path @pytest.fixture def client(project): app = FastAPI() app.include_router(router_mod.router) app.dependency_overrides[auth.require_company] = lambda: {"role": "SYSTEM_ADMIN"} with TestClient(app) as c: yield c def _wait(client, done=lambda p: not p["running"], seconds=10.0): end = time.time() + seconds while time.time() < end: body = client.get(f"/api/projects/{PID}/stab/progress3d").json() if done(body): return body time.sleep(0.05) raise AssertionError("진행 기다림 초과") BODY = {"start_m": 0, "end_m": 100, "search": {"box_half_width_m": 20, "cell_m": 2}} def test_auto_sections(): sections = run.auto_sections(STATIONS) assert sections == [{"start_m": 0.0, "end_m": 100.0}, {"start_m": 100.0, "end_m": 200.0}] short = run.auto_sections(STATIONS[:8]) # 0~140 — 꼬리 40 m 는 앞에 붙임 assert short == [{"start_m": 0.0, "end_m": 140.0}] def test_analyze_cached_force_and_result(client, project, monkeypatch): calls = [] real = run.run_engine def counted(*args, **kwargs): calls.append(1) return real(*args, **kwargs) monkeypatch.setattr(run, "run_engine", counted) first = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json() assert first == {"status": "accepted", "key": "0.00_100.00"} done = _wait(client) assert done["error"] is None and done["percent"] == 100 assert done["summary"]["cells"] == 5 and done["summary"]["verdict"] == "ng" assert done["summary"]["by_method"] == {"hovland": 0.85, "bishop": 0.9, "janbu_corrected": 0.95} # 같은 입력 = 셈하지 않음 again = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json() assert again["status"] == "cached" and len(calls) == 1 # force = 다시 셈 assert ( client.post(f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "force": True}).json()[ "status" ] == "accepted" ) _wait(client) assert len(calls) == 2 # 탐색 조건이 바뀌면 지문이 달라 셈 changed = {**BODY, "search": {**BODY["search"], "dr_m": 0.5}} assert ( client.post(f"/api/projects/{PID}/stab/analyze3d", json=changed).json()["status"] == "accepted" ) _wait(client) assert len(calls) == 3 doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).json() grid = doc["grid"] assert len(doc["f"]) == len(doc["z"]) == len(doc["v"]) == grid["nx"] * grid["ny"] f = np.array([np.nan if v is None else v for v in doc["f"]]) best = doc["best"] assert f[best["iy"] * grid["nx"] + best["ix"]] == pytest.approx(np.nanmin(f), abs=1e-3) assert doc["summary"]["f_min"] == pytest.approx(np.nanmin(f), abs=1e-3) assert doc["stale"] is False over = client.get(f"/api/projects/{PID}/stab/result3d").json() assert [s["key"] for s in over["saved"]] == ["0.00_100.00"] and over["editable"] is True assert len(over["line"]) == len(STATIONS) and over["sections"][0]["end_m"] == 100.0 # E02 지반을 바꾸면 저장본이 「옛 것」 (project / "E02_StabGround").mkdir() (project / "E02_StabGround" / "ground.json").write_text(json.dumps({"road_surcharge_kpa": 12})) assert client.get(f"/api/projects/{PID}/stab/result3d").json()["saved"][0]["stale"] is True assert ( client.post(f"/api/projects/{PID}/stab/analyze3d", json=changed).json()["status"] == "accepted" ) _wait(client) def test_cancel_and_restart(client, monkeypatch): started = threading.Event() def slow(root, stations, ground, settings, on_progress, should_stop): started.set() for i in range(500): if should_stop(): raise InterruptedError on_progress(i // 5, f"{i}") time.sleep(0.01) raise AssertionError("끊기지 않음") monkeypatch.setattr(run, "run_engine", slow) assert ( client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()["status"] == "accepted" ) assert started.wait(5) # 도는 중 또 누르면 409 assert client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).status_code == 409 assert client.post(f"/api/projects/{PID}/stab/analyze3d/cancel").json()["stopping"] is True body = _wait(client) assert body["cancelled"] is True and body["error"] is None assert ( client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).status_code == 404 ) # 다시 시작 monkeypatch.setattr(run, "run_engine", _fake_engine) assert ( client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()["status"] == "accepted" ) body = _wait(client) assert body["cancelled"] is False and body["percent"] == 100 def test_bad_requests(client): url = f"/api/projects/{PID}/stab/analyze3d" assert client.post(url, json={"start_m": 100, "end_m": 50}).status_code == 400 assert client.post(url, json={"start_m": 0, "end_m": 900}).status_code == 400 bad = {**BODY, "search": {"h_min_m": 10, "h_max_m": 5}} assert client.post(url, json=bad).status_code == 400 assert ( client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "../x"}).status_code == 400 ) class _Cur: def __init__(self, rows): self.rows, self.sql = rows, "" async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def execute(self, sql, args=None): self.sql = sql async def fetchone(self): return next((v for k, v in self.rows.items() if k in self.sql), None) class _Pool: def __init__(self, rows): self.rows = rows def acquire(self): pool = self class _Conn: async def __aenter__(self): return self async def __aexit__(self, *exc): return False def cursor(self, *args): return _Cur(pool.rows) return _Conn() class _Req: def __init__(self, method): self.method = method self.path_params = {"project_id": PID} def test_shared_company_get_only(monkeypatch): """main.py 가 E04 길을 프로젝트 권한 검사에 걸었는지 + 지정받은 회사 = GET 만(POST 403).""" import main paths = { "/api/projects/{project_id}/stab/analyze3d", "/api/projects/{project_id}/stab/result3d", } hits = [r for r in main.app.routes if getattr(r, "path", None) in paths] assert len(hits) == 2 for route in hits: calls = {d.call for d in route.dependant.dependencies} assert auth.require_project_access in calls user = {"role": "USER", "company_id": 1} pool = _Pool({"FROM projects": (2,), "plan_route_assignees": (1,)}) monkeypatch.setattr(auth, "get_db_pool", lambda: pool) assert asyncio.run(auth.require_project_access(_Req("GET"), user)) is user with pytest.raises(HTTPException) as e: asyncio.run(auth.require_project_access(_Req("POST"), user)) assert e.value.status_code == 403