⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt` 가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는 시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음. - `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침** - `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시) - 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q` 옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0 ⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
298 lines
15 KiB
Python
298 lines
15 KiB
Python
"""한 계통 회귀 묶음 — **사용자가 넣은 값이 재계산·재진입·다른 경유에서 사라지거나 갈리는 것**.
|
|
|
|
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<Record<string, number>>("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, "절토 경사가 사용자 값 보존 목록에서 빠졌다"
|