Files
Aislo/resources/tester/test_534_3_e05_report.py
T

459 lines
19 KiB
Python

"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`.
가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 ·
3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 ·
갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수.
"""
import io
import json
import shutil
from pathlib import Path
import pytest
from openpyxl import load_workbook
from common_util.common_util_derived_store import derived_dir, write_derived
from common_util.common_util_print_pdf import PrintError
from common_util.common_util_spreadsheet import workbook_pdf
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
from E02_StabGround import E02_StabGround_Model as ground_model
from E05_StabReport import E05_StabReport_Build as build
from E05_StabReport import E05_StabReport_Figure as figure
from E05_StabReport import E05_StabReport_Store as store
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
N = 30 # 측점 수
NG_AT = {3, 10, 17, 24} # N.G 측점
REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")]
# 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔
OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False}
KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station"
REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}}
def _key(i: int) -> str:
return f"grid:{i}"
def _summary(i: int, bump: float = 0.0) -> dict:
"""E03 요약 줄 꼴 — 지배 비탈(홀수 = 깎기 왼쪽 · 짝수 = 쌓기 오른쪽)만 낮은 안전율."""
ng = i in NG_AT and not bump
dry = 1.31 if ng else 1.7 + i / 100 + bump
role = "cut" if i % 2 else "fill"
side = "left" if role == "cut" else "right"
other = "right" if side == "left" else "left"
heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0}
fs = {"dry": dry, "wet": dry - 0.2}
cases = {
cond: {
side: {"fs": fs[cond], "required": REQUIRED[cond][role]},
other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]},
}
for cond in fs
}
return {
"key": _key(i),
"chainage_m": 20.0 * i,
"label": f"No.{i}",
"fp": f"st{i}",
"sides": [
{"side": "left", "role": "cut", "height_m": heights["cut"]},
{"side": "right", "role": "fill", "height_m": heights["fill"]},
],
"cases": cases,
"min": {c: min(v["fs"] for v in cases[c].values()) for c in cases},
"verdict": {
c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng"
for c in cases
},
"error": None,
}
def _write_summary(root: Path, fp: str, rows: list[dict]) -> None:
value = {
"fingerprint": fp,
"computed_at": "2026-10-25T10:00:00",
"conditions": ["dry", "wet"],
"method": "bishop",
"stations": rows,
}
write_derived(root, KIND_SUMMARY, fp, value)
def _slip(F: float) -> dict:
pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)]
return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5}
def _station_doc(i: int) -> dict:
s = _summary(i)
cases = []
for cond in ("dry", "wet"):
F = s["min"][cond]
for side, role in (("left", "cut"), ("right", "fill")):
cases.append(
{
"condition": cond,
"side": side,
"role": role,
"direction": -1 if side == "left" else 1,
"limits": [-10.0, 10.0],
"height_m": 6.0,
"min_F": {"bishop": F},
"result": {"methods": {"bishop": {"min": _slip(F)}}},
}
)
return {"chainage_m": s["chainage_m"], "label": f"No.{i}", "cases": cases}
def _cross(chainage: float) -> dict:
samples = [{"offset_m": float(x), "z": 100.0 + 0.4 * x, "valid": True} for x in range(-20, 21)]
line = [
{"offset_m": 8.0, "elevation_m": 103.2},
{"offset_m": 2.0, "elevation_m": 100.0},
{"offset_m": -2.0, "elevation_m": 100.0},
{"offset_m": -6.0, "elevation_m": 97.6},
]
return {
"chainage_m": chainage,
"label": f"No.{int(chainage // 20)}",
"samples": samples,
"design": {
"design_line": line,
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
"ground_type": "ripping_rock",
},
}
@pytest.fixture
def project(tmp_path, monkeypatch) -> Path:
root = tmp_path / "e_project"
_write_summary(root, "fp-1", [_summary(i) for i in range(N)])
for key, _ in REPS:
i = int(key.split(":")[1])
write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N)
(root / "E03_StabSection").mkdir()
(root / "E03_StabSection" / "representative.json").write_text(
json.dumps({"overrides": OFF}), encoding="utf-8"
)
ground = ground_model.pick(ground_model.load_defaults())
ground["stations"] = [
{"chainage_m": 20.0 * i, "ground_type": "ripping_rock", "soil_thickness_m": 1.5}
for i in range(N)
]
ground["zones"] = [
{"from_m": 0, "to_m": 100, "soil": "colluvium", "rock": None, "soil_thickness_m": None}
]
path = ground_model.ground_path(root)
path.parent.mkdir(parents=True)
path.write_text(json.dumps(ground), encoding="utf-8")
r3 = root / "E04_Stab3D" / "results"
r3.mkdir(parents=True)
(r3 / "0.00_200.00.json").write_text(
json.dumps(
{
"fingerprint": "3d-1",
"settings": {},
"summary": {
"start_m": 0.0,
"end_m": 200.0,
"f_min": 1.42,
"volume": 830.0,
"condition": "wet",
"reference_fs": 1.3,
"verdict": "ok",
},
}
),
encoding="utf-8",
)
stations = [
{"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 10.0 * i, "center_y": 3.0 * i}
for i in range(N)
]
db = {
"project": {"name": "검증 임도", "region": "울진군", "road_type": "산불진화임도"},
"route_points": [
{"sequence_num": i, "model_x": 10.0 * i, "model_y": 3.0 * i, "chainage_m": 20.0 * i}
for i in range(N)
],
}
monkeypatch.setattr(build.source, "db_snapshot", lambda _root: db)
monkeypatch.setattr(build.source, "stations", lambda _root: stations)
monkeypatch.setattr(build.source, "cross_section", lambda _root, ch: _cross(ch))
return root
def _sheets(book: dict) -> dict:
return {s["이름"]: s for s in book["시트"]}
def _create(root: Path) -> dict:
try:
return store.create(root)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
def test_처음_만듦_측점수_NG_그림(project):
meta = _create(project)
book, saved = store.load(project)
by = _sheets(book)
rows = [a1 for a1, c in by["측점표"]["칸"].items() if a1.startswith("A") and "값" in c]
rows = [a1 for a1 in rows if int(a1[1:]) >= 6]
assert len(rows) == N # 측점 수 = 줄 수
j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6]
assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT)
assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT)
figs = sorted((project / "E05_StabReport" / "fig").glob("*.png"))
assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY
assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS]
assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84]
assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상"
assert by["대표단면"]["칸"]["E24"] == {"값": "N.G", "서식": build.ST_NG} # No.3 건기 1.31
assert by["대표단면"]["칸"]["F24"]["값"] == 12.5
assert by["표지"]["칸"]["C8"]["값"] == "검증 임도"
vals = book["계산값"]["s2"]
assert vals["B5"] == {"수": str(N)} and vals["B15"] == {"수": str(len(NG_AT))}
# 입력조건 — 물성 여섯 + 구간 한 줄 · 3D 한 줄
s3 = by["입력조건"]["칸"]
assert s3["A26"]["값"] == "풍화토" and s3["B32"]["값"].startswith("0 ~ 100 m 위 지층")
assert s3["A32"]["값"] == "붕적토" and s3["B13"]["값"] == 1.3
s6 = by["3D요약"]["칸"]
assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "O.K", "서식": build.ST_OK} # E04 판정
assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 우기 최소(3D 와 같은 조건)
assert (build.initial_dir(project) / build.REPORT).is_file()
assert saved["fingerprint"] == build.analysis_fingerprint(project)
def test_광역요약_E07_저장본(project):
"""E07 저장본 없음 = 「광역 해석 전」 · 생김 = 지문 바뀜 → 갱신 뒤 등급 면적 · 위험 측점 줄."""
_create(project)
book, meta = store.load(project)
assert _sheets(book)["광역요약"]["칸"]["B5"]["값"].startswith("광역 해석 전")
keys = ("stable", "moderate", "quasi", "lower", "upper", "defended")
area = {
"settings": {
"band_m": 100.0,
"cell_m": 2.0,
"gamma_t": 17.5,
"gamma_sat": 18.5,
"c": 5.0,
"c_hi": 10.0,
"phi": 27.0,
"phi_hi": 30.0,
"c_root": 0.0,
"soil_depth_m": 1.5,
"scenario": "sinmap",
"tr_lo": 2000.0,
"tr_hi": 3000.0,
"level_lo_pct": 5.0,
"level_hi_pct": 20.0,
},
"computed_at": "2026-10-25T21:00:00",
"stations": [
{
"chainage_m": 0.0,
"cells": 9,
"fs_min": 1.6,
"si_min": 1.6,
"worst_class": 1,
"risk_area_m2": 0.0,
"risk_ratio": 0.0,
"level": "low",
"slope_max_deg": 12.0,
"label": "No.0",
},
{
"chainage_m": 20.0,
"cells": 9,
"fs_min": 0.8,
"si_min": 0.0,
"worst_class": 6,
"risk_area_m2": 36.0,
"risk_ratio": 1.0,
"level": "high",
"slope_max_deg": 41.5,
"label": "No.1",
},
{
"chainage_m": 40.0,
"cells": 9,
"fs_min": 0.95,
"si_min": 0.7,
"worst_class": 4,
"risk_area_m2": 8.0,
"risk_ratio": 0.0556,
"level": "mid",
"slope_max_deg": 33.0,
"label": "No.2",
},
],
"summary": {
"areas_m2": dict(zip(keys, (100.0, 20.0, 12.0, 8.0, 0.0, 36.0))),
"station_levels": {"low": 1, "mid": 1, "high": 1},
},
}
path = project / "E07_StabArea" / "_derived" / "area.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8")
assert build.analysis_fingerprint(project) != meta["fingerprint"]
store.refresh(project)
book, _ = store.load(project)
s7 = _sheets(book)["광역요약"]["칸"]
assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0]
assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7
assert (
s7["E22"]["값"] == "높음 (100.0 %)"
and s7["E23"]["값"] == "보통 (5.6 %)"
and s7["F23"]["값"] == 8.0
and [s7[f"G{r}"]["값"] for r in (12, 13, 14)] == [1, 1, 1]
and s7["F13"]["값"] == "5 ~ 20 %"
and s7["B7"]["값"].startswith("표준 — SINMAP")
)
vals = book["계산값"]["s7"]
assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45")
def test_갱신_사용자몫_불변_덮임(project):
_create(project)
book, _ = store.load(project)
by = _sheets(book)
by["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5}
by["개요"]["칸"]["A22"] = {"값": "사용자 의견", "서식": 15}
store.save(project, book)
out = store.refresh(project)
assert out["overwritten"] == [] # 프로그램 몫을 안 고침
book, _ = store.load(project)
by = _sheets(book)
by["측점표"]["칸"]["A6"]["값"] = "고친 측점"
store.save(project, book)
rows = [_summary(i) for i in range(N)]
rows[3] = _summary(3, 0.5)
_write_summary(project, "fp-2", rows)
assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"]
try:
out = store.refresh(project)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
assert out["overwritten"] == ["측점표"]
assert len(list((project / "E05_StabReport" / "_지난").glob("*.json"))) == 1
book, meta = store.load(project)
by = _sheets(book)
assert (
by["표지"]["칸"]["C10"]["값"] == "산림청" and by["개요"]["칸"]["A22"]["값"] == "사용자 의견"
)
assert by["측점표"]["칸"]["A6"]["값"] == "No.0" # 프로그램 몫은 다시 씀
assert book["계산값"]["s2"]["B15"] == {"수": str(len(NG_AT) - 1)} # 수식이 새 값을 따라감
assert meta["fingerprint"] == build.analysis_fingerprint(project)
assert store.reset(project)
book, _ = store.load(project)
assert (
"C10" not in _sheets(book)["표지"]["칸"] or "값" not in _sheets(book)["표지"]["칸"]["C10"]
)
def test_3D_없음_2D_없음(project):
shutil.rmtree(project / "E04_Stab3D")
inputs = build.gather(project)
book = build.form()
build.build_program(book, inputs)
by = _sheets(book)
assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전"
assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전"
shutil.rmtree(derived_dir(project, KIND_SUMMARY))
with pytest.raises(build.MissingInput):
build.gather(project)
def test_xlsx_되읽기_pdf_쪽수(project):
_create(project)
book, _ = store.load(project)
figs = store.figures_of(project, book)
assert len(figs) == len(REPS) + 1
wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"]
assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1
assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84]
assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith(
"FFC7CE"
)
book.pop("계산값", None)
try:
pdf = workbook_pdf(book, figs)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
from pypdf import PdfReader
texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages]
stab = [i for i, t in enumerate(texts) if "산정 안전율" in t]
assert stab and all("산정 안전율" in texts[i] for i in stab)
# 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1
assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1
def test_단면_그림_SVG():
svg = figure.section_svg(
title="No.3",
ground=[(-10.0, 96.0), (10.0, 104.0)],
surface=[(-10.0, 96.0), (-2.0, 100.0), (2.0, 100.0), (10.0, 104.0)],
rock=[(-10.0, 94.5), (10.0, 102.5)],
slips=[{"condition": "dry", "F": 1.3, "points": [[-5.0, 98.0], [0.0, 97.0], [5.0, 102.0]]}],
road=(-2.0, 2.0),
heights=[{"x": 10.0, "z0": 100.0, "z1": 104.0}],
wet=True,
)
assert svg.startswith("<svg") and "건기 임계면 Fs 1.30" in svg and "노폭 4.00 m" in svg
assert "H 4.00 m" in svg and svg.count("<polyline") >= 4
def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from E05_StabReport import E05_StabReport_Router as router_mod
async def fake_project(_pid):
return project, 3
monkeypatch.setattr(router_mod, "_project", fake_project)
app = FastAPI()
app.include_router(router_mod.router)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3}
client = TestClient(app)
url = "/api/projects/3d3776a1-2973-4385-8dcc-fdc6e49a900e/stab/report"
try:
got = client.get(url).json()
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7
book = got["book"]
_sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5}
assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409
saved = client.put(url, json={"rev": got["rev"], "book": book}).json()
assert saved["rev"] != got["rev"]
assert client.post(f"{url}/refresh", json={"rev": got["rev"]}).status_code == 409
out = client.post(f"{url}/refresh", json={"rev": saved["rev"]}).json()
assert out["overwritten"] == [] and _sheets(out["book"])["표지"]["칸"]["C10"]["값"] == "산림청"
fig = client.get(f"{url}/fig", params={"key": build.fig_key("grid:3")})
assert fig.status_code == 200 and fig.content[:4] == b"\x89PNG"
assert client.get(f"{url}/fig", params={"key": "../x"}).status_code == 400
x = client.post(f"{url}/xlsx", json={"book": out["book"]})
assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지"
back = client.post(f"{url}/reset").json()
assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {})
def test_서명_화면_왕복_같음(project):
"""JS 를 거친 책(10.0 → 10)도 같은 서명 — 안 고친 시트를 덮임으로 알리지 않음."""
book = build.form()
build.build_program(book, build.gather(project))
s3 = _sheets(book)["입력조건"]
assert s3["칸"]["B22"]["값"] == 10.0
trip = json.loads(json.dumps(s3).replace("10.0", "10"))
assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3)