269 lines
9.5 KiB
Python
269 lines
9.5 KiB
Python
"""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
|