- 알약: 예상노선 추적 · 토공 최소 · 기울기 순한 · 짧은 노선(short) · 흙 균형(balance) · 비중 계산 — 짧은 노선 · 흙 균형은 비중 없음 - 비교값에 「남는 흙」(surplus_m3 · + 남음 / − 모자람) - 응답 similar · band_width_m → 비교값 아래 「이 지형은 제약 안에서 갈 길이 폭 …」 한 줄 + 흐린 안내 · 없으면 줄 숨김 - 시험: 가짜 응답 · 여섯 이름 · 남는 흙 · 알림 줄 · 비중 계산 사유 · 왕복 시험에 short · balance 더함 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
305 lines
14 KiB
Python
305 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""계획노선 편집 창 — **초기노선 갈래**(계획서 20) 셈 회귀검증.
|
||
|
||
비중 슬라이더 셋은 합 100 을 지키고(하나를 옮기면 나머지가 비율대로), 서버 답은 노드 목록 꼴과
|
||
`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 기준값 확정(2026-09-29) 뒤로는
|
||
비교값 · 기준값 글에 「후보 · 사용자 확정 대기」 꼬리표가 안 붙는다. 화면 제약 칸에 사용자가
|
||
겹쳐 넣은 값(`criteriaOverrides`)만 캐시·서버 `current_initial` 과 오가고, 그 겹침이 서버 것과
|
||
다르면 캐시를 버린다(`sameBase`). 마지막 초기값은 결과 통 프로젝트 칸.
|
||
|
||
Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
|
||
import pytest
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def results():
|
||
proc = subprocess.run(
|
||
["node", os.path.join(HERE, "helper_b05_routeedit_initial.cjs")],
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
timeout=60,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
return json.loads(proc.stdout)
|
||
|
||
|
||
def test_비중은_늘_합_100_정수(results):
|
||
for weights in results["moves"]:
|
||
assert list(weights) == ["expected", "earthwork", "grade"]
|
||
assert sum(weights.values()) == 100
|
||
assert all(isinstance(v, int) and 0 <= v <= 100 for v in weights.values())
|
||
|
||
|
||
def test_비중은_나머지를_지금_비율대로_나눔(results):
|
||
moves = results["moves"]
|
||
assert moves[0] == {"expected": 70, "earthwork": 15, "grade": 15}
|
||
assert moves[1] == {"expected": 40, "earthwork": 40, "grade": 20}
|
||
# 나머지가 모두 0 이면 반씩.
|
||
assert moves[2] == {"expected": 60, "earthwork": 20, "grade": 20}
|
||
assert moves[3] == {"expected": 0, "earthwork": 0, "grade": 100}
|
||
assert moves[5]["grade"] == 0
|
||
|
||
|
||
def test_노드_목록_꼴을_편집값으로(results):
|
||
snap = results["plain"]["snapshot"]
|
||
assert snap["planned"] == [[0, 0], [50, 10], [100, 0]]
|
||
assert snap["curveOn"] == [True, True, False]
|
||
assert snap["curveRadius"] == [None, 30, None]
|
||
assert snap["curveLock"] == [None, None, None]
|
||
assert snap["apexLock"] == [False, False, False]
|
||
assert snap["picked"] == -1
|
||
|
||
|
||
def test_노선계획_꼴도_같은_규칙으로(results):
|
||
shape = results["planShape"]
|
||
snap = shape["snapshot"]
|
||
assert snap["planned"] == [[0, 0], [40, 30], [90, 30]]
|
||
assert snap["curveOn"] == [False, True, False]
|
||
assert snap["curveRadius"] == [None, 25, None]
|
||
assert shape["weights"] == {"expected": 60, "earthwork": 20, "grade": 20}
|
||
assert shape["metrics"] is None and shape["criteria"] is None
|
||
|
||
|
||
def test_노드가_모자라면_싣지_않음(results):
|
||
assert "모자랍니다" in results["tooShort"]
|
||
|
||
|
||
def test_비교값과_기준값_글(results):
|
||
labels = results["labels"]
|
||
assert labels["follow"] == "예상노선 추적"
|
||
assert labels["weighted"] == "비중 계산 60/20/20"
|
||
assert labels["metrics"] == (
|
||
"연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥"
|
||
" · 기준 초과 2구간 · 이격 3.3m"
|
||
)
|
||
assert "로직을 누르면" in labels["metricsNone"]
|
||
# 기준값 확정(2026-09-29) — 「후보 · 사용자 확정 대기」 꼬리표가 더는 안 붙는다.
|
||
assert labels["criteria"] == (
|
||
"공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m · 기울기 잴 지반 5m 고름"
|
||
)
|
||
assert "확정 대기" not in labels["criteria"]
|
||
assert labels["criteriaFallback"] == "공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ —"
|
||
assert "확정 대기" not in labels["criteriaFallback"]
|
||
|
||
|
||
def test_제약_못_지킨_구간과_상한_풂(results):
|
||
labels = results["labels"]
|
||
assert labels["violations"] == (
|
||
"제약 못 지킨 구간 3곳: No.9+0.0 29.1%(지형상 불가능)"
|
||
" · No.20+0.0 R 9.5m(곡선 둘 자리 모자람) · No.30+0.0 계류 32.0m(계류 버퍼 안)"
|
||
)
|
||
assert labels["violationsNone"] == "제약 못 지킨 구간 없음 — 모두 지킴"
|
||
assert labels["violationsOld"] == "" # 옛 답(목록 칸 없음)은 줄을 숨긴다
|
||
assert labels["violationsMany"].startswith(
|
||
"제약 못 지킨 구간 7곳: No.0+0.0 15.0%(곡선 맞춤 뒤 지반 차)"
|
||
)
|
||
assert labels["violationsMany"].endswith(" 외 2곳")
|
||
assert labels["relaxed"] == "지형상 14.0% 로 못 이어져 상한을 23.5% 로 풂"
|
||
assert labels["notRelaxed"] == ""
|
||
|
||
|
||
def test_서버가_적어_둔_현재_초기값(results):
|
||
bases = results["bases"]
|
||
assert bases["follow"] == {"logic": "follow", "weights": None, "criteria": None}
|
||
assert bases["weighted"] == {
|
||
"logic": "weighted",
|
||
"weights": {"expected": 70, "earthwork": 15, "grade": 15},
|
||
"criteria": {"stream_offset_m": 50},
|
||
}
|
||
assert bases["unknown"] == {"logic": "follow", "weights": None, "criteria": None}
|
||
assert bases["none"] == {"logic": "follow", "weights": None, "criteria": None}
|
||
same = results["sameAsServer"]
|
||
assert same == {
|
||
"earthVsFollow": False,
|
||
"earthVsEarth": True,
|
||
# 같은 갈래라도 넣은 제약 겹침(계류 이격 50m)이 서버 기억(안 겹침)과 다르면 캐시를 버린다.
|
||
"earthVsEarthDiffCriteria": False,
|
||
"weightedSame": True,
|
||
"weightedOther": False,
|
||
}
|
||
|
||
|
||
def test_캐시는_결과_통_프로젝트_칸(results):
|
||
assert results["keys"] == [
|
||
"aislo:result:routeedit-initial:p-1",
|
||
"aislo:result:routeedit-initial:p-3",
|
||
]
|
||
assert results["readBack"]["logic"] == "earthwork"
|
||
assert results["readBack"]["snapshot"]["planned"] == [[0, 0], [50, 10], [100, 0]]
|
||
# 화면 칸에 아무것도 안 겹쳤으면 `criteriaOverrides` 는 null.
|
||
assert results["readBack"]["criteriaOverrides"] is None
|
||
# 계류 이격만 겹쳐 물었으면 그 한 칸만 남는다(나머지는 기본값이라 안 담김).
|
||
assert results["readBackOverrides"]["criteriaOverrides"] == {"stream_offset_m": 50}
|
||
assert results["other"] is None
|
||
assert results["badLogic"] is None
|
||
assert results["cleared"] is None
|
||
|
||
|
||
def test_전처리_초기값_노선을_편집값으로(results):
|
||
snap = results["initialRoute"]["ok"]
|
||
assert snap["planned"] == [[0, 0], [10, 5]]
|
||
assert snap["curveRadius"] == [None, 20]
|
||
assert snap["picked"] == -1
|
||
# 서버가 안 주거나 노드가 모자라면 null — 화면이 [초기화]를 안 한다.
|
||
assert results["initialRoute"]["none"] is None
|
||
assert results["initialRoute"]["tooShort"] is None
|
||
|
||
|
||
def test_미리_보기_지도_선(results):
|
||
lines = results["mapLines"]
|
||
# 폭 460 · 높이 340 · 가장자리 12 — 남는 쪽은 가운데 · 북쪽이 위(y 뒤집기).
|
||
assert lines["fit"] == [
|
||
{"kind": "expected", "d": "M12.0 279.0L448.0 279.0"},
|
||
{"kind": "preview", "d": "M12.0 279.0L448.0 61.0"},
|
||
]
|
||
assert lines["empty"] == []
|
||
assert lines["oneDot"] == [] # 점 하나로는 선이 안 됨
|
||
|
||
|
||
def test_도구_막대는_노선_추천_하나만():
|
||
"""편집 창 도구 막대 — [노선 추천] 하나 · 추천 묶음(로직 단추 · 슬라이더 · 제약 칸) 없음 ·
|
||
「연 상태로」 없음. 그 묶음은 모달(`_Recommend`)로 옮겼다."""
|
||
page = os.path.join(HERE, "..", "..", "B05_Profile")
|
||
chrome = open(os.path.join(page, "B05_Profile_UI_RouteEdit_Chrome.ts"), encoding="utf-8").read()
|
||
assert chrome.count('data-act="recommend"') == 1
|
||
assert "연 상태로" not in chrome and "history-reset" not in chrome
|
||
assert chrome.index('data-act="recommend"') < chrome.index('data-act="contour-minor"')
|
||
initial = open(
|
||
os.path.join(page, "B05_Profile_UI_RouteEdit_Initial.ts"), encoding="utf-8"
|
||
).read()
|
||
assert "data-criteria" not in initial and "data-weight" not in initial
|
||
recommend = open(
|
||
os.path.join(page, "B05_Profile_UI_RouteEdit_Recommend.ts"), encoding="utf-8"
|
||
).read()
|
||
for name in ("follow", "earthwork", "gentle", "weighted"):
|
||
assert f'"{name}"' in recommend
|
||
|
||
|
||
def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results):
|
||
"""걸음마다 「현재 추천」이 노선과 짝 — 추천 없음(null)도 그대로 되돌아온다."""
|
||
assert results["historyRecs"] == [
|
||
{"logic": "earthwork"},
|
||
{"logic": "follow"},
|
||
{"logic": "earthwork"},
|
||
None,
|
||
]
|
||
|
||
|
||
def test_노선_추천_다듬기_23_3():
|
||
"""구분선은 [노선 추천] 오른쪽 · 모달 지도는 편집 창 함수를 가져다 씀 · 모달이 라벨 패널 위."""
|
||
page = os.path.join(HERE, "..", "..", "B05_Profile")
|
||
|
||
def read(name):
|
||
return open(os.path.join(page, name), encoding="utf-8").read()
|
||
|
||
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
|
||
rec = chrome.index('data-act="recommend"')
|
||
divider = chrome.index("b05-routeedit__divider", rec)
|
||
assert rec < divider < chrome.index('data-act="contour-minor"')
|
||
assert chrome.count("b05-routeedit__divider") == 2
|
||
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
|
||
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
|
||
assert f"{name}(" in shared
|
||
css = read("B05_Profile_UI_Style_RouteRecommend.css")
|
||
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
|
||
assert "writing-mode: vertical-lr" in css
|
||
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
|
||
|
||
|
||
def test_비중_고정_합_100_규칙(results):
|
||
"""고정한 값은 안 움직이고 나머지가 합 100 — 어떤 고정 · 어떤 값으로 옮겨도 정수 0~100 · 합 100."""
|
||
assert all(row["sum"] == 100 and row["lockedSame"] and row["range"] for row in results["sweep"])
|
||
w = results["weightLocks"]
|
||
# 안 고정: 지금 비율대로 나눔(기존 규칙)
|
||
assert w["freeThree"] == {"expected": 40, "earthwork": 30, "grade": 30}
|
||
# 예상 70 고정 → 토공을 끌면 기울기만 따라 바뀜
|
||
assert w["lockExpected"] == {"expected": 70, "earthwork": 20, "grade": 10}
|
||
# 남는 몫(30)을 넘게 끌면 잘림
|
||
assert w["lockExpectedClamp"] == {"expected": 70, "earthwork": 30, "grade": 0}
|
||
# 둘 고정 → 셋째는 저절로 정해져 안 움직임
|
||
assert w["twoLocked"] == {"expected": 70, "earthwork": 20, "grade": 10}
|
||
assert w["movingLocked"] == {"expected": 70, "earthwork": 15, "grade": 15}
|
||
assert w["derived"] == [None, None, "grade"]
|
||
# 셋 다 고정은 막음 · 푸는 것은 늘 됨
|
||
assert w["canLock"] == {"none": True, "one": True, "two": False, "unlockOfTwo": True}
|
||
|
||
|
||
def test_비중_값_칸_직접_입력(results):
|
||
"""0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않음(빨간 테두리 · 안 보냄)."""
|
||
i = results["weightLocks"]["input"]
|
||
assert i["free"] == {"ok": True, "weights": {"expected": 37, "earthwork": 55, "grade": 8}}
|
||
assert i["lockedOk"] == {"ok": True, "weights": {"expected": 70, "earthwork": 20, "grade": 10}}
|
||
for name in ("lockedTooBig", "fraction", "negative", "over", "nan", "onLocked"):
|
||
assert i[name]["ok"] is False, name
|
||
assert i[name]["weights"] == {"expected": 70, "earthwork": 15, "grade": 15}, name
|
||
assert i["derivedSame"]["ok"] is True
|
||
assert i["derivedOther"] == {
|
||
"ok": False,
|
||
"weights": {"expected": 70, "earthwork": 20, "grade": 10},
|
||
}
|
||
|
||
|
||
def test_추천_갈래_여섯_이름과_가중치_없음(results):
|
||
six = results["sixth"]
|
||
assert six["labels"] == {
|
||
"follow": "예상노선 추적",
|
||
"earthwork": "토공 최소",
|
||
"gentle": "기울기 순한",
|
||
"short": "짧은 노선",
|
||
"balance": "흙 균형",
|
||
"weighted": "비중 계산",
|
||
}
|
||
# 짧은 노선 · 흙 균형은 비중이 없다(weights 0/0/0 이 와도 이름만)
|
||
assert six["shortLabel"] == "짧은 노선" and six["balanceLabel"] == "흙 균형"
|
||
page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts")
|
||
with open(page, encoding="utf-8") as handle:
|
||
text = handle.read()
|
||
order = [
|
||
text.index(f'"{name}"') for name in ("follow", "earthwork", "gentle", "short", "balance")
|
||
]
|
||
assert order == sorted(order) # 알약 순서: 추적 · 토공 · 기울기 · 짧은 · 균형 · 비중
|
||
|
||
|
||
def test_비교값_남는_흙(results):
|
||
six = results["sixth"]
|
||
assert six["surplus"] == ["+122㎥(남음)", "−578㎥(모자람)", "0㎥(균형)", "0㎥(균형)"]
|
||
assert six["metricsPlus"].endswith(
|
||
"성토 900㎥ · 남는 흙 +122㎥(남음) · 기준 초과 2구간 · 이격 3.3m"
|
||
)
|
||
assert "남는 흙 −578㎥(모자람)" in six["metricsMinus"]
|
||
# 서버가 안 주면 칸이 없다(옛 답) — 기존 비교값 글 그대로
|
||
assert "남는 흙" not in six["metricsNone"]
|
||
|
||
|
||
def test_같은_노선_알림_줄(results):
|
||
six = results["sixth"]
|
||
assert six["similarTwo"] == (
|
||
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — "
|
||
"토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)"
|
||
)
|
||
assert six["similarOne"] == (
|
||
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 흙 균형과 거의 같은 노선(가장 먼 거리 6.1m)"
|
||
)
|
||
# 띠 폭이 없어도 줄은 선다 · 받침 없는 이름은 「와」
|
||
assert "폭" not in six["similarNoBand"] and "비중 계산과 거의" in six["similarNoBand"]
|
||
# 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음
|
||
assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == ""
|
||
assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목은 버림
|
||
assert six["bandParsed"] == 13.5
|
||
assert "제약 값을 바꾸면" in six["hint"]
|
||
|
||
|
||
def test_비중_계산_넘은_구간_사유_그대로(results):
|
||
six = results["sixth"]
|
||
assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)"
|
||
assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)"
|