Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
/* 476-1 추천 [적용] 초안 — 화면 `flattenServerPlan`(logic/B05_Profile_UI_RouteEdit_Edits.ts) +
|
||||
* `routeDraftOf` 칸 꼴(곡선 켬 = !== false · R ?? null)을 Node 로 돌려 JSON 한 줄
|
||||
* (`test_476_1_recommend_initial.py` 가 파이썬 `draft_nodes` 와 견줌).
|
||||
* node helper_476_1_flatten.cjs <후보 {nodes, curves} JSON 파일>
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const edits = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_RouteEdit_Edits.ts"));
|
||||
const body = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const flat = edits.flattenServerPlan(body.nodes, body.curves);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
nodes: flat.planned.map(([x, y]) => [x, y]),
|
||||
curveOn: flat.planned.map((_, i) => flat.curveOn[i] !== false),
|
||||
curveRadius: flat.planned.map((_, i) => flat.curveRadius[i] ?? null),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
/* 476-2 추천 모달 「적용될 초기 종단」 헬퍼 — `_Initial_Model` · `_Recommend_Profile` 을 그 자리에서
|
||||
* 트랜스파일해 Node 로 돌려 서버 `initial_profile` 펴기 · 한 줄 · 그림 · 캐시 빼기를 JSON 한 벌로.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
|
||||
globalThis.fetch = () => Promise.resolve({ ok: false });
|
||||
|
||||
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith("@ui/"))
|
||||
return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
|
||||
const prof = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend_Profile.ts"),
|
||||
);
|
||||
|
||||
// 476-1 응답 꼴(분석 2.3) — 결과 옆 `initial_profile`.
|
||||
const initialRaw = {
|
||||
fingerprint: "abc",
|
||||
with_profile: false,
|
||||
curve_length_m: 20,
|
||||
stations_m: [0, 20, 40],
|
||||
ground_m: [100, 103, 101],
|
||||
plan_m: [100, 102, 101],
|
||||
line: [
|
||||
[0, 100],
|
||||
[10, 101],
|
||||
[20, 102],
|
||||
[30, 101.5],
|
||||
[40, 101],
|
||||
],
|
||||
pvi_count: 3,
|
||||
curve_count: 1,
|
||||
straightened: 2,
|
||||
max_grade_pct: 10.04,
|
||||
warnings: [],
|
||||
seconds: 0.1,
|
||||
};
|
||||
const answer = {
|
||||
nodes: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0 },
|
||||
],
|
||||
curves: [],
|
||||
initial_profile: initialRaw,
|
||||
};
|
||||
const off = model.normalizeRouteInitial(answer, "follow", null, null, false);
|
||||
model.writeInitialChoice("p-476", off);
|
||||
const failed = model.normalizeRouteInitial(
|
||||
{ ...answer, initial_profile: { error: "지반고 종단 — 측점이 모자랍니다" } },
|
||||
"follow",
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
const balance = { stationsM: [0, 20, 40], groundM: [100, 103, 101], planM: [100, 101, 101] };
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
parsed: off.initialProfile,
|
||||
cached: model.readInitialChoice("p-476"),
|
||||
line: prof.initialLine(off.initialProfile),
|
||||
svgOff: prof.profileSvg(null, off.initialProfile),
|
||||
svgOn: prof.profileSvg(balance, off.initialProfile),
|
||||
svgOld: prof.profileSvg(balance),
|
||||
legendOff: prof.profileLegend(false, true),
|
||||
failed: failed.initialProfile,
|
||||
failedLine: prof.initialLine(failed.initialProfile),
|
||||
missing: model.normalizeRouteInitial({ ...answer, initial_profile: undefined }, "follow", null)
|
||||
.initialProfile,
|
||||
warnLine: prof.initialLine(prof.parseInitialProfile({ ...initialRaw, warnings: ["a", "b"] })),
|
||||
shortLine: prof.parseInitialProfile({ ...initialRaw, line: [[0, 100]] }),
|
||||
}),
|
||||
);
|
||||
@@ -487,7 +487,9 @@ def test_추천_적용은_초기값_다시(api_world):
|
||||
def test_균형_종단은_추천_적용만_손_저장은_계획고_옮김(api_world, monkeypatch):
|
||||
calls: list = []
|
||||
|
||||
async def recompute(project_id, root, stored, balanced=None, terrain=None, lines=None):
|
||||
async def recompute(
|
||||
project_id, root, stored, balanced=None, terrain=None, lines=None, initial=False
|
||||
): # 476 — [적용] = initial 칸 더함
|
||||
calls.append((balanced, lines))
|
||||
return {"route_id": 9, "total_length_m": 10.0}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_창_단추와_재계산() -> None:
|
||||
assert "rerun.disabled = busy || invalid.size > 0;" in rec # 늘 켜짐
|
||||
assert 'rerun.addEventListener("click", () => void run(true));' in rec
|
||||
assert "const withProfile = profileOn;" in rec
|
||||
assert "params.onApply(preview);" in rec
|
||||
assert "params.onApply(picked);" in rec # 476 — 확인 창 뒤 미리 본 그 후보
|
||||
|
||||
|
||||
def test_요청과_적용에_종단_반영() -> None:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ruff: noqa: F811 — 346-3 fixture 를 불러 씀
|
||||
"""476-1 ① 추천 [적용] = 늘 새 규칙 초기 종단(요구 276) — 옛 계획고 · 편집 · L 안 옮김.
|
||||
|
||||
체인은 346-3 시험의 가짜 세계(`chain_world`)로 · 종단 파일은 실제 1차 계획선.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TESTER = Path(__file__).resolve().parent
|
||||
for path in (ROOT, TESTER):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from test_346_3_route_save import ( # noqa: E402, F401 — 가짜 세계 fixture
|
||||
NEW,
|
||||
_async,
|
||||
_longitudinal,
|
||||
_run,
|
||||
api_world,
|
||||
chain_world,
|
||||
)
|
||||
|
||||
import B05_Profile.B05_Profile_Repository as b05_repo # noqa: E402
|
||||
import B05_Profile.B05_Profile_Route_Save as save # noqa: E402
|
||||
import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402
|
||||
import B06_Section.B06_Section_Router as b06_router # noqa: E402
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import vertices_of # noqa: E402
|
||||
|
||||
LONG = "B06_Section/longitudinal/longitudinal.json"
|
||||
|
||||
|
||||
def _real_profile_world(world, monkeypatch):
|
||||
"""옛 종단 = 손 편집 · L 15 · 새 종횡단 = 실제 1차 계획선 — `_carry_profile` 은 진짜."""
|
||||
total = float(vertices_of(NEW)[-1].chainage_m)
|
||||
old = _longitudinal(total)
|
||||
old["profile_alignment"]["edits"] = {"station_offsets": {"40.000": 0.7}, "curve_radii": {}}
|
||||
old["profile_alignment"]["policy"]["default_curve_length_m"] = 15.0
|
||||
path = world.root / LONG
|
||||
path.write_text(json.dumps(old), "utf-8")
|
||||
fresh = _longitudinal(total)
|
||||
|
||||
async def regenerate(*args, **kwargs):
|
||||
world.order.append("sections")
|
||||
path.write_text(json.dumps(fresh), "utf-8")
|
||||
|
||||
monkeypatch.setattr(b06_router, "regenerate_route_sections", regenerate)
|
||||
monkeypatch.setattr(save, "_carry_profile", _CARRY)
|
||||
monkeypatch.setattr(b05_repo, "update_longitudinal_grade_summary", _async(None))
|
||||
return path, fresh["profile_alignment"]
|
||||
|
||||
|
||||
_CARRY = save._carry_profile
|
||||
|
||||
|
||||
def test_적용_끔이면_계획고_안_옮김(chain_world):
|
||||
result = _run(chain_world, initial=True)
|
||||
assert "error" not in result, result
|
||||
assert "profile" not in chain_world.order # 옛 코드 = 같은 노선 옛 꼭짓점 통째 옮김
|
||||
|
||||
|
||||
def test_적용_같은_노선_끔_새_1차_계획선(chain_world, monkeypatch):
|
||||
path, fresh = _real_profile_world(chain_world, monkeypatch)
|
||||
_run(chain_world, initial=True)
|
||||
alignment = json.loads(path.read_text("utf-8"))["profile_alignment"]
|
||||
assert alignment == fresh # 꼭짓점 · L(정책) · 편집 = 새 1차 계획선(438) 그대로
|
||||
assert not (alignment.get("edits") or {}).get("station_offsets")
|
||||
|
||||
|
||||
def test_손_노선_저장은_계획고_옮김_그대로(chain_world, monkeypatch):
|
||||
path, fresh = _real_profile_world(chain_world, monkeypatch)
|
||||
_run(chain_world)
|
||||
alignment = json.loads(path.read_text("utf-8"))["profile_alignment"]
|
||||
assert alignment["edits"]["station_offsets"].get("40.000") == 0.7 # 앞 구간 손 편집 옮김
|
||||
|
||||
|
||||
def test_적용_켬_균형_셈_실패도_1차_계획선(chain_world, monkeypatch):
|
||||
path, fresh = _real_profile_world(chain_world, monkeypatch)
|
||||
_run(chain_world, profile_target=None, initial=True) # 균형 목표 None(셈 실패)
|
||||
assert json.loads(path.read_text("utf-8"))["profile_alignment"] == fresh
|
||||
|
||||
|
||||
def test_적용은_체인에_initial(api_world, monkeypatch):
|
||||
seen: list = []
|
||||
|
||||
async def recompute(*args, **kwargs):
|
||||
seen.append(args[6] if len(args) > 6 else kwargs.get("initial"))
|
||||
return {"route_id": 9, "total_length_m": 10.0}
|
||||
|
||||
monkeypatch.setattr(replan, "_recompute", recompute)
|
||||
body = save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}])
|
||||
asyncio.run(replan.replan_route(uuid4(), body))
|
||||
asyncio.run(replan.replan_route(uuid4(), body.model_copy(update={"as_initial": True})))
|
||||
assert seen == [False, True]
|
||||
|
||||
|
||||
def test_횡단_손_입력_넘김_한_자리():
|
||||
# 사용자 답 전 = [적용]도 넘김(475-3) — 답 뒤 이 함수 한 줄
|
||||
assert save.carries_cross_inputs(True) is True and save.carries_cross_inputs(False) is True
|
||||
@@ -0,0 +1,409 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""476-1 추천 후보 초기 종단 — 미리 셈 = [적용] 체인 종단 · 화면 펴기 짝 · 응답 칸 · 저장본 지문.
|
||||
|
||||
고정 자료 = `fixtures/golden_472`(사본) · 체인 쪽 = `run_route_save_chain` 파일 길 차례(노선 csv →
|
||||
구조물 · 추가 측점 따라감 → 노선 geojson → 관 따라감 · 정본 → `run_section_generation` →
|
||||
켬이면 `_set_base_pvi` + `_apply_alignment_edits`) · 미리 셈 쪽 = 메모리 조각(파일 안 씀).
|
||||
화면 펴기 = TS `flattenServerPlan`(Node `helper_476_1_flatten.cjs`) ↔ 파이썬 `draft_nodes`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TESTER = Path(__file__).resolve().parent
|
||||
for path in (ROOT, TESTER):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
import helper_472_1_paths as P # noqa: E402
|
||||
from test_224_3_recommend_store import _ask, wired # noqa: E402, F401 — 가짜 세계 fixture
|
||||
|
||||
from B05_Profile import B05_Profile_Engine_InitialProfile as ip # noqa: E402
|
||||
|
||||
RADIUS = 12.0
|
||||
HELPER = TESTER / "helper_476_1_flatten.cjs"
|
||||
NODES_CSV = "B05_Profile/route/planned_route_initial_nodes.csv"
|
||||
WALL = {
|
||||
"structure_id": "wall600",
|
||||
"type_id": "retaining_wall",
|
||||
"placement": "interval",
|
||||
"chainage_m": 600.0,
|
||||
"start_m": 595.0,
|
||||
"end_m": 605.0,
|
||||
"options": {"wall_form": "콘크리트", "length_m": 10, "height_m": 2},
|
||||
"memo": "",
|
||||
"placement_source": "manual",
|
||||
"status": "draft",
|
||||
"revision": 0,
|
||||
"geometry": None,
|
||||
"summary_item": None,
|
||||
"group_items": None,
|
||||
"x": 208785.614,
|
||||
"y": 367845.1028,
|
||||
}
|
||||
|
||||
|
||||
def _candidate(root: Path, shift: float = 0.0) -> dict:
|
||||
"""엔진 결과 꼴 후보(`nodes` · `curves` as_dict) — 교점 9 를 동으로 `shift` m."""
|
||||
from B05_Profile.B05_Profile_Router_Replan import _vertices_of
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
nodes = [tuple(p) for p in _vertices_of(root / NODES_CSV)]
|
||||
nodes[9] = (nodes[9][0] + shift, nodes[9][1])
|
||||
outline = build_planned_polyline(nodes, min_radius_m=RADIUS, simplify=False)
|
||||
return {
|
||||
"nodes": [n.as_dict() for n in outline.nodes],
|
||||
"curves": [c.as_dict() for c in outline.curves],
|
||||
}
|
||||
|
||||
|
||||
def _browser_draft(candidate: dict, work: Path) -> dict:
|
||||
if not (ROOT / "config" / "node_modules" / "typescript").is_dir():
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
body = work / "candidate.json"
|
||||
body.write_text(json.dumps(candidate), encoding="utf-8")
|
||||
out = subprocess.run(
|
||||
["node", str(HELPER), str(body)], capture_output=True, text=True, check=True
|
||||
)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def _settings() -> dict:
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
|
||||
summary = P.read("summary.json")["longitudinal"]["data"]
|
||||
options = {k: v for k, v in summary["options"].items() if k != "extra_stations"}
|
||||
return {
|
||||
"options": SectionGenerationOptions(**options),
|
||||
"grade_options": GradeDesignOptions(**summary["grade_options"]),
|
||||
"crs": f"EPSG:{P.read('context.json')['crs_epsg']}",
|
||||
}
|
||||
|
||||
|
||||
def _old_xy(root: Path) -> list[list[float]]:
|
||||
coords = json.loads((root / P.ROUTE).read_text("utf-8"))["geometry"]["coordinates"]
|
||||
return [[c[0], c[1]] for c in coords]
|
||||
|
||||
|
||||
def _seed(root: Path, extras: bool) -> None:
|
||||
from B05_Profile.B05_Profile_Plus_Stations import save_plus_stations
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
if extras:
|
||||
atomic_write_json(
|
||||
root / "B05_Profile" / "route" / "structures.json",
|
||||
{"revision": 1, "structures": [copy.deepcopy(WALL)], "definition_version": None},
|
||||
)
|
||||
save_plus_stations(root, [510], _old_xy(root))
|
||||
|
||||
|
||||
def _chain(root: Path, draft: dict, target) -> dict:
|
||||
"""`run_route_save_chain` 파일 길 차례(DB 없이) — [적용] 뒤 종단 정본."""
|
||||
from B05_Profile.B05_Profile_Engine import _route_geojson
|
||||
from B05_Profile.B05_Profile_Engine_AsPlanned import _vertex_z
|
||||
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
|
||||
from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations
|
||||
from B05_Profile.B05_Profile_Route_Save import followed_pipes
|
||||
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
||||
from B05_Profile.B05_Profile_Router_Replan import _vertices_of, _write_planned_polyline
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi
|
||||
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
ensure_pipe_ids,
|
||||
fill_pipe_coordinates,
|
||||
pipe_points_path_in,
|
||||
read_pipe_points_file,
|
||||
route_signature,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_geometry import planned_route_working_path
|
||||
|
||||
old_xy = _old_xy(root)
|
||||
old_pipes = read_pipe_points_file(pipe_points_path_in(root))
|
||||
working = planned_route_working_path(root)
|
||||
count = len(draft["nodes"])
|
||||
_write_planned_polyline(
|
||||
working,
|
||||
[tuple(n) for n in draft["nodes"]],
|
||||
RADIUS,
|
||||
simplify=False,
|
||||
curve_flags=draft["curveOn"],
|
||||
radii=draft["curveRadius"],
|
||||
station_snap=True,
|
||||
station_snap_step=None,
|
||||
locks=[None] * count,
|
||||
arcs=[None] * count,
|
||||
)
|
||||
points = [{"x": x, "y": y} for x, y in _vertices_of(working)]
|
||||
follow_structures_to_route(root, P.ROUTE, points)
|
||||
follow_plus_stations(root, [(p["x"], p["y"]) for p in points])
|
||||
xy = [(p["x"], p["y"]) for p in points]
|
||||
z = _vertex_z(root, *P.SURFACE, xy)
|
||||
atomic_write_json(root / P.ROUTE, _route_geojson([[x, y, h] for (x, y), h in zip(xy, z)]))
|
||||
pairs, _crossing, vertices = followed_pipes(old_pipes, old_xy, points, [])
|
||||
pipes = [pipe for _, pipe in pairs]
|
||||
fill_pipe_coordinates(pipes, vertices)
|
||||
ensure_pipe_ids(pipes)
|
||||
atomic_write_json(
|
||||
pipe_points_path_in(root),
|
||||
{"route_signature": route_signature(vertices), "points": [p.as_dict() for p in pipes]},
|
||||
)
|
||||
settings = _settings()
|
||||
run_section_generation(root, P.ROUTE, *P.SURFACE, **settings)
|
||||
if target is not None:
|
||||
_set_base_pvi(root / P.LONG, *target)
|
||||
_apply_alignment_edits(root, P.LONG, {})
|
||||
return json.loads((root / P.LONG).read_text("utf-8"))
|
||||
|
||||
|
||||
def _preview(root: Path, candidate: dict, target) -> tuple[dict, list, list]:
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
|
||||
line, curves = ip.candidate_line(candidate["nodes"], candidate["curves"], RADIUS)
|
||||
facilities = ip.followed_facilities(
|
||||
root,
|
||||
line,
|
||||
P.ROUTE,
|
||||
_old_xy(root),
|
||||
read_pipe_points_file(pipe_points_path_in(root)),
|
||||
[],
|
||||
)
|
||||
sampler = build_surface_sampler(root / "B04_PreProcess" / "models", *P.SURFACE)
|
||||
longitudinal = ip.initial_longitudinal(
|
||||
root,
|
||||
line,
|
||||
curves,
|
||||
sampler,
|
||||
facilities,
|
||||
source_snapshot={"filter": "classification", "method": "dtm", "smooth": True},
|
||||
target=target,
|
||||
**_settings(),
|
||||
)
|
||||
return longitudinal, line, curves
|
||||
|
||||
|
||||
def _target(root: Path, line, curves):
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
|
||||
sampler = build_surface_sampler(root / "B04_PreProcess" / "models", *P.SURFACE)
|
||||
profile = route_profile(line, sampler, max_grade=0.14, design_speed=20, design_curves=curves)
|
||||
return profile["stations_m"], profile["plan_m"]
|
||||
|
||||
|
||||
CASES = {
|
||||
"same_off": {"shift": 0.0, "extras": False, "balanced": False},
|
||||
"moved_off": {"shift": 5.0, "extras": True, "balanced": False},
|
||||
"moved_on": {"shift": 5.0, "extras": True, "balanced": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=list(CASES))
|
||||
def pair(request, tmp_path_factory):
|
||||
case = CASES[request.param]
|
||||
work = tmp_path_factory.mktemp(request.param)
|
||||
chain_root = P.stage_project(work / "chain")
|
||||
preview_root = P.stage_project(work / "preview")
|
||||
for root in (chain_root, preview_root):
|
||||
_seed(root, case["extras"])
|
||||
candidate = _candidate(preview_root, case["shift"])
|
||||
draft = _browser_draft(candidate, work)
|
||||
target = None
|
||||
if case["balanced"]:
|
||||
line, curves = ip.candidate_line(candidate["nodes"], candidate["curves"], RADIUS)
|
||||
target = _target(preview_root, line, curves)
|
||||
preview, line, curves = _preview(preview_root, candidate, target)
|
||||
chain = _chain(chain_root, draft, target)
|
||||
return {"preview": preview, "chain": chain, "line": line, "curves": curves, "root": chain_root}
|
||||
|
||||
|
||||
def test_화면_펴기_짝(tmp_path):
|
||||
root = P.stage_project(tmp_path / "p")
|
||||
candidate = _candidate(root, 5.0)
|
||||
draft = _browser_draft(candidate, tmp_path)
|
||||
points, flags, radii = ip.draft_nodes(candidate["nodes"], candidate["curves"])
|
||||
assert [list(p) for p in points] == draft["nodes"]
|
||||
assert flags == draft["curveOn"] and radii == draft["curveRadius"]
|
||||
|
||||
|
||||
def test_노선_곡선표_같음(pair):
|
||||
from B05_Profile.B05_Profile_Engine_Sections import _load_design_curves
|
||||
from B05_Profile.B05_Profile_Router_Replan import _vertices_of
|
||||
from common_util.common_util_route_geometry import planned_route_working_path
|
||||
|
||||
root = pair["root"]
|
||||
assert [tuple(p) for p in _vertices_of(planned_route_working_path(root))] == pair["line"]
|
||||
assert _load_design_curves(root) == pair["curves"]
|
||||
|
||||
|
||||
def test_미리_셈_종단_체인과_1mm(pair):
|
||||
preview, chain = pair["preview"], pair["chain"]
|
||||
got = [float(s["chainage_m"]) for s in preview["stations"]]
|
||||
want = [float(s["chainage_m"]) for s in chain["stations"]]
|
||||
assert got == pytest.approx(want, abs=1e-6)
|
||||
a = preview["profile_alignment"]["base_pvi"]
|
||||
b = chain["profile_alignment"]["base_pvi"]
|
||||
assert len(a) == len(b)
|
||||
for p, q in zip(a, b):
|
||||
assert abs(p["chainage_m"] - q["chainage_m"]) <= 1e-3
|
||||
assert abs(p["elevation_m"] - q["elevation_m"]) <= 1e-3
|
||||
assert ip.profile_summary(preview)["plan_m"] == pytest.approx(
|
||||
ip.profile_summary(chain)["plan_m"], abs=1e-3
|
||||
)
|
||||
|
||||
|
||||
def test_초기_종단_438_규칙(pair):
|
||||
summary = ip.profile_summary(pair["preview"])
|
||||
assert summary["curve_length_m"] == 20.0 # 432 L 20
|
||||
assert summary["straightened"] > 0 # R > 500 꼭짓점 묶음 = 직선 위(438)
|
||||
curves = pair["preview"]["profile_alignment"]["curves"]
|
||||
assert not [c for c in curves if not c["omitted"] and c["r_m"] > 500.0 + 1e-6]
|
||||
assert len(summary["line"]) > len(summary["stations_m"]) # 종단곡선 포함 선
|
||||
|
||||
|
||||
# ── 응답 칸 · 저장본 지문 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self):
|
||||
pool = self
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self):
|
||||
return pool
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
return _Acquire()
|
||||
|
||||
def cursor(self, *args):
|
||||
return self.acquire()
|
||||
|
||||
|
||||
def _async(value):
|
||||
async def fake(*args, **kwargs):
|
||||
return value() if callable(value) else value
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gather(tmp_path, monkeypatch):
|
||||
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
|
||||
import B05_Profile.B05_Profile_Repository as b05_repo
|
||||
import B05_Profile.B05_Profile_Router_Replan as replan
|
||||
import B05_Profile.B05_Profile_Router_RouteInitial as route_initial
|
||||
import B05_Profile.B05_Profile_Router_RouteInitial_Initial as gatherer
|
||||
import B06_Section.B06_Section_Repository as b06_repo
|
||||
import common_util.common_util_drainage_pipes as drainage
|
||||
import common_util.common_util_workflow_state as workflow
|
||||
|
||||
root = P.stage_project(tmp_path / "project")
|
||||
summary = P.read("summary.json")["longitudinal"]["data"]
|
||||
grade = summary["grade_options"]
|
||||
params = {
|
||||
"options": {
|
||||
"grade_class": grade.get("grade_class") or "trunk",
|
||||
"terrain_type": grade.get("terrain_type") or "special",
|
||||
"design_speed_kph": grade.get("design_speed_kph"),
|
||||
},
|
||||
"max_grade_pct": grade.get("max_grade_pct"),
|
||||
}
|
||||
state = {"stages": [{"stage_no": 2, "params": params}]}
|
||||
pipes_file = root / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json"
|
||||
monkeypatch.setattr(gatherer, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(replan, "_plan_criteria", _async((RADIUS, 0.0, 0.0)))
|
||||
monkeypatch.setattr(replan, "_road_settings", _async(("trunk", 20, "special")))
|
||||
monkeypatch.setattr(b05_repo, "get_latest_route", _async({"id": 1, "route_data_path": P.ROUTE}))
|
||||
monkeypatch.setattr(b06_repo, "get_route_generation_source", _async({"crs_epsg": 5186}))
|
||||
monkeypatch.setattr(b06_repo, "get_latest_section_options", _async(summary["options"]))
|
||||
monkeypatch.setattr(workflow, "get_workflow_state", _async(lambda: copy.deepcopy(state)))
|
||||
monkeypatch.setattr(route_initial, "project_standard", _async(None))
|
||||
monkeypatch.setattr(watershed, "_prepare", _async({"streams": []}))
|
||||
monkeypatch.setattr(watershed, "_sheet_dir", lambda stored: root / "none")
|
||||
monkeypatch.setattr(drainage, "pipe_points_path", lambda stored: pipes_file)
|
||||
request = replan.RouteInitialRequest(mode="follow", with_profile=False)
|
||||
results = root / "B05_Profile" / "route_recommend"
|
||||
results.mkdir(parents=True, exist_ok=True)
|
||||
(results / "k1.json").write_text("{}", "utf-8") # 결과 저장본 짝(없으면 초기 종단 파일 지움)
|
||||
candidate = _candidate(root)
|
||||
selection = {"source_filter": "classification", "method": "dtm", "smooth": True}
|
||||
|
||||
def run(key: str = "k1") -> dict:
|
||||
return asyncio.run(
|
||||
gatherer.initial_profile_of(uuid4(), root, "p", candidate, request, selection, key)
|
||||
)
|
||||
|
||||
return {"run": run, "root": root, "state": state, "pipes": pipes_file}
|
||||
|
||||
|
||||
def test_응답_칸(gather):
|
||||
got = gather["run"]()
|
||||
assert "error" not in got, got
|
||||
for name in (
|
||||
"stations_m",
|
||||
"ground_m",
|
||||
"plan_m",
|
||||
"line",
|
||||
"pvi_count",
|
||||
"curve_count",
|
||||
"straightened",
|
||||
"max_grade_pct",
|
||||
"curve_length_m",
|
||||
"warnings",
|
||||
):
|
||||
assert name in got, name
|
||||
assert got["with_profile"] is False and got["curve_length_m"] == 20.0
|
||||
assert len(got["stations_m"]) == len(got["plan_m"]) == len(got["ground_m"])
|
||||
|
||||
|
||||
def test_지문_같으면_꺼냄_다르면_다시(gather, monkeypatch):
|
||||
first = gather["run"]()
|
||||
assert "error" not in first, first
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise RuntimeError("다시 셈")
|
||||
|
||||
monkeypatch.setattr(ip, "initial_longitudinal", broken)
|
||||
assert gather["run"]() == first # 같은 입력 = 저장본
|
||||
gather["state"]["stages"][0]["params"]["max_grade_pct"] = 9.0 # 페이지 설정 최대 기울기
|
||||
assert "다시 셈" in gather["run"]()["error"]
|
||||
gather["state"]["stages"][0]["params"].pop("max_grade_pct")
|
||||
doc = json.loads(gather["pipes"].read_text("utf-8"))
|
||||
doc["points"][0]["x"] = float(doc["points"][0]["x"]) + 1.0 # 관 하나 옮김
|
||||
gather["pipes"].write_text(json.dumps(doc), "utf-8")
|
||||
assert "다시 셈" in gather["run"]()["error"]
|
||||
|
||||
|
||||
def test_코드_바뀌면_다시(gather, monkeypatch):
|
||||
assert "error" not in gather["run"]()
|
||||
monkeypatch.setattr(ip, "source_fingerprint", lambda: "other")
|
||||
monkeypatch.setattr(ip, "initial_longitudinal", lambda *a, **k: {})
|
||||
assert "error" in gather["run"]() # 438 등 소스 바뀜 = 다시 셈(빈 종단 = 실패 글)
|
||||
|
||||
|
||||
def test_추천_응답에_initial_profile(wired, monkeypatch):
|
||||
import B05_Profile.B05_Profile_Router_RouteInitial_Initial as gatherer
|
||||
|
||||
seen: list = []
|
||||
|
||||
async def fake(project_id, root, stored, result, request, selection, key):
|
||||
seen.append(key)
|
||||
return {"pvi_count": 3}
|
||||
|
||||
monkeypatch.setattr(gatherer, "initial_profile_of", fake)
|
||||
body = _ask(mode="earthwork") # 224-3 가짜 세계
|
||||
assert body["initial_profile"] == {"pvi_count": 3} and seen # 옛 코드 = 칸 없음
|
||||
@@ -0,0 +1,64 @@
|
||||
"""476-2 추천 모달 — 「적용될 초기 종단」 선 · 한 줄 · [적용] 확인 글(요구 276).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_476_2_recommend_initial_line.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
HELPER = Path(__file__).with_name("helper_476_2_initial_profile.cjs")
|
||||
|
||||
|
||||
def _out() -> dict:
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
def test_펴기와_캐시_빼기() -> None:
|
||||
out = _out()
|
||||
p = out["parsed"]
|
||||
assert p["withProfile"] is False and p["curveLengthM"] == 20
|
||||
assert p["pviCount"] == 3 and p["curveCount"] == 1 and p["straightened"] == 2
|
||||
assert p["line"][3] == [30, 101.5] and p["planM"] == [100, 102, 101]
|
||||
assert "initialProfile" not in out["cached"] # 세션에 안 적음
|
||||
assert out["missing"] is None # 옛 서버 답
|
||||
assert out["shortLine"] is None # 선 점 하나 = 꼴 틀림
|
||||
|
||||
|
||||
def test_한_줄과_실패_글() -> None:
|
||||
out = _out()
|
||||
assert out["line"] == "초기 종단: 꼭짓점 3 · 종단곡선 1 · 직선화 2 · 최대 종단경사 10.0%(L 20)"
|
||||
assert out["warnLine"].endswith("(L 20) · 알림 2건") # 알림 글은 title
|
||||
assert out["failed"] == {"error": "지반고 종단 — 측점이 모자랍니다"}
|
||||
assert out["failedLine"].startswith("초기 종단을 셈하지 못했습니다 — 지반고 종단")
|
||||
|
||||
|
||||
def test_그림_선() -> None:
|
||||
out = _out()
|
||||
# 끔 = 지반선 + 초기 종단(계획선 빔)
|
||||
assert 'class="is-ground" d="M6.0 90.0L300.0 6.0L594.0 62.0"' in out["svgOff"]
|
||||
assert 'class="is-plan" d=""' in out["svgOff"]
|
||||
initial = 'class="is-initial" d="M6.0 90.0L153.0 62.0L300.0 34.0L447.0 48.0L594.0 62.0"'
|
||||
assert initial in out["svgOff"] and initial in out["svgOn"]
|
||||
assert 'class="is-plan" d="M6.0 90.0L300.0 62.0L594.0 62.0"' in out["svgOn"]
|
||||
assert "is-initial" not in out["svgOld"] # 옛 답 = 지반 · 균형만
|
||||
assert "is-plan" not in out["legendOff"] and "적용될 초기 종단" in out["legendOff"]
|
||||
|
||||
|
||||
def test_모달_배선과_확인_글() -> None:
|
||||
rec = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
|
||||
assert '<div data-line="initial" hidden></div>' in rec
|
||||
assert "profileSvg(balance, initialShape)" in rec
|
||||
assert 'line("profile").hidden = !balance && !initialShape;' in rec
|
||||
apply = rec[rec.index('applyButton.addEventListener("click"') :][:400]
|
||||
assert 'showConfirmDialog(APPLY_CONFIRM, "적용")' in apply
|
||||
assert apply.index("showConfirmDialog") < apply.index("params.onApply(picked);")
|
||||
assert "종단 · 횡단 손 입력이 모두 지워지고 이 노선 기준으로 새로 시작" in rec # 요구 277
|
||||
css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8")
|
||||
assert ".b05-routerec__profile .is-initial" in css and "var(--map-route" in css
|
||||
Reference in New Issue
Block a user