Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
/* 194-1 · 194-2 소단 고름 헬퍼 — `_Cross_Design_Pick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림.
|
||||
* 소단 = 높이만(▲▼ · ◀▶ 막힘 · 노면 끝 위 금지) · 조각 키로 다시 그려도 같은 조각 · 좌측 상자 알림. 짝 = `test_194_1_berm_depth.py`.
|
||||
*/
|
||||
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();
|
||||
|
||||
class Node {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.listeners = {};
|
||||
this.hidden = false;
|
||||
this.isConnected = true;
|
||||
this._text = "";
|
||||
this._cls = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...n) => n.forEach((c) => self._cls.add(c)),
|
||||
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
|
||||
contains: (c) => self._cls.has(c),
|
||||
};
|
||||
}
|
||||
get descendants() {
|
||||
return this.children.flatMap((c) => [c, ...c.descendants]);
|
||||
}
|
||||
/** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */
|
||||
querySelector(sel) {
|
||||
return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null;
|
||||
}
|
||||
getClientRects() {
|
||||
return this._cls.has("is-hidden") ? [] : [{}];
|
||||
}
|
||||
set className(v) {
|
||||
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
set textContent(v) {
|
||||
this._text = String(v);
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
insertBefore(kid) {
|
||||
this.children.push(kid);
|
||||
}
|
||||
setAttribute(n, v) {
|
||||
this.attrs[n] = String(v);
|
||||
if (n === "class") this.className = v;
|
||||
}
|
||||
getAttribute(n) {
|
||||
return this.attrs[n] ?? null;
|
||||
}
|
||||
hasAttribute(n) {
|
||||
return n in this.attrs;
|
||||
}
|
||||
addEventListener(t, fn) {
|
||||
(this.listeners[t] ??= []).push(fn);
|
||||
}
|
||||
click() {
|
||||
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
|
||||
}
|
||||
}
|
||||
const docListeners = {};
|
||||
globalThis.document = {
|
||||
createElement: (t) => new Node(t),
|
||||
createElementNS: (_n, t) => new Node(t),
|
||||
addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn),
|
||||
removeEventListener: (t, fn) => {
|
||||
docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn);
|
||||
},
|
||||
};
|
||||
const key = (name) =>
|
||||
[...(docListeners.keydown ?? [])].forEach((fn) =>
|
||||
fn({ key: name, target: null, preventDefault: () => undefined }),
|
||||
);
|
||||
|
||||
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 req = (r) =>
|
||||
r.startsWith("@util/")
|
||||
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
|
||||
: loadTs(path.join(path.dirname(full), `${r}.ts`));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
globalThis.Element = Node;
|
||||
|
||||
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
|
||||
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
|
||||
const y = (e) => 900 - (e - 100) * 12;
|
||||
const line = (pts, structure = false) => {
|
||||
const el = new Node("polyline");
|
||||
el.setAttribute("class", "b06-chart__design-cross");
|
||||
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
|
||||
if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, "");
|
||||
return el;
|
||||
};
|
||||
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
|
||||
const press = (name) => {
|
||||
let prevented = false;
|
||||
[...(docListeners.keydown ?? [])].forEach((fn) =>
|
||||
fn({ key: name, target: null, preventDefault: () => (prevented = true) }),
|
||||
);
|
||||
return prevented;
|
||||
};
|
||||
|
||||
/** 양성토 단면(노면 끝 표고 100) — 좌 소단 깊이 `dl`(노면 끝 아래) · 우 1.0 · 사면 1:1.5 · 끝 표고 97. `order` = 그리는 차례. */
|
||||
function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], stationId } = {}) {
|
||||
for (const k of Object.keys(docListeners)) if (k === "keydown") docListeners[k] = [];
|
||||
const design = {
|
||||
road_edges: {
|
||||
left: { offset_m: 2, elevation_m: 100 },
|
||||
right: { offset_m: -2, elevation_m: 100 },
|
||||
},
|
||||
ditch: null,
|
||||
ditch_side: null,
|
||||
ditch_enabled: false,
|
||||
section_mode: "both_fill",
|
||||
berm: { width_m: 0.5, interval_m: 3, slope_deg: 0, depth_m: 1 },
|
||||
};
|
||||
if (stored !== undefined) design.berm_depth_left_m = stored;
|
||||
const slope = (s, d) => {
|
||||
const k = s === "left" ? 1 : -1;
|
||||
const knee = 2 + 1.5 * d;
|
||||
const toe = knee + 0.5 + 1.5 * (100 - d - 97);
|
||||
return [
|
||||
[k * toe, 97],
|
||||
[k * (knee + 0.5), 100 - d],
|
||||
[k * knee, 100 - d],
|
||||
[k * 2, 100],
|
||||
];
|
||||
};
|
||||
const lines = {
|
||||
left: line(slope("left", dl)),
|
||||
road: line([
|
||||
[2, 100],
|
||||
[0, 100.04], // 노면 횡단경사 2 % — 깊이 0 소단이 노면과 한 직선으로 안 합쳐짐
|
||||
[-2, 100],
|
||||
]),
|
||||
right: line(slope("right", 1)),
|
||||
};
|
||||
const layer = new Node("g");
|
||||
layer.append(...order.map((name) => lines[name]));
|
||||
const values = { left: 0, right: 0 };
|
||||
const sets = [];
|
||||
const focus = [];
|
||||
const handle = pick.attachDesignLinePick({
|
||||
layer,
|
||||
design,
|
||||
x,
|
||||
toDisplayY: y,
|
||||
onPick: () => undefined,
|
||||
shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) },
|
||||
berm: { set: (s, v) => sets.push([s, v]), focus: (on) => focus.push(on) },
|
||||
stationId,
|
||||
});
|
||||
const find = (test) => handle.pieces.findIndex(test);
|
||||
const mid = (p) => (p.from.offset + p.to.offset) / 2;
|
||||
const at = {
|
||||
leftBerm: find((p) => pick.isBermPiece(design, p) && mid(p) > 0),
|
||||
rightBerm: find((p) => pick.isBermPiece(design, p) && mid(p) < 0),
|
||||
leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl),
|
||||
road: find((p) => mid(p) > 0 && mid(p) < 2),
|
||||
};
|
||||
const slot = (name) =>
|
||||
handle.panel.descendants.find(
|
||||
(n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden,
|
||||
);
|
||||
const rows = () =>
|
||||
handle.panel.descendants
|
||||
.filter((n) => n._cls.has("b06-structure-panel__struct"))
|
||||
.map((n) => n._cls.has("is-hidden"));
|
||||
const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent;
|
||||
return { design, handle, values, sets, focus, at, slot, rows, text };
|
||||
}
|
||||
|
||||
const out = {};
|
||||
|
||||
// ① 갈래 · 축 — 소단 = depth · 사면 = x · 노면 = y · 키
|
||||
{
|
||||
const c = card();
|
||||
out.axis = Object.fromEntries(
|
||||
Object.entries(c.at).map(([name, i]) => [
|
||||
name,
|
||||
pick.pieceMoveAxis(c.design, c.handle.pieces[i]),
|
||||
]),
|
||||
);
|
||||
out.keys = pick.pieceKeys(c.design, c.handle.pieces).sort();
|
||||
}
|
||||
|
||||
// ② 소단 고름 — ▲▼ 만 · ◀▶ 막힘 · ↺ = 구간 기본 · 좌측 상자 켬 알림
|
||||
{
|
||||
const c = card();
|
||||
c.handle.setActive(c.at.leftBerm);
|
||||
const [moveHidden, liftHidden] = c.rows();
|
||||
const leftBlocked = press("ArrowLeft");
|
||||
const rightBlocked = press("ArrowRight");
|
||||
const afterSide = { values: { ...c.values }, sets: c.sets.length };
|
||||
const upTaken = press("ArrowUp");
|
||||
press("ArrowDown");
|
||||
c.handle.panel.descendants
|
||||
.filter((n) => n._cls.has("b06-structure-panel__btn--reset"))
|
||||
.at(-1)
|
||||
.click();
|
||||
out.berm = {
|
||||
title: c.text("b06-structure-panel__title"),
|
||||
readout: c.text("b06-structure-panel__value"),
|
||||
moveHidden,
|
||||
liftHidden,
|
||||
leftBlocked,
|
||||
rightBlocked,
|
||||
afterSide,
|
||||
upTaken,
|
||||
sets: c.sets,
|
||||
focus: c.focus,
|
||||
};
|
||||
c.handle.setActive(c.at.leftUpper); // 사면으로 바꿈 — 소단 상자 끔 · ▲▼ 막힘 · 깊이 안 바뀜
|
||||
const before = c.sets.length;
|
||||
out.berm.slopeUpBlocked = press("ArrowUp");
|
||||
out.berm.slopeSets = c.sets.length - before;
|
||||
out.berm.slopeTitle = c.text("b06-structure-panel__title");
|
||||
out.berm.focusAfter = c.focus.at(-1);
|
||||
// 노면 = ▲▼ 는 종단도로 넘김(안 막음)
|
||||
c.handle.setActive(c.at.road);
|
||||
out.berm.roadUpPassed = !press("ArrowUp");
|
||||
out.berm.roadSets = c.sets.length - before;
|
||||
}
|
||||
|
||||
// ③ 도로 위 금지 = 0 에서 멈춤 · 사면 끝 앞에서 멈춤
|
||||
{
|
||||
const top = card({ dl: 0, stored: 0 });
|
||||
top.handle.setActive(top.at.leftBerm);
|
||||
press("ArrowUp");
|
||||
const deep = card({ dl: 2.9, stored: 2.9 });
|
||||
deep.handle.setActive(deep.at.leftBerm);
|
||||
press("ArrowDown"); // 2.9 → 3.0 = 사면 끝(97) — 막힘
|
||||
press("ArrowUp");
|
||||
out.limit = { topSets: top.sets, topFound: top.at.leftBerm >= 0, deepSets: deep.sets };
|
||||
}
|
||||
|
||||
// ④ 옮긴 뒤 다시 그려도 같은 조각(194-2) — 그리는 차례 · 깊이가 바뀌어도
|
||||
{
|
||||
const first = card({ stationId: "S1" });
|
||||
pick.setPickedLine("S1", first.at.leftBerm);
|
||||
const again = card({
|
||||
dl: 1.1,
|
||||
stored: 1.1,
|
||||
order: ["right", "road", "left"],
|
||||
stationId: "S1",
|
||||
});
|
||||
const restored = pick.pickedLineAt("S1");
|
||||
pick.setPickedLine("S1", again.at.leftUpper);
|
||||
const third = card({ dl: 1.2, stored: 1.2, stationId: "S1" });
|
||||
out.stable = {
|
||||
firstIndex: first.at.leftBerm,
|
||||
againIndex: again.at.leftBerm,
|
||||
restored,
|
||||
upperRestored: pick.pickedLineAt("S1") === third.at.leftUpper,
|
||||
upperIndexMoved: again.at.leftUpper !== third.at.leftUpper,
|
||||
};
|
||||
pick.setPickedLine("S1", null);
|
||||
}
|
||||
|
||||
// ⑤ 카드 창구 — 소단 깊이 변경 · 좌측 상자 알림(한 번만 · 이 측점이 켠 때만 끔)
|
||||
{
|
||||
const changes = [];
|
||||
const design = { ground_type: "soil", section_mode: "both_fill", shoulder_extend_left_m: 0.3 };
|
||||
const moves = pick.designLineMoves({ station_id: "S9", chainage_m: 40, design }, (ch, change) =>
|
||||
changes.push([ch, change]),
|
||||
);
|
||||
moves.berm.set("left", 1.3);
|
||||
const host = [];
|
||||
pick.setBermPickHost((ch) => host.push(ch));
|
||||
moves.berm.focus(true);
|
||||
moves.berm.focus(true); // 다시 그린 카드 — 안 부름
|
||||
pick
|
||||
.designLineMoves({ station_id: "S8", chainage_m: 20, design }, () => undefined)
|
||||
.berm.focus(false);
|
||||
moves.berm.focus(false);
|
||||
out.moves = {
|
||||
stationId: moves.stationId,
|
||||
shiftValue: moves.shift.valueOf("left"),
|
||||
change: changes[0],
|
||||
host,
|
||||
none: pick.designLineMoves({ station_id: "S7", chainage_m: 0, design }),
|
||||
};
|
||||
pick.setBermPickHost(null);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -199,7 +199,9 @@ def test_저장분_깊이_읽기():
|
||||
|
||||
|
||||
def test_좌측_패널_칸_구간_깊이_나름():
|
||||
"""구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴)."""
|
||||
"""옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴).
|
||||
|
||||
좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`)."""
|
||||
source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""194-1 · 194-2 소단 = 좌측 위치 칸 없앰 · 횡단도에서 고른 소단을 ▲▼ 로만 옮김.
|
||||
|
||||
측점 · 쪽마다 깊이(`berm_depth_{side}_m`).
|
||||
|
||||
- 좌측 칸 없음(정의 그룹 · 표시) · 기본 = 노면 끝 아래 1 m
|
||||
- 짝 거울: `common_util_cross_berm` · 설계 기하(파이썬 ↔ TS) — 쪽마다 깊이(`berm_depth_{side}_m`)
|
||||
- 서버 [저장] 다시 셈: 구조물 정본 소단 구간 + 측점 patch 깊이(옛 구간 depth_m = 기본값)
|
||||
- 화면(흉내 DOM · `helper_194_1_berm_pick.cjs`): ▲▼ 만 · ◀▶ 막힘 · 도로 위 금지 ·
|
||||
옮긴 뒤 같은 조각 ·
|
||||
좌측 소단 상자 알림
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
USER_TOUCHED_KEYS,
|
||||
berm_from,
|
||||
enforce_design_lines,
|
||||
stored_berm,
|
||||
)
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch
|
||||
from common_util.common_util_cross_berm import BermSpec, side_berm
|
||||
from config.config_system import STANDARD_CROSS_SECTION
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
HELPER = Path(__file__).parent / "helper_194_1_berm_pick.cjs"
|
||||
DEF = ROOT / "resources" / "master_template" / "구조물정의.json"
|
||||
|
||||
node_only = pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
|
||||
def _ground() -> list[dict]:
|
||||
"""양성토 — 지반이 노면 끝보다 3 m 이상 낮음."""
|
||||
return [
|
||||
{"offset_m": o / 2, "elevation_m": round(96.0 + 0.02 * o / 2, 4), "valid": True}
|
||||
for o in range(-40, 41)
|
||||
]
|
||||
|
||||
|
||||
def _berm(left=None, right=None, depth=1.0) -> BermSpec:
|
||||
return BermSpec(0.5, 3.0, 0.0, depth, left, right)
|
||||
|
||||
|
||||
CASES = [
|
||||
{"name": "양쪽 기본 1.0", "berm": _berm()},
|
||||
{"name": "좌 2.0 · 우 기본", "berm": _berm(left=2.0)},
|
||||
{"name": "좌 0(노면 끝) · 우 0.5", "berm": _berm(left=0.0, right=0.5)},
|
||||
{"name": "좌 음수 = 0", "berm": _berm(left=-1.0)},
|
||||
]
|
||||
|
||||
|
||||
def _python(berm: BermSpec) -> dict:
|
||||
return compute_cross_design(
|
||||
_ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil",
|
||||
section_mode="both_fill", berm=berm,
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def _flat_z(design: dict, side: str) -> list[float]:
|
||||
"""그 쪽 노면 끝 밖 평탄 도막(소단) 표고 — 지반(≤ 96.4)에 닿기 전 사면만."""
|
||||
edge = design["road_edges"][side]["offset_m"]
|
||||
sign = 1 if side == "left" else -1
|
||||
line = sorted(
|
||||
(
|
||||
p
|
||||
for p in design["design_line"]
|
||||
if sign * p["offset_m"] >= sign * edge - 1e-6 and p["elevation_m"] > 96.5
|
||||
),
|
||||
key=lambda p: sign * p["offset_m"],
|
||||
)
|
||||
flats = {
|
||||
round(a["elevation_m"], 3) # 설계선 표본이 한 단을 여러 도막으로 끊어도 한 표고
|
||||
for a, b in zip(line, line[1:], strict=False)
|
||||
if abs(b["offset_m"] - a["offset_m"]) > 1e-6
|
||||
and abs(b["elevation_m"] - a["elevation_m"]) / abs(b["offset_m"] - a["offset_m"]) < 0.05
|
||||
}
|
||||
return sorted(flats)
|
||||
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeCrossDesign } from "./common_util_cross_design.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const designs = input.cases.map((c) => computeCrossDesign(input.samples, 100, {
|
||||
standard: input.standard, groundType: "soil", sectionMode: "both_fill",
|
||||
berm: { widthM: c[0], intervalM: c[1], slopeDeg: c[2], depthM: c[3],
|
||||
depthLeftM: c[4], depthRightM: c[5] },
|
||||
}));
|
||||
writeFileSync(process.argv[3], JSON.stringify(designs));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_designs(tmp_path_factory) -> list[dict]:
|
||||
tmp = tmp_path_factory.mktemp("berm194")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), str(ROOT / "common_util" / "common_util_cross_design.ts"),
|
||||
"--outDir", str(out), "--module", "esnext", "--target", "es2022",
|
||||
"--moduleResolution", "bundler", "--ignoreConfig"],
|
||||
cwd=str(ROOT), check=True, capture_output=True,
|
||||
) # fmt: skip
|
||||
for emitted in out.glob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
src, dst = tmp / "in.json", tmp / "out.json"
|
||||
src.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cases": [list(case["berm"]) for case in CASES],
|
||||
"samples": _ground(),
|
||||
"standard": STANDARD_CROSS_SECTION,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
|
||||
)
|
||||
return json.loads(dst.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _close(left, right, path=""):
|
||||
if isinstance(left, dict):
|
||||
assert set(left) == set(right), f"{path} 키 다름 {set(left) ^ set(right)}"
|
||||
for key in left:
|
||||
_close(left[key], right[key], f"{path}.{key}")
|
||||
elif isinstance(left, list):
|
||||
assert len(left) == len(right), f"{path} 길이"
|
||||
for i, (a, b) in enumerate(zip(left, right, strict=True)):
|
||||
_close(a, b, f"{path}[{i}]")
|
||||
elif isinstance(left, (int, float)) and not isinstance(left, bool):
|
||||
assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}"
|
||||
else:
|
||||
assert left == right, f"{path}: {left!r} vs {right!r}"
|
||||
|
||||
|
||||
# ── 좌측 칸 없음 · 기본 1 m ────────────────────────────────────────────────
|
||||
def test_좌측_패널_깊이_칸_없음() -> None:
|
||||
data = json.loads(DEF.read_text(encoding="utf-8"))
|
||||
assert data["그룹"]["berm"][0]["옵션"] == ["width_m"]
|
||||
assert data["표시"]["berm"]["depth_m"] is False
|
||||
berm = next(t for t in data["types"] if t["type_id"] == "berm")
|
||||
depth = next(o for o in berm["options"] if o["key"] == "depth_m")
|
||||
assert depth["default"] == 1.0 # 옛 구간 값 · 새 구간 기본 = 노면 끝 아래 1 m
|
||||
|
||||
|
||||
def test_기본_1m_수평_한_단() -> None:
|
||||
design = _python(_berm())
|
||||
for side in ("left", "right"):
|
||||
edge_z = design["road_edges"][side]["elevation_m"]
|
||||
assert _flat_z(design, side) == [pytest.approx(edge_z - 1.0, abs=1e-3)]
|
||||
assert "berm_depth_left_m" not in design and "berm_depth_right_m" not in design
|
||||
|
||||
|
||||
# ── 짝 거울 · 쪽마다 깊이 ───────────────────────────────────────────────────
|
||||
def test_쪽마다_깊이_그_쪽만() -> None:
|
||||
by = {case["name"]: _python(case["berm"]) for case in CASES}
|
||||
two = by["좌 2.0 · 우 기본"]
|
||||
assert _flat_z(two, "left") == [pytest.approx(two["road_edges"]["left"]["elevation_m"] - 2.0)]
|
||||
assert _flat_z(two, "right") == [pytest.approx(two["road_edges"]["right"]["elevation_m"] - 1.0)]
|
||||
assert two["berm_depth_left_m"] == 2.0 and "berm_depth_right_m" not in two
|
||||
assert two["berm"]["depth_m"] == 1.0 # 구간 값은 그대로
|
||||
top = by["좌 0(노면 끝) · 우 0.5"]
|
||||
assert _flat_z(top, "left") == [pytest.approx(top["road_edges"]["left"]["elevation_m"])]
|
||||
neg = by["좌 음수 = 0"]
|
||||
assert neg["berm_depth_left_m"] == 0.0 # 도로보다 높을 수 없음
|
||||
assert neg["design_line"] == _python(_berm(left=0.0))["design_line"]
|
||||
assert side_berm(_berm(right=1.5), "right").depth_m == 1.5
|
||||
assert side_berm(_berm(right=1.5), "left").depth_m == 1.0
|
||||
|
||||
|
||||
@node_only
|
||||
def test_파이썬_TS_거울(ts_designs: list[dict]) -> None:
|
||||
for case, ts in zip(CASES, ts_designs, strict=True):
|
||||
_close(_python(case["berm"]), ts, case["name"])
|
||||
|
||||
|
||||
def test_짝_머리_표시() -> None:
|
||||
for name in ("common_util_cross_berm.py", "common_util_cross_berm.ts"):
|
||||
text = (ROOT / "common_util" / name).read_text(encoding="utf-8")
|
||||
assert "짝" in text and ("side_berm" in text or "sideBerm" in text)
|
||||
|
||||
|
||||
# ── 측점 patch 저장 왕복 · 서버 다시 셈 ─────────────────────────────────────
|
||||
def test_patch_칸_왕복() -> None:
|
||||
patch = CrossSectionPatch(chainage_m=40, berm_depth_left_m=1.5, berm_depth_right_m=0)
|
||||
dumped = {k: v for k, v in patch.model_dump().items() if v is not None}
|
||||
assert dumped == {"chainage_m": 40, "berm_depth_left_m": 1.5, "berm_depth_right_m": 0}
|
||||
with pytest.raises(ValidationError):
|
||||
CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지
|
||||
assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS)
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "patch.berm_depth_left_m = sides.left" in persist
|
||||
|
||||
|
||||
def test_저장분_읽기_옛_구간값_기본() -> None:
|
||||
old = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}})
|
||||
assert (old.depth_m, old.depth_left_m, old.depth_right_m) == (2.5, None, None)
|
||||
moved = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}, "berm_depth_left_m": 1.2})
|
||||
assert (moved.depth_left_m, moved.depth_right_m) == (1.2, None)
|
||||
# 프리뷰 payload(구간 제원 + 쪽 깊이)
|
||||
spec = berm_from({"width_m": 0.5, "depth_m": 1.0, "berm_depth_right_m": -3}, {
|
||||
"berm_depth_right_m": -3
|
||||
}) # fmt: skip
|
||||
assert spec.depth_right_m == 0.0
|
||||
|
||||
|
||||
LONG = {
|
||||
"design_profiles": [
|
||||
{
|
||||
"samples": [
|
||||
{"chainage_m": 0, "elevation_m": 100},
|
||||
{"chainage_m": 200, "elevation_m": 100},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _project(tmp_path: Path, structures: list[dict]) -> Path:
|
||||
route = tmp_path / "B05_Profile" / "route"
|
||||
route.mkdir(parents=True)
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
BERM_SPAN = {
|
||||
"type_id": "berm",
|
||||
"placement": "interval",
|
||||
"start_m": 0,
|
||||
"end_m": 100,
|
||||
"options": {"width_m": 0.5, "depth_m": 2.5}, # 옛 좌측 칸 값 = 기본값
|
||||
}
|
||||
|
||||
|
||||
def test_저장_때_서버가_정본_구간_측점_깊이로_다시_셈(tmp_path: Path) -> None:
|
||||
root = _project(tmp_path, [BERM_SPAN])
|
||||
browser = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="both_fill")
|
||||
# 브라우저 patch = 사용자 값(좌 깊이)만 · 설계선은 소단 없는 옛 것
|
||||
stored = {**browser, "berm_depth_left_m": 1.5}
|
||||
sections = [{"chainage_m": 40.0, "samples": _ground(), "design": stored}]
|
||||
assert enforce_design_lines(LONG, sections, root) == 1
|
||||
design = sections[0]["design"]
|
||||
assert design["berm"]["depth_m"] == 2.5 and design["berm_depth_left_m"] == 1.5
|
||||
left_z = design["road_edges"]["left"]["elevation_m"]
|
||||
right_z = design["road_edges"]["right"]["elevation_m"]
|
||||
assert _flat_z(design, "left") == [pytest.approx(left_z - 1.5, abs=1e-3)]
|
||||
assert _flat_z(design, "right") == [pytest.approx(right_z - 2.5, abs=1e-3)]
|
||||
# 구간 밖 측점 = 소단 없음 · 옮긴 값만 남음(다시 놓으면 그대로 씀)
|
||||
outside = [{"chainage_m": 150.0, "samples": _ground(), "design": dict(stored)}]
|
||||
enforce_design_lines(LONG, outside, root)
|
||||
assert "berm" not in outside[0]["design"] and _flat_z(outside[0]["design"], "left") == []
|
||||
|
||||
|
||||
def test_구간_지우면_저장분_소단도_사라짐(tmp_path: Path) -> None:
|
||||
root = _project(tmp_path, [])
|
||||
old = _python(_berm(left=1.5))
|
||||
sections = [{"chainage_m": 40.0, "samples": _ground(), "design": old}]
|
||||
enforce_design_lines(LONG, sections, root)
|
||||
assert "berm" not in sections[0]["design"]
|
||||
assert _flat_z(sections[0]["design"], "left") == []
|
||||
|
||||
|
||||
# ── 화면 고름(흉내 DOM) ─────────────────────────────────────────────────────
|
||||
@pytest.fixture(scope="module")
|
||||
def ui() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
@node_only
|
||||
def test_이동_셋_분리(ui: dict) -> None:
|
||||
assert ui["axis"] == {"leftBerm": "depth", "rightBerm": "depth", "leftUpper": "x", "road": "y"}
|
||||
assert "berm:left:0" in ui["keys"] and "fill:left:1" in ui["keys"]
|
||||
|
||||
|
||||
@node_only
|
||||
def test_소단_고름_위아래만(ui: dict) -> None:
|
||||
berm = ui["berm"]
|
||||
assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m"
|
||||
assert berm["moveHidden"] is True and berm["liftHidden"] is False
|
||||
assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘
|
||||
assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감
|
||||
assert berm["upTaken"]
|
||||
assert berm["sets"] == [["left", 0.9], ["left", 1.1], ["left", 1]] # ▲ · ▼ · ↺ 기본
|
||||
assert berm["slopeTitle"] == "사면 이동 (좌)" and berm["slopeUpBlocked"]
|
||||
assert berm["slopeSets"] == 0 and berm["roadSets"] == 0
|
||||
assert berm["roadUpPassed"] # 노면 ▲▼ = 종단도 몫
|
||||
|
||||
|
||||
@node_only
|
||||
def test_도로_위_금지_사면_끝_앞_멈춤(ui: dict) -> None:
|
||||
assert ui["limit"]["topFound"] and ui["limit"]["topSets"] == [] # 깊이 0 에서 ▲ = 안 감
|
||||
assert ui["limit"]["deepSets"] == [["left", 2.8]] # ▼ 막힘 · ▲ 는 감
|
||||
|
||||
|
||||
@node_only
|
||||
def test_옮긴_뒤_같은_조각(ui: dict) -> None:
|
||||
stable = ui["stable"]
|
||||
assert stable["firstIndex"] != stable["againIndex"] # 번호는 바뀜
|
||||
assert stable["restored"] == stable["againIndex"] # 키로 같은 소단
|
||||
assert stable["upperRestored"] and stable["upperIndexMoved"]
|
||||
|
||||
|
||||
@node_only
|
||||
def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None:
|
||||
assert ui["berm"]["focus"][0] is True and ui["berm"]["focusAfter"] is False
|
||||
moves = ui["moves"]
|
||||
assert moves["stationId"] == "S9" and moves["shiftValue"] == 0.3
|
||||
chainage, change = moves["change"]
|
||||
assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3}
|
||||
assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음
|
||||
assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시
|
||||
assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음
|
||||
selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "setBermPickHost(" in selection and 'classList.add("is-active")' in selection
|
||||
@@ -28,7 +28,11 @@ TABLE = {
|
||||
"옹벽",
|
||||
["wall_form", "length_m", "height_m", "before_m", "after_m"],
|
||||
),
|
||||
"berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2)
|
||||
"berm": (
|
||||
"gbermdetail",
|
||||
"상세 내용",
|
||||
["width_m"],
|
||||
), # 높이 · 깊이 칸 숨김(깊이 = 횡단도 ▲▼ · 194-1)
|
||||
}
|
||||
SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user