"""534-3 · 534-5(빌더 · storage · 길 · 인쇄) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Section` · `_Print` · `_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_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_Print as printer 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" SLICES = 24 # 임계면 절편 수 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 _slice(i: int) -> dict: return { "width": 0.5, "height": 1.0 + i / 10, "alpha_deg": 10.0 + i, "weight": 9.0 + i, "surcharge": 0.0, "pore_pressure": 2.0, "base_length": 0.51, "c": 15.0, "phi_deg": 30.0, "normal_eff": 8.0 + i, "shear_mobilized": 6.0 + i / 10, } # fmt: skip 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, "slices": [_slice(i) for i in range(SLICES)], } 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) k = [ c.get("값") for a1, c in by["측점표"]["칸"].items() if a1.startswith("K") and int(a1[1:]) >= 6 ] assert k.count("대책 검토") == len(NG_AT) and k.count("-") == N - len(NG_AT) # 측점표 쪽 나눔 = 고르게(덩이 크기 차 ≤ 1 · 한 줄 꼬리 없음) cuts = by["측점표"]["인쇄"]["쪽나눔"] sizes = [b - a for a, b in zip([5, *cuts], [*cuts, 5 + N])] assert len(sizes) >= 2 and max(sizes) - min(sizes) <= 1 # 부록 절편 — 대표 단면마다 지배 조건 한 덩이 · 저항력 = 동원 전단 × Fs · 덩이 끝 쪽 나눔 s10 = by["부록_절편"]["칸"] block = 3 + SLICES + 1 assert by["부록_절편"]["인쇄"]["쪽나눔"] == [block * (i + 1) for i in range(len(REPS))] assert "건기" in s10["A1"]["값"] and s10["A1"]["값"].startswith("부록 ① 절편 계산표 — No.3") assert s10["A4"]["값"] == 1 and s10["M4"]["값"] == pytest.approx(6.0 * 1.31) assert s10[f"A{block}"]["값"] == "합계" assert s10[f"E{block}"]["값"] == pytest.approx(sum(9.0 + i for i in range(SLICES))) assert book["서식"][s10["B4"]["서식"]]["크기"] == 8 # 목차 — 본문 시트 차례 · 쪽 번호(목차 = 1) toc = by["목차"]["칸"] assert [toc[f"B{r}"]["값"] for r in (4, 5)] == [printer.TITLES["s2"], printer.TITLES["s3"]] assert toc["A10"]["값"] == "부록" # 보강검토 없음 → 본문 6 + 부록 and toc["C4"]["값"] == 2 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_옛_작업본_갱신_목차자리_서식(project): """534-5 전 작업본(목차 · 부록 없음 · 서식 18 개) → 갱신 = 목차는 표지 다음 · 부록은 끝 · 새 서식 붙임(옛 서식 번호 그대로) · 사용자 몫 불변.""" _create(project) book, meta = store.load(project) book["시트"] = [s for s in book["시트"] if s["id"] not in ("s9", "s10")] book["서식"] = book["서식"][:18] + [{"크기": 30}] # 사용자가 더한 서식 = 18 번 _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 18} meta["signs"] = {k: v for k, v in meta["signs"].items() if k not in ("s9", "s10")} store.save(project, book) (project / "E05_StabReport" / build.META).write_text(json.dumps(meta), encoding="utf-8") store.refresh(project) book, _ = store.load(project) ids = [s["id"] for s in book["시트"]] assert ids[:3] == ["s1", "s9", "s2"] and ids[-1] == "s10" by = _sheets(book) assert by["표지"]["칸"]["C10"] == {"값": "산림청", "서식": 18} and book["서식"][18] == { "크기": 30 } head = book["서식"][by["부록_절편"]["칸"]["A3"]["서식"]] assert head["크기"] == 8 and head["굵게"] and len(book["서식"]) == len(build.form()["서식"]) + 1 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요약", "광역요약", "부록_절편", ] # fmt: skip 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" ) try: pdf = printer.report_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 len(stab) == len(book["시트"][4]["인쇄"]["쪽나눔"]) + 1 # 측점표 = 나눈 덩이 수 # 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 + 부록 3 assert len(texts) == 4 + len(stab) + len(REPS) + 1 + 1 + len(REPS) body = len(texts) - 1 assert f"/ {body}" not in texts[0] and "검증 임도" not in texts[0].split("보고서")[0] assert all(f"- {i} / {body} -" in texts[i] and "검증 임도" in texts[i] for i in range(1, body)) # 목차 쪽 번호 = 실제 쪽(본문 n 쪽 = PDF n + 1 번째 쪽) toc = texts[1] marks = {"측점별 안정성 판정 일람표": "산정 안전율", printer.TITLES["s5"]: "대표 단면 ①"} marks[printer.TITLES["s10"]] = "절편 계산표" for title, inside in marks.items(): line = next(x for x in toc.splitlines() if title in x) page = int(line.split()[-1]) assert inside in texts[page], (title, page) 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("= 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"]["시트"]) == 9 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)