feat(B05): 노선 추천 종단 반영 켜기·끄기 + 갈래 설명 한 쪽씩 넘기기

- 64-1: 추천 창 「종단 반영」 체크(기본 켬) — 요청 with_profile 로 서버가 가름 · 끄면 원지반만(63-4 전 점수) · 묶음 캐시에 남은 종단은 걷어 냄 · 값은 편집 창 안에서만(저장 안 함)
- 64-2: 설명 칸을 갈래 한 쪽씩 ◀ ▶ + 「n / 8」 · 고른 갈래 쪽부터 · 토공 최소 · 공사비 어림 최소 쪽에 종단 반영 설명 추가
- 시험: test_64_1_profile_toggle.py · ui_checks 64-1 · 64-2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-06 23:14:14 +09:00
co-authored by Claude Sonnet 5.5
parent d42efb162b
commit 89433a352d
13 changed files with 309 additions and 34 deletions
@@ -0,0 +1,73 @@
"""추천 「종단 반영」 켜기 · 끄기(PLAN 64-1) — 점수 분기 시험.
./venv/Scripts/python.exe -m pytest resources/tester/test_64_1_profile_toggle.py -q
켬(기본) = 후보마다 균형 종단을 세워 토공 점수를 그것으로(63-4) · 끔 = 종단 없이 원지반 가늠
(절 + 성). 끈 요청은 켠 요청이 묶음 캐시에 남긴 종단을 걷어 내고 처음부터 끈 것과 같아야 한다.
"""
import numpy as np
import pytest
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import COST_EARTH_M3
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
from B05_Profile.B05_Profile_Router_Replan import RouteInitialRequest
GRADE = 0.14
RADIUS = 12.0
@pytest.fixture(scope="module")
def hill() -> Terrain:
x = np.arange(0.0, 802.0, 2.0)
y = np.arange(0.0, 402.0, 2.0)
xx, yy = np.meshgrid(x, y)
z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
def _run(terrain: Terrain, mode: str, with_profile: bool) -> dict:
expected = [(float(x), 200.0) for x in np.arange(50.0, 752.0, 4.0)]
return generate_initial_route(
expected,
terrain,
[],
mode=mode,
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
with_profile=with_profile,
)
@pytest.mark.parametrize("mode", ["earthwork", "cost", "follow"])
def test_켬은_종단_끔은_원지반(hill: Terrain, mode: str) -> None:
on = _run(hill, mode, True)
off = _run(hill, mode, False)
assert on["with_profile"] is True and off["with_profile"] is False
assert "error" not in on["metrics"]["profile"]
assert "profile" not in off["metrics"] and "earth_estimate_m3" not in off["metrics"]
# 끔 점수 = 절 + 성 · 켬 점수 = 균형 종단 토공
assert GOALS["earthwork"](off["metrics"]) == (
off["metrics"]["cut_m3"] + off["metrics"]["fill_m3"],
)
assert GOALS["earthwork"](on["metrics"]) == (profile_earth(on["metrics"]["profile"]),)
# 공사비 어림 = 켬은 토공 몫을 종단 점수로 바꾼 값(고른 노선이 달라도 그 노선의 처음 값 기준)
metrics = on["metrics"]
shift = COST_EARTH_M3 * (profile_earth(metrics["profile"]) - metrics["earth_estimate_m3"])
assert metrics["cost_index"] == pytest.approx(metrics["cost_index_base"] + shift, abs=1.0)
def test_끈_요청은_묶음_캐시의_종단을_걷어_낸다(hill: Terrain) -> None:
first = _run(hill, "cost", False)
_run(hill, "cost", True) # 같은 묶음 캐시에 종단을 붙임
again = _run(hill, "cost", False)
assert again["metrics"] == first["metrics"] and again["planned"] == first["planned"]
assert "profile" not in again["metrics"]
def test_요청_기본은_켬이고_끔을_받는다() -> None:
assert RouteInitialRequest(mode="earthwork").with_profile is True
assert RouteInitialRequest(mode="earthwork", with_profile=False).with_profile is False
@@ -0,0 +1,18 @@
/* 64-1 — B05 노선 추천 「종단 반영」 켬 · 끔 점수 비교
* 시작 화면: B05 종단설계 → [계획노선 편집] → [노선 추천] 열림 · 검증 프로젝트(검증_sub1)
* 부르는 법: 실행 뒤 `window.__r` 를 읽음(끝나면 채워짐)
* 기대 수치: 끔 = 균형 종단 줄 · 종단 그림 · 덮개 줄 없음 · 토공 최소 절 + 성 = 원지반 가늠 ·
* 켬 = 균형 종단 줄 있음 · 토공 최소 노선 달라짐(연장 1,156.7 → 1,153.2 m) · 공사비 어림 6,206 → 18,606
*/
(()=>{window.__r=undefined;(async()=>{
const w=ms=>new Promise(r=>setTimeout(r,ms));
const q=s=>document.querySelector(s);
const waitDone=async()=>{const t0=Date.now();await w(400);for(;;){await w(300);const s=q('[data-state]').textContent;if(s.includes('미리 보기:')||s.includes('셈하지 못')) return (Date.now()-t0)/1000;if(Date.now()-t0>280000) return -1}};
const snap=()=>({m:q('[data-line=metrics]').textContent,w:q('[data-line=metrics-work]').textContent,bal:q('[data-line=balance]').textContent,prof:!q('[data-line=profile]').hidden,cands:q('[data-line=candidates]').textContent.length});
const setBox=v=>{if(q('[data-with-profile]').checked!==v) q('[data-with-profile]').click()};
const out={};
for(const l of ['earthwork','cost','gentle']){
setBox(false); q('[data-logic='+l+']').click(); const t1=await waitDone(); out[l+'_off']={t:t1,...snap()};
setBox(true); q('[data-act=rerun]').click(); const t2=await waitDone(); out[l+'_on']={t:t2,...snap()};
}
window.__r=out;})();return 'started'})()
@@ -0,0 +1,20 @@
/* 64-2 — B05 노선 추천 창 오른쪽 설명 = 한 갈래씩 ◀ ▶ 쪽 넘김
* 시작 화면: B05 종단설계 → [계획노선 편집] → [노선 추천] (첫 미리 보기가 뜬 뒤) · 검증 프로젝트(검증_sub1)
* 기대 수치: 쪽 수 = 갈래 수 = 8 · 한 번에 한 쪽만 보임 · 처음 쪽 = 고른 갈래 · 1 / 8 에서 ◀ 꺼짐 ·
* 8 / 8 에서 ▶ 꺼짐 · 다른 칸을 만져 다시 그려도 넘겨 보던 쪽 그대로
*/
(async()=>{
const w=ms=>new Promise(r=>setTimeout(r,ms));
const q=s=>document.querySelector(s);
const help=()=>({page:q('[data-help-page]').textContent,visible:[...document.querySelectorAll('[data-help-logic]')].filter(x=>!x.hidden).map(x=>x.dataset.helpLogic),on:[...document.querySelectorAll('[data-help-logic].is-on')].map(x=>x.dataset.helpLogic),prevDis:q('[data-help-nav="-1"]').disabled,nextDis:q('[data-help-nav="1"]').disabled,total:document.querySelectorAll('[data-help-logic]').length});
const o={};
o.start=help();
for(let i=0;i<10;i++){q('[data-help-nav="-1"]').click()} o.firstPage=help();
for(let i=0;i<10;i++){q('[data-help-nav="1"]').click()} o.lastPage=help();
q('[data-help-nav="-1"]').click(); o.prev=help();
// 렌더가 돌아도 넘겨 보던 쪽 유지 — 체크 토글로 render 유발
q('[data-with-profile]').click(); await w(100); o.afterRender=help();
q('[data-with-profile]').click();
const a=q('.b05-routerec__help'); const r=a.getBoundingClientRect(); o.helpBox={w:Math.round(r.width),h:Math.round(r.height),scrollH:a.scrollHeight};
const art=[...document.querySelectorAll('[data-help-logic]')].find(x=>!x.hidden); o.artText=art.innerText.slice(0,260);
return JSON.stringify(o,null,1)})()