"""485-1 B07 도면 X · Y 축척(요구 280-5) — 프로젝트마다 `drawing_scales.json`. 값 없음 = 기본 지정값(자동 없음 · 523-1) · 종단 X 바꿈 = 장 수 · 도각 축척 글 · 횡단 X ≠ Y 덧그림 자리 · 토적 · 전개도 · 범위 밖 거절 · 바꾼 종류만 저장본 풂. """ import json import shutil import subprocess from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from B07_DesignDetail import B07_DesignDetail_Engine_Cad_Development as dev from B07_DesignDetail import B07_DesignDetail_Router_Support_Scale as scale_router from B07_DesignDetail.B07_DesignDetail_Engine_Cad import build_cross_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development_Draw import ( build_development_drawing, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongSheet import ( build_longitudinal_drawing, longitudinal_chunks, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import build_cross_sheet, plan_cross_sheets from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( DRAWING_SCALE_DEFAULTS, drawing_scales_path, entities_bbox, read_drawing_scales, use_project_templates, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest from B07_DesignDetail.B07_DesignDetail_Router_Support_Scale import ( checked_scales, saved_counts, write_drawing_scales, ) ROOT = Path(__file__).resolve().parents[2] FIXTURE = Path(__file__).parent / "fixtures" / "b07_cross_overlay_sections.json" NODE_MODULES = ROOT / "config" / "node_modules" EXTENT_BUNDLE = ROOT / "config" / "b07_extent_node" / "B07_DesignDetail_Cross_Extent_Node.js" SOURCE = { "chainage_m": 0.0, "samples": [ {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.2 * x} for x in range(-10, 11) ], } DESIGN_LINE = [ {"offset_m": -4.0, "elevation_m": 99.0}, {"offset_m": 0.0, "elevation_m": 98.5}, {"offset_m": 4.0, "elevation_m": 99.5}, ] @pytest.fixture def project(tmp_path: Path): """빈 프로젝트 폴더를 이 요청 프로젝트로 — 끝나면 시스템 양식으로(문맥 변수 새지 않게).""" use_project_templates(tmp_path) yield tmp_path use_project_templates(None) def _set(project: Path, scales: dict) -> None: path = drawing_scales_path(project) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(scales), encoding="utf-8") def _longitudinal(count: int = 66, interval: float = 20.0) -> dict: xs = [index * interval for index in range(count)] return { "samples": [{"valid": True, "chainage_m": x, "elevation_m": 100.0 + 0.05 * x} for x in xs], "stations": [{"chainage_m": x} for x in xs], "design_profiles": [ {"samples": [{"chainage_m": x, "elevation_m": 100.5 + 0.05 * x} for x in xs]} ], } def _texts(drawing: dict) -> list[str]: out: list[str] = [] def walk(entity: dict) -> None: label = (entity.get("shapeData") or {}).get("label") if isinstance(label, str): out.append(label) for child in entity.get("children") or []: walk(child) for entity in drawing["entities"]: walk(entity) return out def _ground_xy(drawing: dict) -> list[tuple[float, float]]: ground = next(e for e in drawing["entities"] if e["layerId"] == "b08-ground") return [ (child["shapeData"]["startPoint"]["x"], child["shapeData"]["startPoint"]["y"]) for child in ground["children"] ] def test_값_없음_기본_그대로(project: Path) -> None: assert read_drawing_scales(project) == DRAWING_SCALE_DEFAULTS assert read_drawing_scales(None) == DRAWING_SCALE_DEFAULTS with_root = build_cross_drawing(SOURCE, "cross_00000m", DESIGN_LINE, None, None, "No.0") longitudinal = _longitudinal() chunks = longitudinal_chunks(longitudinal) use_project_templates(None) without = build_cross_drawing(SOURCE, "cross_00000m", DESIGN_LINE, None, None, "No.0") assert with_root == without assert longitudinal_chunks(longitudinal) == chunks # 깨진 파일 · 어긋난 값 = 기본 use_project_templates(project) drawing_scales_path(project).parent.mkdir(parents=True, exist_ok=True) drawing_scales_path(project).write_text("{깨짐", encoding="utf-8") assert read_drawing_scales(project) == DRAWING_SCALE_DEFAULTS _set(project, {"cross": {"x": -5, "y": True}, "longitudinal": "x"}) assert read_drawing_scales(project) == DRAWING_SCALE_DEFAULTS def test_종단_X_바꿈_장_수_그림_폭_도각_글(project: Path) -> None: longitudinal = _longitudinal() before = longitudinal_chunks(longitudinal) first = build_longitudinal_drawing(longitudinal, before[0]["id"], before[0]) _set(project, {"longitudinal": {"x": 500, "y": 200}}) after = longitudinal_chunks(longitudinal) # 1/500 = 1 m 당 2 mm — 한 장에 담는 측점이 줄어 장이 늘어남 · id 도 다시 매김 assert len(after) > len(before) assert [c["id"] for c in after] == [f"longitudinal_{n}" for n in range(1, len(after) + 1)] chunk = {**before[0], "id": "probe"} wide = build_longitudinal_drawing(longitudinal, "probe", chunk) def width(drawing: dict) -> float: ground = [e for e in drawing["entities"] if e["layerId"] == "b08-ground"] box = entities_bbox(ground) return box[2] - box[0] assert width(wide) == pytest.approx(2.0 * width(first)) assert any("500(H) · 200(V)" in text for text in _texts(wide)) assert any("1000(H) · 200(V)" in text for text in _texts(first)) def test_횡단_X_다른_Y_덧그림_자리_도각_글(project: Path) -> None: base = build_cross_drawing(SOURCE, "cross_00000m", DESIGN_LINE, None, None, "No.0") _set(project, {"cross": {"x": 100, "y": 50}}) tall = build_cross_drawing(SOURCE, "cross_00000m", DESIGN_LINE, None, None, "No.0") placement = tall["cross_placements"][0] assert (placement["mm_per_m_x"], placement["mm_per_m_y"]) == (10.0, 20.0) assert placement["mm_per_m"] == 10.0 # 옛 칸 = 가로 assert base["cross_placements"][0]["mm_per_m_y"] == 10.0 (bx0, by0), (bx1, by1) = _ground_xy(base)[0], _ground_xy(base)[-1] (tx0, ty0), (tx1, ty1) = _ground_xy(tall)[0], _ground_xy(tall)[-1] assert tx1 - tx0 == pytest.approx(bx1 - bx0) # 가로 그대로 assert ty1 - ty0 == pytest.approx(2.0 * (by1 - by0)) # 세로 2 배 # 덧그림 식 = 서버가 그린 지반점과 같은 자리(TS `placeStructureEntities` 와 같은 식) for sample in SOURCE["samples"][8:13]: # 그리는 창 안 측점(오프셋 -2 ~ 2 m) x = sample["offset_m"] * placement["mm_per_m_x"] + placement["ox"] y = (sample["elevation_m"] - placement["dy"]) * placement["mm_per_m_y"] + placement["oy"] assert any((x, y) == pytest.approx(point) for point in _ground_xy(tall)) sheet = plan_cross_sheets([0.0])[0] section = {"chainage": 0.0, "source": SOURCE, "design_line": DESIGN_LINE, "title": "No.0"} texts = _texts(build_cross_sheet(sheet, [section])) assert any("100(H) · 50(V)" in text for text in texts) _set(project, {"cross": {"x": 200, "y": 200}}) texts = _texts(build_cross_sheet(sheet, [section])) assert any(text.strip().endswith("200") and "(H)" not in text for text in texts) def test_덧그림_TS_가_가로_세로_칸을_씀() -> None: overlay = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Cross_Overlay.ts").read_text( encoding="utf-8" ) assert "placement.mm_per_m_x ?? placement.mm_per_m" in overlay assert "placement.mm_per_m_y ?? placement.mm_per_m" in overlay node = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Cross_Extent_Node.ts").read_text( encoding="utf-8" ) assert "input.mm_per_m_x ?? MM_PER_M" in node and "input.mm_per_m_y ?? MM_PER_M" in node @pytest.mark.skipif( shutil.which("node") is None or not EXTENT_BUNDLE.is_file(), reason="node · 번들 없음" ) def test_덧그림_범위_Node_축척을_받음(tmp_path: Path) -> None: detail = json.loads(FIXTURE.read_text(encoding="utf-8")) def extents(mm_x: float, mm_y: float) -> dict: source, target = tmp_path / "in.json", tmp_path / "out.json" payload = {"detail": detail, "mm_per_m_x": mm_x, "mm_per_m_y": mm_y} source.write_text(json.dumps(payload), encoding="utf-8") subprocess.run(["node", str(EXTENT_BUNDLE), str(source), str(target)], check=True) rows = json.loads(target.read_text(encoding="utf-8"))["extents"] return {row["chainage_m"]: row for row in rows} base, tall = extents(10.0, 10.0), extents(10.0, 20.0) assert base and base.keys() == tall.keys() for chainage, row in base.items(): # 가로 축척 같음 = 가로 범위(m) 같음 · 세로 축척만 바뀜 assert tall[chainage]["x_min"] == pytest.approx(row["x_min"]) assert tall[chainage]["x_max"] == pytest.approx(row["x_max"]) LONGITUDINAL = {"stations": [{"chainage_m": x} for x in (0.0, 20.0, 40.0)]} MASS_HAUL = { "points": [ {"chainage_m": 0.0, "cumulative_volume_m3": 0.0}, {"chainage_m": 20.0, "cumulative_volume_m3": 1000.0}, {"chainage_m": 40.0, "cumulative_volume_m3": 400.0}, ] } def test_토적_가로_세로(project: Path) -> None: base = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") assert "SCALE H=1:2,000 V=1:50,000" in _texts(base) # 기본 = 지정값(자동 없음 · 523-1) _set(project, {"mass_haul": {"x": 1000, "y": 100000}}) fixed = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") assert "SCALE H=1:1,000 V=1:100,000" in _texts(fixed) curve = next(e for e in fixed["entities"] if e["layerId"] == "b08-masshaul-curve") end = curve["children"][0]["shapeData"]["endPoint"] assert (end["x"], end["y"]) == pytest.approx((20.0, 10.0)) # 20 m · 1,000 ㎥ / 100 ㎥/mm def test_전개도_가로_세로(project: Path) -> None: rows = [dev._row("soil", "돌망태", i * 20.0, i * 20.0 + 6, 2.0, str(i)) for i in range(3)] sheet = dev.development_sheets(rows)[0] base = build_development_drawing(sheet["id"], sheet, 20.0) assert "S=1:50" in _texts(base) # 기본 = 가로 · 세로 1/50 지정값(523-1) _set(project, {"development": {"x": 200, "y": 100}}) fixed = build_development_drawing(sheet["id"], sheet, 20.0) assert "H=1:200 V=1:100" in _texts(fixed) boxes = [e for e in fixed["entities"] if e["layerId"] == "b07-dev-box"] box = entities_bbox(boxes[:1]) assert (box[2] - box[0], box[3] - box[1]) == pytest.approx((6 * 5.0, 2.0 * 10.0)) assert any("200(H) · 100(V)" in text for text in _texts(fixed)) def test_범위_검사() -> None: assert checked_scales({"cross": {"x": 100, "y": None}}) == {"cross": {"x": 100, "y": None}} # 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 기본 assert checked_scales({"longitudinal": {"plan": 3000}}) == { "longitudinal": {"x": None, "y": None, "plan": 3000} } with pytest.raises(ValueError, match="평면도 축척은 1/500~1/50,000"): checked_scales({"longitudinal": {"plan": 400}}) for bad in ( {"longitudinal": {"plan": 50001}}, {"plan": {"x": 500}}, {"standard": {"x": 50}}, {"cross": {"x": 5}}, {"cross": {"x": 2000}}, {"longitudinal": {"x": 500.5}}, {"longitudinal": {"x": True}}, {"longitudinal": {"x": "500"}}, {"cross": 100}, [], ): with pytest.raises(ValueError): checked_scales(bad) def test_바꾼_종류만_저장본_풂(project: Path) -> None: _write_manifest( project, { "drawings": { "longitudinal_1": {"kind": "longitudinal", "confirmed": True}, "longitudinal_2": {"kind": "longitudinal", "confirmed": True}, "cross_00020m": {"kind": "cross", "confirmed": True}, "mass_haul": {"kind": "mass_haul", "confirmed": True}, "cover": {"kind": "cover", "confirmed": True}, } }, ) assert saved_counts(project) == { "longitudinal": 2, "cross": 1, "mass_haul": 1, "development": 0, } # 기본과 같은 값 = 바뀐 것 없음 = 안 풂 assert write_drawing_scales(project, project / "none.json", {"cross": {"x": 100}}) == ({}, []) invalidated, stale = write_drawing_scales( project, project / "none.json", {"longitudinal": {"x": 500, "y": None}} ) assert (invalidated, stale) == ({"longitudinal": 2}, []) drawings = _read_manifest(project)["drawings"] assert not drawings["longitudinal_1"]["confirmed"] assert drawings["cross_00020m"]["confirmed"] and drawings["cover"]["confirmed"] assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": 5000} # 보낸 종류만 바뀜 — 앞서 쓴 종단은 남음 · 횡단 = 측점 설계 잠정 몫 invalidated, stale = write_drawing_scales( project, project / "none.json", {"cross": {"x": 100, "y": 50}} ) assert (invalidated, stale) == ({"cross": 1}, [20]) assert read_drawing_scales(project)["longitudinal"]["x"] == 500 def test_GET_PUT_범위_밖_400(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: async def source(_project_id): return 1, project, project / "none.json", False monkeypatch.setattr(scale_router, "_confirmed_source", source) app = FastAPI() app.include_router(scale_router.router) client = TestClient(app) url = "/api/projects/9872e9a7-e483-427b-a6b2-72cdbc063bc3/drawing-scales" got = client.get(url).json() assert got["scales"] == DRAWING_SCALE_DEFAULTS assert got["ranges"]["cross"]["x"] == [10, 1000] assert got["saved"] == {"longitudinal": 0, "cross": 0, "mass_haul": 0, "development": 0} bad = client.put(url, json={"scales": {"cross": {"x": 5}}}) assert bad.status_code == 400 and "1/10~1/1,000" in bad.json()["message"] assert not drawing_scales_path(project).exists() # 거절 = 안 씀 put = client.put(url, json={"scales": {"longitudinal": {"x": 500, "y": 200}}}) assert put.status_code == 200 assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": 5000} assert put.json()["invalidated"] == {}