Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into sub_laptop_8
This commit is contained in:
@@ -2013,6 +2013,16 @@
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "depth_m",
|
||||
"label": "소단 위치(도로 아래)",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.0,
|
||||
"required": false,
|
||||
"phase": "b05",
|
||||
"default_basis": "사용자 지정 — 성토 사면 노면 끝 아래 1 m · 0 미만 = 0(도로보다 높을 수 없음)"
|
||||
},
|
||||
{
|
||||
"key": "height_m",
|
||||
"label": "소단 높이",
|
||||
@@ -3563,6 +3573,7 @@
|
||||
"weep_hole_area_m2": false
|
||||
},
|
||||
"berm": {
|
||||
"height_m": false,
|
||||
"length_m": false,
|
||||
"before_m": false,
|
||||
"after_m": false,
|
||||
@@ -3797,7 +3808,7 @@
|
||||
"이름": "상세 내용",
|
||||
"옵션": [
|
||||
"width_m",
|
||||
"height_m"
|
||||
"depth_m"
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,28 @@ const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
// 카드 여백은 원본에서 읽음(화면과 같은 값)
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
// 축 눈금 간격도 원본 함수 그대로(179-3 · 축 다시 그리기)
|
||||
const niceSrc = /export function niceTickStep[\s\S]*?\n\}/.exec(common.replace(/\r/g, ""))[0];
|
||||
const niceTickStep = ts
|
||||
.transpileModule(niceSrc.replace("export ", ""), { compilerOptions: {} })
|
||||
.outputText.concat("\nreturn niceTickStep;");
|
||||
const svgElement = (tag, attrs = {}) => {
|
||||
const node = el(tag);
|
||||
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
|
||||
return node;
|
||||
};
|
||||
const svgText = (text, attrs) => Object.assign(svgElement("text", attrs), { textContent: text });
|
||||
const STUBS = {
|
||||
"@ui/ui_template_modal": { openModal: () => {} },
|
||||
"../A00_Common/b_page_state": {},
|
||||
"./B06_Section_Api_Fetch": {},
|
||||
"./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key },
|
||||
"./B06_Section_UI_Section_Common": {
|
||||
CROSS_PAD,
|
||||
L: (key) => key,
|
||||
niceTickStep: Function(niceTickStep)(),
|
||||
svgElement,
|
||||
svgText,
|
||||
},
|
||||
"./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} },
|
||||
};
|
||||
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
||||
@@ -52,6 +69,24 @@ function el(tag) {
|
||||
node.parent = self;
|
||||
self.childNodes.push(node);
|
||||
}),
|
||||
replaceChildren: (...nodes) => {
|
||||
self.childNodes.splice(0).forEach((node) => (node.parent = null));
|
||||
self.append(...nodes);
|
||||
},
|
||||
// 「tag.class」 · 「tag」 만 — 깊이 우선 첫 것
|
||||
querySelector: (selector) => {
|
||||
const [tagName, cls] = selector.split(".");
|
||||
const walk = (node) => {
|
||||
for (const child of node.childNodes) {
|
||||
const classes = (child.attrs.class ?? "").split(" ");
|
||||
if (child.tag === tagName && (!cls || classes.includes(cls))) return child;
|
||||
const found = walk(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(self);
|
||||
},
|
||||
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
|
||||
fire: (type, event) => {
|
||||
const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event };
|
||||
@@ -69,16 +104,42 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e
|
||||
|
||||
const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts"));
|
||||
|
||||
const svg = el("svg");
|
||||
svg.setAttribute("viewBox", "0 0 600 300");
|
||||
svg.append(el("rect"), el("g"), el("g"));
|
||||
// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150)
|
||||
const AXES = {
|
||||
widthPx: 600,
|
||||
heightPx: 300,
|
||||
pixelsPerMeter: 10,
|
||||
maxOffset: 24,
|
||||
displayMax: 120,
|
||||
elevationMid: 110,
|
||||
exaggeration: 1,
|
||||
};
|
||||
function chart() {
|
||||
const svg = el("svg");
|
||||
svg.setAttribute("viewBox", "0 0 600 300");
|
||||
const axes = svgElement("g", { class: "b06-chart__axes" });
|
||||
const clipGroup = svgElement("g", { "clip-path": "url(#c)" });
|
||||
const plot = svgElement("g", { class: "b06-chart__plot-layer" });
|
||||
const center = svgElement("g", { class: "b06-chart__center" });
|
||||
center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 }));
|
||||
plot.append(center);
|
||||
clipGroup.append(plot);
|
||||
svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" }));
|
||||
return { svg, axes, plot, center };
|
||||
}
|
||||
const ticks = (axes) =>
|
||||
axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent);
|
||||
const { svg, axes, plot: layer, center } = chart();
|
||||
let saved;
|
||||
const handle = attachDetailZoom(svg, undefined, (next) => (saved = next));
|
||||
const layer = svg.childNodes[0];
|
||||
const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next));
|
||||
const ticksBefore = ticks(axes);
|
||||
const out = {
|
||||
wrapped: svg.childNodes.length === 1 && layer.tag === "g" && layer.childNodes.length === 3,
|
||||
// 179-3 — 감싸지 않음 · 자식 넷 그대로
|
||||
wrapped: svg.childNodes.length === 1 && svg.childNodes[0].tag === "g",
|
||||
children: svg.childNodes.length,
|
||||
ratio: svg.attrs["style:--ratio"],
|
||||
before: layer.getAttribute("transform"),
|
||||
ticksBefore: ticksBefore.length,
|
||||
};
|
||||
// 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정
|
||||
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
|
||||
@@ -97,19 +158,35 @@ svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
|
||||
svg.fire("pointermove", { clientX: 300, clientY: 140 });
|
||||
svg.fire("pointerup", {});
|
||||
out.middlePan = { ...saved };
|
||||
// 팬 뒤 — 도형 층만 transform · 축 층 · 바깥 그룹 transform 없음 · 눈금 글 바뀜
|
||||
out.plotTransform = layer.getAttribute("transform");
|
||||
out.axesTransform = axes.getAttribute("transform");
|
||||
out.clipTransform = svg.childNodes[2].getAttribute("transform");
|
||||
out.ticksChanged = JSON.stringify(ticks(axes)) !== JSON.stringify(ticksBefore);
|
||||
out.ticksAfter = ticks(axes).length;
|
||||
out.panClickStopped = svg.fire("click", {
|
||||
stopPropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
}).stopped;
|
||||
// 다시 떠도 배율 이어 감
|
||||
const again = el("svg");
|
||||
again.setAttribute("viewBox", "0 0 600 300");
|
||||
attachDetailZoom(again, saved, () => {});
|
||||
out.restored = again.childNodes[0].getAttribute("transform");
|
||||
const again = chart();
|
||||
attachDetailZoom(again.svg, AXES, saved, () => {});
|
||||
out.restored = again.plot.getAttribute("transform");
|
||||
// 다시 떠도 축은 이어 간 범위로(1배 원래 눈금과 다름)
|
||||
out.restoredTicksShifted = JSON.stringify(ticks(again.axes)) !== JSON.stringify(ticksBefore);
|
||||
// 축 옵션 없음 · 도형 층 없음 — 줌은 걸고 축은 그대로 / 줌 없음
|
||||
const bare = chart();
|
||||
out.noAxesHandle = attachDetailZoom(bare.svg, null, undefined, () => {}) !== null;
|
||||
const flat = el("svg");
|
||||
flat.setAttribute("viewBox", "0 0 600 300");
|
||||
out.noLayer = attachDetailZoom(flat, AXES, undefined, () => {});
|
||||
// 더블클릭 원복
|
||||
svg.fire("dblclick", {});
|
||||
out.reset = layer.getAttribute("transform");
|
||||
// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로)
|
||||
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
|
||||
out.centerTransform = center.getAttribute("transform");
|
||||
out.max = (() => {
|
||||
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
|
||||
return saved.scale;
|
||||
|
||||
@@ -40,10 +40,24 @@ def out() -> dict:
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_복제_자식을_g_하나로_감쌈_비율(out):
|
||||
assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임
|
||||
def test_감싸지_않음_비율(out):
|
||||
# 179-3 — 복제를 g 하나로 감싸지 않음(축 · 눈금 · 도형 한 장 → 도형 층만)
|
||||
assert out["wrapped"] is False and out["children"] == 4
|
||||
assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음
|
||||
assert out["before"] is None
|
||||
assert out["ticksBefore"] > 0
|
||||
|
||||
|
||||
def test_축_고정_도형_층만_움직임_눈금_다시(out):
|
||||
# 179-3 — 팬 뒤 도형 층만 transform · 축 층 · clip 그룹 제자리 · 눈금 글은 보이는 범위로
|
||||
assert out["plotTransform"] == "translate(100 20) scale(1)"
|
||||
assert out["axesTransform"] is None and out["clipTransform"] is None
|
||||
assert out["ticksChanged"] is True and out["ticksAfter"] > 0
|
||||
assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로
|
||||
# 확대 — 십자는 중심(300,150)에서 배율 되돌림
|
||||
assert out["centerTransform"].startswith("translate(300 150) scale(0.869")
|
||||
assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만
|
||||
assert out["noLayer"] is None # 도형 층 없음 = 줌 없음
|
||||
|
||||
|
||||
def test_휠_당기면_확대_커서_고정(out):
|
||||
@@ -69,8 +83,14 @@ def test_모달_배선_단추_칸_비율():
|
||||
src = _read("B06_Section_UI_Cross_Detail.ts")
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
|
||||
assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src
|
||||
assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src
|
||||
assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src
|
||||
assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src
|
||||
assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3)
|
||||
assert 'clone.querySelector<SVGGElement>("g.b06-chart__plot-layer")' in src
|
||||
assert "createCrossAxes(axisLayer, axes)" in src
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view
|
||||
assert "return { svg, axes };" in view
|
||||
assert "position: relative;" in css
|
||||
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
|
||||
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
|
||||
@@ -102,3 +122,27 @@ def test_빈칸_누름_없음_이름_칸_첫_키():
|
||||
assert 'if (!key || el.hasAttribute("data-empty")) return;' in src
|
||||
assert 'cell("th", groupL, { row: 2 }, keyL)' in src
|
||||
assert 'cell("th", groupM, { row: 2 }, keyM)' in src
|
||||
|
||||
|
||||
def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만():
|
||||
# 179-3 — 표 글자 14px · 이름 칸(th) 바탕 = 데이터 표 머리색 · 빈 칸 바탕 지움은 값 칸(td)만
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
table = css.split(".b06-detail__qtable {", 1)[1].split("}", 1)[0]
|
||||
assert "font-size: var(--text-body-sm);" in table
|
||||
assert (
|
||||
"--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));"
|
||||
in table
|
||||
)
|
||||
head = css.split(".b06-detail__qtable th {", 1)[1].split("}", 1)[0]
|
||||
assert "background: var(--b06-detail-head);" in head and "color: var(--color-text);" in head
|
||||
empty = css.split(".b06-detail__qtable [data-key][data-empty] {", 1)[1].split("}", 1)[0]
|
||||
assert "background" not in empty
|
||||
assert ".b06-detail__qtable td[data-key][data-empty] {\n background: none;" in css
|
||||
assert (
|
||||
".b06-detail__qtable th[data-key][data-empty] {\n background: var(--b06-detail-head);"
|
||||
in css
|
||||
)
|
||||
theme = open(
|
||||
os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8"
|
||||
).read()
|
||||
assert "--text-body-sm: 14px;" in theme
|
||||
|
||||
@@ -14,7 +14,7 @@ SPOTS = {
|
||||
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
|
||||
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
|
||||
"B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'],
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 · 면 = 수기 입력"],
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"],
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
|
||||
"ui_template/ui_template_locale_b2.ts": [
|
||||
"수량 원천: 수기 입력(직접비)",
|
||||
|
||||
@@ -87,7 +87,7 @@ def test_상세_테마_토큰_어두운_고정색_없음():
|
||||
def test_상세_칸_크기로_새로_그림_배선():
|
||||
src = _read("B06_Section_UI_Cross_Detail.ts")
|
||||
assert "const { clientWidth: width, clientHeight: height } = cad;" in src
|
||||
assert "const clone = drawChart(state.section, width, height);" in src
|
||||
assert "const drawn = drawChart(state.section, width, height);" in src
|
||||
assert "host?.chart(section.station_id, widthPx, heightPx)" in src
|
||||
assert "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src
|
||||
# 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음
|
||||
@@ -99,7 +99,12 @@ def test_상세_칸_크기로_새로_그림_배선():
|
||||
assert "base && fitPlotBase(base, heightPx)" in view
|
||||
assert "cardZoomStates.delete(stationId);" in view
|
||||
types = _read("B06_Section_UI_Section_View_Types.ts")
|
||||
assert "detailChart: (stationId: string, widthPx: number, heightPx: number) =>" in types
|
||||
# 179-3 — svg + 축 옵션
|
||||
assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types
|
||||
assert (
|
||||
" detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number,"
|
||||
in types
|
||||
)
|
||||
sync = _read("B06_Section_UI_Page_Design_Sync.ts")
|
||||
assert "ctx.view().detailChart(stationId, widthPx, heightPx)" in sync
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""178-1 사면안정 벽(흙막이 · 옹벽) 가드 한 벌 — 카드 · 서버 면적 · B07 · 3D.
|
||||
|
||||
흠: 카드는 「연동 관 벽이 이 측점에 실제로 설 때만」 독립 벽을 건너뛰는데, 서버 면적
|
||||
(`computeStoredLayouts` → Node) · B07 은 링크만 있어도 건너뛰고 3D 는 관 측점만 봤다.
|
||||
⇒ 관 링크는 있으나 관 벽이 안 서는 측점에서 카드엔 흙막이가 서고 서버 면적 · B07 엔 없음.
|
||||
|
||||
고침: `ownWallsDrawAt` 한 벌을 세 곳이 함께 씀. 서버 면적은 같은 TS 를 Node 로 돌리므로
|
||||
번들을 새로 빌드해 그 결과로 판정한다.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
|
||||
FIXTURE = ROOT / "resources" / "tester" / "fixtures" / "b06_73_4_body_guard_sections.json"
|
||||
|
||||
SOIL_GUARD = {
|
||||
"structure_id": "w1",
|
||||
"type_id": "soil_guard",
|
||||
"name": "흙막이",
|
||||
"start_m": 150,
|
||||
"end_m": 370,
|
||||
"anchor_m": 260,
|
||||
"form": "돌쌓기(찰)",
|
||||
"height_m": 1.5,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bundle(tmp_path_factory) -> Path:
|
||||
if not VITE.is_file():
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
out = tmp_path_factory.mktemp("server_calc")
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(VITE),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../B06_Section/B06_Section_Server_Calc_Node.ts",
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--logLevel",
|
||||
"error",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return out / "B06_Section_Server_Calc_Node.js"
|
||||
|
||||
|
||||
def _areas(bundle: Path, sections: list, tmp_path: Path) -> dict[float, tuple]:
|
||||
src, dst = tmp_path / "in.json", tmp_path / "out.json"
|
||||
src.write_text(json.dumps({"detail": {"cross_sections": sections}}), encoding="utf-8")
|
||||
subprocess.run(["node", str(bundle), str(src), str(dst)], check=True, cwd=str(ROOT)) # noqa: S603
|
||||
rows = json.loads(dst.read_text(encoding="utf-8"))["areas"]
|
||||
return {row["chainage_m"]: (row["cut_area_m2"], row["fill_area_m2"]) for row in rows}
|
||||
|
||||
|
||||
def _sections() -> list:
|
||||
return copy.deepcopy(json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"])
|
||||
|
||||
|
||||
def test_링크만_있고_관_벽이_안_서면_흙막이_면적_행이_나온다(bundle, tmp_path):
|
||||
"""173.087 관의 다단 구간값(20 m)이 160 · 180 을 덮어 링크는 서지만 관 벽(전 · 후 5 m)은 안 섬."""
|
||||
sections = [s for s in _sections() if s["chainage_m"] < 300]
|
||||
sections[1]["design"]["extra_spans"] = {"extra0": {"before_m": 20, "after_m": 20}}
|
||||
for section in sections:
|
||||
if not section.get("culvert"):
|
||||
section["revetment"] = SOIL_GUARD
|
||||
areas = _areas(bundle, sections, tmp_path)
|
||||
assert 160 in areas and 180 in areas, f"흙막이가 선 측점 면적 행 없음: {sorted(areas)}"
|
||||
# 표준 성토(160 = 1.45 · 180 = 5.32 ㎡)보다 벽이 사면을 끊어 줄어듦
|
||||
assert areas[160][1] < 1.45 and areas[180][1] < 5.32
|
||||
|
||||
|
||||
def test_관_벽이_서는_측점은_독립_벽을_안_그린다(bundle, tmp_path):
|
||||
"""340 · 360 은 352.14 관 벽이 섬 — 흙막이를 얹어도 면적이 관 벽 그대로(겹쳐 그리지 않음)."""
|
||||
plain = _areas(bundle, _sections(), tmp_path)
|
||||
walled = _sections()
|
||||
for section in walled:
|
||||
if not section.get("culvert"):
|
||||
section["revetment"] = SOIL_GUARD
|
||||
areas = _areas(bundle, walled, tmp_path)
|
||||
assert areas[340] == plain[340] and areas[360] == plain[360]
|
||||
|
||||
|
||||
def test_카드_서버_3D_가_가드_한_벌을_쓴다():
|
||||
files = {
|
||||
"카드": ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts",
|
||||
"서버 면적 · B07": ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts",
|
||||
"3D": ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Structures.ts",
|
||||
}
|
||||
for where, path in files.items():
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "ownWallsDrawAt(" in source, f"{where} 가 가드 한 벌을 안 씀"
|
||||
assert "culvertWallsStandAt(" not in source, f"{where} 에 따로 가드가 남음"
|
||||
@@ -0,0 +1,206 @@
|
||||
"""178-2 소단 = 성토 사면 노면 끝 아래 depth_m 에 수평 한 단 · 아래로 성토 이음 — 파이썬 · TS 거울.
|
||||
|
||||
짝: `common_util_cross_berm.py` ↔ `.ts` · `B06_Section_Engine_Design_Geometry.py` ↔
|
||||
`common_util_cross_design_geometry.ts`. 절토 소단은 끔 · 깊이 0 미만 = 0(도로보다 높을 수 없음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from common_util.common_util_cross_berm import BermSpec, fill_profile_points
|
||||
from config.config_system import STANDARD_CROSS_SECTION
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
|
||||
def _ground(slope: float) -> list[dict]:
|
||||
return [
|
||||
{"offset_m": o / 2, "elevation_m": round(100.0 + slope * o / 2, 4), "valid": True}
|
||||
for o in range(-40, 41)
|
||||
]
|
||||
|
||||
|
||||
def _case(name, slope, z, mode, berm):
|
||||
return {"name": name, "samples": _ground(slope), "z": z, "mode": mode, "berm": berm}
|
||||
|
||||
|
||||
# 좌절토 = 우측 성토(지반이 오른쪽으로 내려감)
|
||||
CASES = [
|
||||
_case("깊이 1.0 · 폭 0.5", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 1.0)),
|
||||
_case("깊이 0 = 노면 끝 높이", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 0.0)),
|
||||
_case("깊이 음수 = 0", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, -2.0)),
|
||||
_case("깊이 > 사면 높이 = 소단 없음", 0.15, 100.0, "left_cut", (0.5, 3.0, 0.0, 5.0)),
|
||||
_case("안쪽 기울기 5°", 0.6, 100.0, "left_cut", (1.0, 3.0, 5.0, 1.5)),
|
||||
_case("소단 없음", 0.6, 100.0, "left_cut", None),
|
||||
]
|
||||
|
||||
|
||||
def _python(case):
|
||||
berm = BermSpec(*case["berm"]) if case["berm"] else None
|
||||
return compute_cross_design(
|
||||
case["samples"],
|
||||
case["z"],
|
||||
standard=STANDARD_CROSS_SECTION,
|
||||
ground_type="soil",
|
||||
section_mode=case["mode"],
|
||||
ditch_side="left",
|
||||
berm=berm,
|
||||
)
|
||||
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeCrossDesign } from "./common_util_cross_design.js";
|
||||
import { fillProfilePoints } from "./common_util_cross_berm.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const designs = input.cases.map((c) => computeCrossDesign(c.samples, c.z, {
|
||||
standard: input.standard, groundType: "soil", sectionMode: c.mode, ditchSide: "left",
|
||||
berm: c.berm ? { widthM: c.berm[0], intervalM: c.berm[1], slopeDeg: c.berm[2], depthM: c.berm[3] } : null,
|
||||
}));
|
||||
const profile = fillProfilePoints(4, 99.9, 1.5, { widthM: 0.5, intervalM: 3, slopeDeg: 0, depthM: 1 }, 20, 100);
|
||||
writeFileSync(process.argv[3], JSON.stringify({ designs, profile }));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_results(tmp_path_factory):
|
||||
if not TSC.is_file():
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
tmp = tmp_path_factory.mktemp("berm")
|
||||
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,
|
||||
)
|
||||
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": CASES, "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 _flats(design: dict) -> list[tuple[float, float, float]]:
|
||||
"""성토(오른쪽 · 음수 오프셋) 노면 끝 밖의 평탄 · 완만 도막 [(안 오프셋, 바깥 오프셋, 표고)]."""
|
||||
edge = design["road_edges"]["right"]["offset_m"]
|
||||
line = sorted(design["design_line"], key=lambda p: -p["offset_m"]) # 노면 → 바깥
|
||||
found: list[tuple[float, float, float]] = []
|
||||
for a, b in zip(line, line[1:], strict=False):
|
||||
run = a["offset_m"] - b["offset_m"]
|
||||
if a["offset_m"] > edge + 1e-6 or run <= 1e-6:
|
||||
continue
|
||||
if abs(b["elevation_m"] - a["elevation_m"]) / run < 0.1: # 소단(≤ 5°) · 지반 0.15 는 빠짐
|
||||
if found and abs(found[-1][1] - a["offset_m"]) < 1e-6: # 이어진 도막은 하나로
|
||||
found[-1] = (found[-1][0], b["offset_m"], found[-1][2])
|
||||
else:
|
||||
found.append((a["offset_m"], b["offset_m"], a["elevation_m"]))
|
||||
return found
|
||||
|
||||
|
||||
def _close(left, right, path=""):
|
||||
if isinstance(left, dict):
|
||||
assert set(left) == set(right), f"{path} 키 다름"
|
||||
for key in left:
|
||||
_close(left[key], right[key], f"{path}.{key}")
|
||||
elif isinstance(left, list):
|
||||
assert len(left) == len(right), f"{path} 길이 {len(left)} vs {len(right)}"
|
||||
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}"
|
||||
|
||||
|
||||
def test_파이썬_TS_설계선_같음(ts_results):
|
||||
for case, ts in zip(CASES, ts_results["designs"], strict=True):
|
||||
_close(_python(case), ts, case["name"])
|
||||
|
||||
|
||||
def test_사면_꼭짓점_거울():
|
||||
py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100)
|
||||
assert py[1] == pytest.approx((4 + 0.9 * 1.5, 99.0)) # 노면 끝 100 − 1.0
|
||||
assert py[2] == pytest.approx((4 + 0.9 * 1.5 + 0.5, 99.0)) # 폭 0.5 수평
|
||||
assert len(py) == 4 # 한 단만 · 그 아래 같은 경사로 끝까지
|
||||
|
||||
|
||||
def test_사면_꼭짓점_TS_같음(ts_results):
|
||||
py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100)
|
||||
_close([list(p) for p in py], ts_results["profile"], "profile")
|
||||
|
||||
|
||||
def test_한_단_깊이_폭():
|
||||
by = {case["name"]: _python(case) for case in CASES}
|
||||
edge_z = by["깊이 1.0 · 폭 0.5"]["road_edges"]["right"]["elevation_m"]
|
||||
flats = _flats(by["깊이 1.0 · 폭 0.5"])
|
||||
assert len(flats) == 1
|
||||
inner, outer, z = flats[0]
|
||||
assert z == pytest.approx(edge_z - 1.0, abs=1e-3)
|
||||
assert inner - outer == pytest.approx(0.5, abs=1e-3)
|
||||
assert by["깊이 1.0 · 폭 0.5"]["berm"]["depth_m"] == 1.0
|
||||
|
||||
zero = _flats(by["깊이 0 = 노면 끝 높이"])
|
||||
assert len(zero) == 1 and zero[0][2] == pytest.approx(edge_z, abs=1e-3)
|
||||
assert by["깊이 음수 = 0"]["design_line"] == by["깊이 0 = 노면 끝 높이"]["design_line"]
|
||||
assert by["깊이 음수 = 0"]["berm"]["depth_m"] == 0.0
|
||||
|
||||
assert _flats(by["깊이 > 사면 높이 = 소단 없음"]) == []
|
||||
assert _flats(by["소단 없음"]) == []
|
||||
# 소단이 선 만큼 성토 사면이 바깥으로 밀려 성토 면적이 늘어남
|
||||
assert by["깊이 1.0 · 폭 0.5"]["fill_area_m2"] > by["소단 없음"]["fill_area_m2"]
|
||||
|
||||
|
||||
def test_절토_소단_끔():
|
||||
"""좌측 절토 사면은 소단을 놓아도 소단 없는 단면과 같음."""
|
||||
with_berm = _python(CASES[0])
|
||||
plain = _python(CASES[-1])
|
||||
left = [p for p in with_berm["design_line"] if p["offset_m"] > 0]
|
||||
assert left == [p for p in plain["design_line"] if p["offset_m"] > 0]
|
||||
assert with_berm["cut_area_m2"] == plain["cut_area_m2"]
|
||||
|
||||
|
||||
def test_저장분_깊이_읽기():
|
||||
"""[저장] 재계산이 읽는 저장분 — 없으면 기본 1.0 · 음수는 0."""
|
||||
from B06_Section.B06_Section_Router_Design import stored_berm
|
||||
|
||||
assert stored_berm({"berm": {"width_m": 0.5}}).depth_m == 1.0
|
||||
assert stored_berm({"berm": {"width_m": 0.5, "depth_m": -1}}).depth_m == 0.0
|
||||
assert stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}}).depth_m == 2.5
|
||||
|
||||
|
||||
def test_좌측_패널_칸_구간_깊이_나름():
|
||||
"""구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴)."""
|
||||
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")
|
||||
assert "depthM: spec.depth_m" in refresh
|
||||
@@ -0,0 +1,29 @@
|
||||
"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」(값 그대로) · 견적서 = 견적 아래.
|
||||
|
||||
소스 시험(열 정의 · 고칠 칸) — 셈 · 저장(`quote_page` 남음)은 안 바뀜(test_166_4).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _compare() -> str:
|
||||
start = SRC.index("export const buildCompare")
|
||||
return SRC[start : SRC.index("export const buildMaterial")]
|
||||
|
||||
|
||||
def test_견적은_단가_견적서_그_밖은_단가_출처():
|
||||
body = _compare()
|
||||
assert (
|
||||
's === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s])' in body
|
||||
)
|
||||
assert '"면"' not in body # 면 머리 없음
|
||||
assert body.count('text("files", "견적서"') == 1 # 맨 끝 견적서 열 지움
|
||||
|
||||
|
||||
def test_견적_면_수기_칸_없앰():
|
||||
body = _compare()
|
||||
assert 'key === "g:견적"' not in body and "quote_page" not in body
|
||||
assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body
|
||||
@@ -0,0 +1,88 @@
|
||||
"""183-1 재료비목록표 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재만 · 수량 · 단가 한 줄 · 쓰는 곳.
|
||||
|
||||
- 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도 든다(`Linked.used_keys`)
|
||||
- 거름: 대가 · 산근 · 중기 블록 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(`Summary.used_materials`)
|
||||
- 수량 = 입력 `material_qty`(1차 역전개 총수량) — `Calc_Base` 짝 거울 · 두 번 셈(`Router.compute_twice`)
|
||||
- 화면 비고 = 쓰는 곳(`UI_Tabs_Base.usesOf`)
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from B09_Estimation import B09_Estimation_Router as router_mod
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Linked import used_keys
|
||||
from B09_Estimation.B09_Estimation_Summary import used_materials
|
||||
from resources.tester.test_144_b09_base_calc import _run_ts
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
MATERIALS = [
|
||||
{"key": "MT1", "name": "쓰는 자재", "prices": {"물가자료": 1000}},
|
||||
{"key": "MT2", "name": "고르기 후보", "prices": {"물가자료": 500}},
|
||||
{"key": "MT3", "name": "관급 표시", "prices": {"물가자료": 300}},
|
||||
{"key": "MO1", "name": "경유", "prices": {"유가": 1800}},
|
||||
{"key": "b08:pipe_800", "name": "파형강관", "prices": {}, "qty": 8, "supply": "관급"},
|
||||
]
|
||||
USER = {"materials": {"MT3": {"supply": "관급"}}}
|
||||
SG = [{"key": "SG1", "no": 1, "lines": [{"kind": "material", "ref": {"key": "MT1"}, "qty": 2}]}]
|
||||
MACHINE = [{"key": "EQ1", "lines": [{"kind": "material", "ref": {"key": "MO1"}, "qty": None}]}]
|
||||
|
||||
|
||||
def test_사본_일위대가_자재_사전조합_자재_코드도_수요(tmp_path):
|
||||
labor = {
|
||||
"줄": {"담은로직": [], "자재": [{"코드": "MT000053"}, {"코드": "PM000001"}]},
|
||||
"참조사본": {"참조|system||SC-000001": {"담은로직": [], "자재": [{"코드": "MT000008"}]}},
|
||||
}
|
||||
(tmp_path / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8")
|
||||
assert used_keys(tmp_path) == ["MT000053", "MT000008"] # 개인 자재(PM) = 원가 표 밖
|
||||
|
||||
|
||||
def test_쓰는_자재만_남김():
|
||||
sheets = {"sg": SG, "daega_blocks": [], "machine_blocks": MACHINE}
|
||||
inputs = {"materials": MATERIALS}
|
||||
assert used_materials(sheets, inputs, USER) == {"MT1", "MO1", "MT3", "b08:pipe_800"}
|
||||
|
||||
|
||||
def test_수량_원천_material_qty_거울():
|
||||
inputs = {"labor": [], "machines": [], "materials": MATERIALS}
|
||||
old = {r["key"]: r["qty"] for r in compute_base(inputs, USER)["material"]}
|
||||
assert old["b08:pipe_800"] == 8 and old["MT1"] is None # 없으면 입력 줄 수량(지금 길)
|
||||
got = compute_base({**inputs, "material_qty": {"MT1": 6, "b08:pipe_800": 8}}, USER)
|
||||
rows = {r["key"]: r for r in got["material"]}
|
||||
assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6, 6000)
|
||||
assert rows["MT2"]["qty"] is None and rows["MT2"]["amount"] is None # 없는 키 = 빈칸
|
||||
for case in (inputs, {**inputs, "material_qty": {"MT1": 6}}):
|
||||
assert _run_ts(case, USER) == compute_base(case, USER)
|
||||
|
||||
|
||||
def test_두_번_셈_후보_줄_빠지고_수량_역전개(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake(root, inputs, user):
|
||||
calls.append([m["key"] for m in inputs["materials"]])
|
||||
base = compute_base(inputs, user)
|
||||
bill = {"rows": [{"kind": "item", "qty": 3, "ref": {"tab": "sg", "key": "SG1"}}]}
|
||||
return {**base, "sg": SG, "daega_blocks": [], "machine_blocks": MACHINE, "bill": bill}
|
||||
|
||||
monkeypatch.setattr(router_mod, "compute_sheets", fake)
|
||||
inputs = {"labor": [], "machines": [], "materials": list(MATERIALS)}
|
||||
sheets, summary = router_mod.compute_twice(Path("."), inputs, USER)
|
||||
assert calls[0] == [m["key"] for m in MATERIALS]
|
||||
assert calls[1] == ["MT1", "MT3", "MO1", "b08:pipe_800"] # 후보 MT2 뺌
|
||||
assert inputs["material_qty"] == {"MT1": 6.0} # 설계내역서 3 × 호표 2
|
||||
rows = {r["key"]: r for r in sheets["material"]}
|
||||
assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6.0, 6000)
|
||||
assert [r["qty"] for r in summary["sum_material"]] == [rows["MT1"]["qty"]] # 집계표와 같은 값
|
||||
|
||||
|
||||
def test_화면_비고_쓰는_곳():
|
||||
src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8")
|
||||
for label in (
|
||||
"`대가 ${b.no}호표`",
|
||||
"`산근 ${b.no}호표`",
|
||||
"`중기 ${n + 1}호표`",
|
||||
'"내역 자재대"',
|
||||
):
|
||||
assert label in src
|
||||
assert 'text("uses", "비고(쓰는 곳)"' in src and 'text("origin", "비고"' not in src
|
||||
@@ -28,7 +28,7 @@ TABLE = {
|
||||
"옹벽",
|
||||
["wall_form", "length_m", "height_m", "before_m", "after_m"],
|
||||
),
|
||||
"berm": ("gbermdetail", "상세 내용", ["width_m", "height_m"]),
|
||||
"berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2)
|
||||
}
|
||||
SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]
|
||||
|
||||
|
||||
@@ -133,17 +133,10 @@ def test_group_a_point_and_revetment_facility_join(tmp_path):
|
||||
|
||||
|
||||
def test_other_groups_do_not_plant_stations(tmp_path):
|
||||
"""C·E·G군은 측점을 만들지 않는다 — 지금 켠 것은 A군과 D군 구간형뿐이다."""
|
||||
"""E·G군은 측점을 만들지 않는다 — 켠 것은 A군과 C·D군 구간형뿐이다(178-1)."""
|
||||
_write_structures(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type_id": "retaining_wall",
|
||||
"chainage_m": 40.0,
|
||||
"start_m": 40.0,
|
||||
"end_m": 60.0,
|
||||
"placement": "interval",
|
||||
},
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"chainage_m": 300.0,
|
||||
@@ -157,6 +150,40 @@ def test_other_groups_do_not_plant_stations(tmp_path):
|
||||
assert [chainage for chainage, _label in extras] == [84.3, 149.73, 200.92, 275.71]
|
||||
|
||||
|
||||
def test_slope_group_interval_plants_start_anchor_end(tmp_path):
|
||||
"""C군 구간형(옹벽·흙막이·소단)도 시작·기준·종료 셋을 심는다 — 격자 사이 벽도 횡단에 얹힘(178-1)."""
|
||||
_write_structures(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type_id": "soil_guard",
|
||||
"chainage_m": 45.0,
|
||||
"start_m": 40.0,
|
||||
"end_m": 50.0,
|
||||
"placement": "interval",
|
||||
},
|
||||
{
|
||||
"type_id": "retaining_wall",
|
||||
"chainage_m": 125.0,
|
||||
"start_m": 120.0,
|
||||
"end_m": 130.0,
|
||||
"placement": "interval",
|
||||
},
|
||||
{
|
||||
"type_id": "berm",
|
||||
"chainage_m": 165.0,
|
||||
"start_m": 160.0,
|
||||
"end_m": 170.0,
|
||||
"placement": "interval",
|
||||
},
|
||||
],
|
||||
)
|
||||
extras = resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS))
|
||||
assert [c for c, label in extras if label == "흙막이"] == [40.0, 45.0, 50.0]
|
||||
assert [c for c, label in extras if label == "옹벽"] == [120.0, 125.0, 130.0]
|
||||
assert [c for c, label in extras if label == "소단"] == [160.0, 165.0, 170.0]
|
||||
|
||||
|
||||
def test_broken_structures_file_keeps_pipe_derived_stations(tmp_path):
|
||||
route_dir = tmp_path / "B05_Profile" / "route"
|
||||
route_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
Reference in New Issue
Block a user