Files
Aislo/resources/tester/test_534_3_e05_report.py
T

535 lines
23 KiB
Python

"""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("<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"]["시트"]) == 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)