"""한 계통 회귀 묶음 — **사용자가 넣은 값이 재계산·재진입·다른 경유에서 사라지거나 갈리는 것**. 2026-09-07 하루에 이 계통으로만 일곱 건이 나왔다. 하나하나는 자리도 원인도 다르지만 증상은 같다 — **사용자는 넣었는데 결과에는 없다**. 그래서 각 고침의 시험은 제자리에 두고 (사연이 그 시험 머리에 적혀 있다), 여기에는 **같은 계통을 한 번에 도는 얇은 묶음**을 둔다. 다음에 어느 하나가 다시 새면 이 파일이 먼저 깨진다. 각 줄에 어느 결함의 재발인지 커밋과 한 줄 사연을 적었다. 셋을 지킨다 — ① 값을 넣는다 ② **재계산·재진입을 태운다** ③ 값이 그대로인지 수치로 본다. ②를 건너뛰고 저장만 보는 시험은 이 계통을 못 잡는다(일곱 건 전부 ②에서 샜다). 화면(브라우저)이 있어야 도는 것은 넣지 않았다 — 이 묶음은 `pytest` 로 언제든 돌아야 한다. """ import math import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402 from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 MIN_RADIUS = 12.0 # ── ① [확인]을 누를 때마다 계획노선이 깎이던 것 (`19444149`) ──────────────────── # 낸 결과를 다시 입력으로 넣으면 또 단순화돼 노선이 회차마다 짧아졌다. def test_노선은_다시_넣어도_안_깎인다(): points = [(float(x), 6.0 * math.sin(x / 40.0)) for x in range(0, 600, 5)] once = build_planned_polyline(points, min_radius_m=MIN_RADIUS) twice = build_planned_polyline( [(node.x, node.y) for node in once.nodes], min_radius_m=MIN_RADIUS, simplify=False, curve_flags=[True] * len(once.nodes), radii=[None] * len(once.nodes), ) assert len(twice.nodes) == len(once.nodes), ( f"두 번째에 꺾임점이 {len(once.nodes)} → {len(twice.nodes)} 로 줄었음" ) # ── ② 편집 한 번에 맞춰 둔 반지름이 하한으로 눌리던 것 (`a0bd9c87`) ───────────── # 묶인 곡선의 교각점은 원본 꺾임점이 아니라, 그대로 되넣으면 묶음이 흩어져 R 이 하한이 됐다. def _arc(radius_m: float) -> list[tuple[float, float]]: points = [(float(x), 0.0) for x in range(-150, 0, 3)] for degree in range(0, 91, 3): angle = math.radians(degree) points.append((radius_m * math.sin(angle), radius_m - radius_m * math.cos(angle))) points += [(radius_m, radius_m + y) for y in range(3, 150, 3)] return points def _flatten(result): """화면이 모달을 열 때 하는 것 — 묶인 구간을 교각점 하나로 갈아 끼운다.""" replaced = {curve.node_first: curve for curve in result.curves} dropped = { index for curve in result.curves for index in range(curve.node_first + 1, curve.node_last + 1) } nodes, radii = [], [] for index, node in enumerate(result.nodes): if index in dropped: continue curve = replaced.get(index) nodes.append(curve.apex if curve else (node.x, node.y)) radii.append(curve.radius_m if curve else None) return nodes, radii def test_편집을_거쳐도_맞춰_둔_반지름이_남는다(): first = build_planned_polyline(_arc(40.0), min_radius_m=MIN_RADIUS) nodes, radii = _flatten(first) again = build_planned_polyline( nodes, min_radius_m=MIN_RADIUS, simplify=False, curve_flags=[True] * len(nodes), radii=radii, ) kept = max(curve.radius_m for curve in again.curves) assert kept > 30.0, f"맞춰 둔 R 이 {kept:.1f}m 로 눌렸음" def test_음성대조_갈아_끼우지_않으면_눌린다(): """그물이 헛돌지 않는다는 증명 — 갈아 끼우기를 빼면 하한으로 눌린다.""" first = build_planned_polyline(_arc(40.0), min_radius_m=MIN_RADIUS) raw = [(node.x, node.y) for node in first.nodes] again = build_planned_polyline( raw, min_radius_m=MIN_RADIUS, simplify=False, curve_flags=[True] * len(raw), radii=[None] * len(raw), ) assert max(c.radius_m for c in again.curves) <= MIN_RADIUS + 1e-6 # ── ③ 재계산이 다단 구간값을 지우던 것 (`b6941bd2`) ──────────────────────────── _USER_VALUES = { "extra_spans": {"extra0": {"length_m": 15.0, "before_m": 8.0, "after_m": 7.0}}, "extra_wall_counts": {"outlet": 1, "basin": 0}, "revet_adjust": {"outlet": {"x": 2.0, "d": 0.5, "h": None, "m": None}}, "display_half_width_m": 12.5, } def _fake_engine(monkeypatch): """지형·계획고·포장구간은 이 시험의 대상이 아니다 — 재계산 자리만 본다.""" def fake_compute(samples, elevation, **kwargs): return {"paved": True, "cut_area_m2": 1.0, "fill_area_m2": 2.0} monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)]) monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) def _section() -> dict: return {"chainage_m": 100.0, "samples": [], "design": {"paved": False, **_USER_VALUES}} def test_포장_재계산이_구조물_조작값을_안_지운다(monkeypatch): _fake_engine(monkeypatch) sections = [_section()] assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 for key, value in _USER_VALUES.items(): assert sections[0]["design"].get(key) == value, f"{key} 가 재계산에서 사라짐" def test_음성대조_목록에서_빼면_사라진다(monkeypatch): """그물이 헛돌지 않는다는 증명 — 보존 목록에서 빼면 그 값이 즉시 사라진다.""" _fake_engine(monkeypatch) trimmed = tuple(k for k in design_mod.USER_TOUCHED_KEYS if k != "extra_spans") monkeypatch.setattr(design_mod, "USER_TOUCHED_KEYS", trimmed) sections = [_section()] design_mod.enforce_pavement_ranges({}, sections, Path("."), None) assert sections[0]["design"].get("extra_spans") is None # ── ④ 사용자가 켠 2단 절토가 재계산에서 사라지던 것 (`b8f0f7ad`) ──────────────── # 엔진이 **적용 결과**를 저장해, 못 쓰는 자리(토사)에서 켬이 false 로 굳고 되먹여졌다. def _ground(slope: float = 0.35) -> list[dict]: samples, offset = [], -12.0 while offset <= 12.0001: samples.append( { "offset_m": round(offset, 3), "elevation_m": round(100.0 + slope * offset, 4), "valid": True, } ) offset += 0.5 return samples def test_토사에서_켠_2단_절토가_왕복해도_남는다(): def once(flag: bool) -> dict: return compute_cross_design( _ground(), 100.4, ground_type="soil", section_mode="left_cut", ditch_side=None, ditch_type="standard", paved=False, standard=None, rock_boundary_offset_m=-1.5, two_stage_slope=flag, ditch_enabled=None, ) first = once(True) assert first["two_stage_slope"] is True assert once(bool(first["two_stage_slope"]))["two_stage_slope"] is True # ── ⑤ 노선 변경 때 옛 사용자 설계가 덮여 사라지던 것 (`908c75d5`) ─────────────── # 이월을 측점 재생성 **앞**에서 하면 재생성이 덮어썼다. 순서가 곧 고침이라 순서를 지킨다. def test_이월은_측점_재생성_뒤에_한다(): source = (PROJECT_ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text( encoding="utf-8" ) rebuild = source.index("_prepare_drainage_pipes_and_reprofile(project_id, new_route_id") carry = source.index("merge_cross_section_designs(") assert carry > rebuild, "이월이 측점 재생성보다 앞섰음 — 덮어써서 사라진다" # ── ⑥ 암 경계선 오프셋을 읽는 쪽이 옛 키를 보던 것 (`f8fafd23`) ───────────────── def test_암경계선은_등록표_키로_읽는다(): persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text( encoding="utf-8" ) assert 'readState>("rockb"' in persist # ── ⑦ 저장된 표준 횡단면이 새 탭에서 안 서던 것 (`72e50b3e`) ─────────────────── def test_저장된_표준단면이_브라우저까지_간다(): from B06_Section.B06_Section_Schema import SectionContextResponse assert "stored_standard_cross_section" in SectionContextResponse.model_fields fetch = (PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts").read_text(encoding="utf-8") assert "function seedStandardCross(" in fetch assert fetch.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움" # ── ⑨ 다단 기슭막이가 물량에 안 닿던 것 (`e9ad8cdf`) ────────────────────────── # 다단 벽의 성토부선이 면적 트림에 없어, 단을 올려도 폐회로가 그대로라 **물량이 안 바뀌었다**. # 사용자가 「그대로 둠」으로 확정한 변경이라(3-5) 되돌아가면 조용히 금액이 줄어든다. # ⚠ 값이 아니라 구조로 본다 — 면적을 실제로 내려면 `structureAreaRows` 를 태워야 하는데 # 그 의존 사슬이 `@ui/ui_template_elements`(DOM)까지 끌고 와 화면 없이 못 돈다. # 값으로 재는 확인은 화면 실측에 있음(보조 창 b269ea34 81.77m — 단 0 → 1 에서 성토 # 10.9027 → 10.6743㎡). 계획서에 그 수치가 남아 있다. def test_다단_성토부선이_면적_트림에_들어간다(): source = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text( encoding="utf-8" ) assert "const extendTrimSlope = (" in source, "트림 확장 자리가 사라짐" # 유출측·집수정 계류측 **둘 다** — 한쪽만 넣으면 그쪽 단만 물량에 잡힌다. assert "extendTrimSlope(drawnFillPoints(extras)" in source, "유출측 다단이 빠짐" assert "extendTrimSlope(drawnFillPoints(basinExtras)" in source, "집수정 계류측 다단이 빠짐" # 그리지 않는 `cut` 갈래(벽이 묻힌 자리)까지 넣으면 면적이 거꾸로 부푼다. assert 'segment.kind !== "cut"' in source, "묻힌 구간을 안 걸러냄" # ── ⑧ 암 경계선이 전 구간 재계산 뒤에도 남는 것 (`f8fafd23`) ────────────────── # 보조 창 화면 실측(8879c53b) — 180.0m 에서 경계를 -0.5 → -0.9m 로 내리니 절토가 # 토사 1.83 → 3.22 · 리핑암 1.78 → 0.89 로 갈렸고, **종단 계획고를 한 칸 올려 전 측점을 # 다시 계산한 뒤에도** 그대로였다(손 안 댄 196.1m 도 그대로). 그 창구를 오프셋을 빼고 # 부르면 A 가 3.5178 → 1.9989㎡ 로 되돌아갔다 — 값이 실제로 실려 간다는 음성 대조다. # 여기서는 같은 성질을 엔진에서 수치로 본다: **경계를 내리면 토사가 늘고 암이 준다.** def test_암_경계선을_내리면_토사가_늘고_암이_준다(): def once(offset_m: float) -> dict: return compute_cross_design( _ground(0.45), 100.6, ground_type="ripping_rock", section_mode="left_cut", ditch_side=None, ditch_type="standard", paved=False, standard=None, rock_boundary_offset_m=offset_m, two_stage_slope=True, ditch_enabled=None, ) shallow, deep = once(-0.5), once(-0.9) assert shallow["cut_soil_area_m2"] > 0 and shallow["cut_rock_area_m2"] > 0, ( "토사·암이 둘 다 나오는 자리라야 나눔이 갈리는 것을 볼 수 있음" ) assert deep["cut_soil_area_m2"] > shallow["cut_soil_area_m2"], ( "경계를 내렸는데 토사가 안 늘었음" ) assert deep["cut_rock_area_m2"] < shallow["cut_rock_area_m2"], "경계를 내렸는데 암이 안 줄었음" # ⚠ 합계는 안 잡는다 — 2단 절토의 무릎이 경계와 함께 움직여 **총 절토도 달라진다** # (실측 0.9153 → 1.0512㎡). 여기서 지키는 것은 「경계가 나눔에 실제로 닿는다」이다. # ── ⑩ 소단이 재계산에서 계단째 사라지던 것 (2026-09-07, 25 가 고침) ───────────── # 소단은 다른 사용자 값과 **성질이 다르다** — 값을 나르는 게 아니라 **기하 입력**이다. # 그래서 계산이 끝난 뒤 키만 베껴 붙이면 **설계선·면적은 계단 없이 나오고 `berm` 값만 남아** # 저장분과 그림이 어긋난다. 고침은 순서였다: 저장분 소단을 **계산 전에** 읽어 넣는 것. # 이 줄은 그 순서를 지킨다 — 되돌리면 「값은 있는데 계단이 없는」 자리가 되살아난다. def test_소단은_계산_전에_읽어_넣는다(): source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Router_Design.py").read_text( encoding="utf-8" ) # 재계산이 저장분 소단을 **인자로** 넣는다(계산 뒤 베껴 붙이는 것이 아니다). assert source.count("berm=stored_berm(design)") >= 2, ( "포장·세월교 재계산 경로가 저장분 소단을 안 싣는다 — 계단이 사라진다" ) # 보존 목록에도 있어야 사용자가 놓은 값이 지워지지 않는다. assert '"berm"' in source, "소단이 사용자 값 보존 목록에서 빠졌다" # ── ⑪ 절토 경사도 같은 성질 — 「기하 입력」이다 (2026-09-07 사용자 지시) ───────── # 사용자가 카드에 넣는 암 절토각은 소단과 같은 자리다. 계산 뒤에 키만 베껴 붙이면 설계선은 # 옛 경사로 그려지고 숫자만 새것이 된다. 게다가 **표준 횡단면 설정을 바꿔도 개별로 고친 # 측점은 그대로**여야 한다(사용자 원문: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」). def test_절토_경사도_계산_전에_넣는다(): source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Router_Design.py").read_text( encoding="utf-8" ) # 재계산 세 경로(포장 강제·세월교 하강·선형 재계산)가 저장분 경사를 **인자로** 넣는다. assert source.count("cut_slope_ratio=") >= 3, ( "재계산 경로가 저장분 절토 경사를 안 싣는다 — 그림과 값이 어긋난다" ) assert '"cut_slope_ratio_user"' in source, "절토 경사가 사용자 값 보존 목록에서 빠졌다"