- 길: asyncio.to_thread(build.save · refresh · reset) → store(나눈 storage 모듈) — PUT 500 고침 - 시트 서명: 수를 float 로 맞춤 — 화면(JS)을 거친 10.0 → 10 을 사용자 고침으로 잘못 알리던 덮임 알림 고침 - 화면: 본문 칸이 min-height 뿐이라 내장 엑셀 격자 높이 0 — 본문 높이 calc(100dvh - 머리) - 그림: 우기 · 지진 임계면 점선(건기와 겹쳐도 보임) · 해석 방법 글 짧게(Bishop) - 시험: 길(TestClient — 판 409 · 저장 · 갱신 · 그림 · xlsx · 초기화) · 서명 화면 왕복 같음 — 7 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
14 KiB
Python
346 lines
14 KiB
Python
"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`.
|
|
|
|
가짜 E 프로젝트(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_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_fs"), ("grid:12", "cut_15m"), ("grid:20", "fill_10m")]
|
|
|
|
|
|
def _key(i: int) -> str:
|
|
return f"grid:{i}"
|
|
|
|
|
|
def _summary(i: int, bump: float = 0.0) -> dict:
|
|
ng = i in NG_AT
|
|
dry = 1.31 if ng else 1.7 + i / 100 + bump
|
|
return {
|
|
"station_key": _key(i),
|
|
"chainage_m": 20.0 * i,
|
|
"side": "cut" if i % 2 else "fill",
|
|
"fs_min": dry - 0.2,
|
|
"fs_by_condition": {"dry": dry, "wet": dry - 0.2},
|
|
"method": "bishop",
|
|
"verdict": "ng" if ng else "ok",
|
|
}
|
|
|
|
|
|
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["fs_by_condition"][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"
|
|
d2 = root / "E03_StabSection" / "_derived"
|
|
(d2 / "results").mkdir(parents=True)
|
|
(d2 / "result2d.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"input_fingerprint": "fp-1",
|
|
"computed_at": "2026-10-25T10:00:00",
|
|
"stations": [_summary(i) for i in range(N)],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
for key, _ in REPS:
|
|
i = int(key.split(":")[1])
|
|
(d2 / "results" / f"grid_{i}.json").write_text(
|
|
json.dumps(_station_doc(i)), encoding="utf-8"
|
|
)
|
|
(root / "E03_StabSection" / "representative.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"rule": {"fill_h_m": 10, "cut_h_m": 15, "lowest_n": 5},
|
|
"stations": [{"station_key": k, "reason": r, "enabled": True} for k, r in REPS]
|
|
+ [{"station_key": _key(5), "reason": "user", "enabled": False}],
|
|
}
|
|
),
|
|
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},
|
|
}
|
|
),
|
|
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"] == {"값": "N.G", "서식": build.ST_NG}
|
|
assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 최소
|
|
assert (build.initial_dir(project) / build.REPORT).is_file()
|
|
assert saved["fingerprint"] == build.analysis_fingerprint(project)
|
|
|
|
|
|
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)
|
|
r2 = project / build.RESULT2D
|
|
doc = json.loads(r2.read_text(encoding="utf-8"))
|
|
doc["stations"][3] = _summary(4, 0.5) | {"station_key": _key(3), "chainage_m": 60.0}
|
|
doc["input_fingerprint"] = "fp-2"
|
|
r2.write_text(json.dumps(doc), encoding="utf-8")
|
|
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 해석 전"
|
|
(project / build.RESULT2D).unlink()
|
|
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
|
|
assert len(texts) == 3 + len(stab) + len(REPS) + 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"]["시트"]) == 6
|
|
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)
|