Files
Aislo/resources/tester/test_b05_routeedit_initial.py
T
eomsangdonandClaude Sonnet 5.5 2deef5a5da feat(B05): 노선 추천 — 알약 여섯(짧은 노선 · 흙 균형) · 남는 흙 비교값 · 같은 노선 알림 줄 · 비중 고정 시험 (PLAN 23-4c 화면 · 26-2c)
- 알약: 예상노선 추적 · 토공 최소 · 기울기 순한 · 짧은 노선(short) · 흙 균형(balance) · 비중 계산 — 짧은 노선 · 흙 균형은 비중 없음
- 비교값에 「남는 흙」(surplus_m3 · + 남음 / − 모자람)
- 응답 similar · band_width_m → 비교값 아래 「이 지형은 제약 안에서 갈 길이 폭 …」 한 줄 + 흐린 안내 · 없으면 줄 숨김
- 시험: 가짜 응답 · 여섯 이름 · 남는 흙 · 알림 줄 · 비중 계산 사유 · 왕복 시험에 short · balance 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 08:10:09 +09:00

305 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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%(비중 계산)"