Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-10 16:44:26 +09:00
19 changed files with 364 additions and 213 deletions
@@ -0,0 +1,154 @@
/* 225-1 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 헬퍼 — 짝 = `test_225_1_pipe_round.py`.
* 입력 = 인자(반올림 거울 값 JSON 배열) · B07 픽스처 배관 측점(139-6 과 같은 자료).
*/
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();
const noop = new Proxy({}, { get: () => () => "" });
/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */
class El {
constructor(tag) {
this.tag = tag;
this.attrs = {};
this.children = [];
this.parentNode = null;
this.ownerSVGElement = null;
this.textContent = "";
}
setAttribute(k, v) {
this.attrs[k] = String(v);
}
getAttribute(k) {
return this.attrs[k] ?? null;
}
appendChild(c) {
c.parentNode = this;
c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement;
this.children.push(c);
return c;
}
append(...cs) {
cs.forEach((c) => this.appendChild(c));
}
addEventListener() {}
all() {
return this.children.flatMap((c) => [c, ...c.all()]);
}
querySelectorAll(sel) {
return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1)));
}
querySelector(sel) {
return this.querySelectorAll(sel)[0] ?? null;
}
}
globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
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 resolve = (base) =>
fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`;
const req = (r) => {
if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop;
if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), r)));
};
new Function("exports", "module", "require", js)(box.exports, box, req);
cache.set(full, box.exports);
return box.exports;
}
const B06 = (name) => loadTs(path.join(ROOT, "B06_Section", `${name}.ts`));
const { computeCulvertLayout } = B06("B06_Section_UI_Cross_Culvert_Geom");
const { cutLength } = B06("B06_Section_UI_Cross_Culvert_Solve");
const { pipeLengthText, pipeLengthWhole } = B06("B06_Section_UI_Cross_Culvert_Const");
const { appendPipeInfoText } = B06("B06_Section_UI_Cross_Culvert_Types");
const { structurePanelDeps } = B06("B06_Section_UI_Cross_View_Structure");
// ① 글 자리 — 노폭 · 노견 라벨(x 200 · y 180) 바로 위 줄 · 둘이면 한 줄 더 위 · 라벨 없으면 fallback
const svg = new El("svg");
const road = new El("text");
road.setAttribute("class", "b06-chart__carriageway-label");
road.setAttribute("x", 200);
road.setAttribute("y", 180);
svg.appendChild(road);
const plot = new El("g");
svg.appendChild(plot);
appendPipeInfoText(plot, "관매설 Φ600m/m, L=8 m (7.6)", null);
appendPipeInfoText(plot, "세월교 L8m", [10, 10]);
const infos = svg.querySelectorAll(".b06-chart__pipe-info").map((e) => ({
x: Number(e.getAttribute("x")),
y: Number(e.getAttribute("y")),
text: e.textContent,
inSvg: e.parentNode === svg,
}));
const bare = new El("svg");
appendPipeInfoText(bare, "관", null);
appendPipeInfoText(bare, "세월교", [10, 20]);
const bareInfos = bare.querySelectorAll(".b06-chart__pipe-info").map((e) => ({
x: Number(e.getAttribute("x")),
y: Number(e.getAttribute("y")),
text: e.textContent,
}));
// ② 0.1 m 이동 — 걸음 분류 · 유출 벽을 0.1 m 씩 밀면 관 길이가 벽 자리를 따라 0.1 m 로 바뀜
const fine = (key, hiddenPipe, fordGuard) =>
structurePanelDeps({
hiddenPipe: () => hiddenPipe,
fordGuard: () => fordGuard,
isLinked: false,
}).fineMove(key);
const steps = {
pipeOutlet: fine("outlet", false, false),
pipeInlet: fine("inlet", false, false),
fordGuard: fine("outlet", true, true),
hiddenOwn: fine("outlet", true, false),
};
const sections = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
).cross_sections;
const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
const r3 = (v) => Math.round(v * 1000) / 1000;
// ◀/▶ 한 번 = x 0.1 + d 빈 칸은 지금 자리로 묶음(옛 기본 d 로 튀지 않게)
const patches = [];
const nudgeDeps = structurePanelDeps({
adjustOf: () => ({ x: 0, d: null, h: null, m: null }),
appliedD: () => new Map([["outlet", 0.4]]),
outwardOf: () => 1,
adjustChainage: () => 0,
revetOffset: { update: (_c, key, patch) => patches.push({ key, ...patch }) },
});
nudgeDeps.nudge("outlet", 0.1);
nudgeDeps.nudge("extra0", 0.1);
const base = computeCulvertLayout(section, section.samples);
const d0 = base.revetShift.outlet.d;
const moved = [0, 0.1, 0.2, 0.3].map((x) => {
const layout = computeCulvertLayout(section, section.samples, {
outlet: { x, d: x ? d0 : null, h: null, m: null },
});
return { x, lengthM: layout.pipe.lengthM, cut: r3(cutLength(layout.pipeCorners)) };
});
// ③ · ④ 표시 · 반올림 거울
const values = JSON.parse(process.argv[2] ?? "[]");
process.stdout.write(
JSON.stringify({
infos,
bareInfos,
steps,
patches,
moved,
text: values.map((v) => pipeLengthText(v)),
whole: values.map((v) => pipeLengthWhole(v)),
}),
);
+2 -2
View File
@@ -31,9 +31,9 @@ def out() -> dict:
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None:
"""배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수."""
"""배관 기준벽(0.1 m 걸음 · 225-1) — 사면 d · 수평 x 가 한 번에 · 값은 걸음 배수."""
case = out["wall"][side]
assert case["update"] == {"role": "outlet", "x": 1, "d": 4}
assert case["update"] == {"role": "outlet", "x": 1.3, "d": 4}
assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"]
assert "▼ 2.0m" in case["snap"]["text"]
+6 -6
View File
@@ -1,7 +1,7 @@
"""139-6 노견을 넓힌 유출 쪽 관 길이 자리 — 사면 우선(물매 1:1.2 그대로) + 유출 벽 높이 역산(0.1 m).
설계선 엔진으로 노견 확장 설계를 내고 배수관 기하를 돌린 값(`helper_139_6_pipe_fit.cjs` · B07 픽스처
배관 85.052 · 유출 = 좌). 안 넓힌 측점은 종전 맞춤 그대로(나눔 전 코드와 같은 값 · 5.772 → 표기 6).
배관 85.052 · 유출 = 좌). 225-1 부터 정수 길이 맞춤 · 높이 역산 없음 — 관 길이 = 그려진 길이 0.1 m.
"""
import json
@@ -40,8 +40,9 @@ def out(tmp_path_factory) -> dict:
def test_안_넓힌_측점은_종전_그대로(out: dict) -> None:
"""225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8)."""
base = out["base"]
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 6)
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8)
assert base["cut"] == pytest.approx(5.772)
@@ -51,10 +52,9 @@ def test_넓힌_쪽은_물매_그대로_높이_역산(out: dict, name: str, exte
assert case["edge"] == pytest.approx(2 + extend) and case["extend"] == extend
assert case["slope"] == pytest.approx(1.2, abs=0.05) # 사면 우선 — 물매 그대로
assert round(case["pureHeight"] * 10) == pytest.approx(case["pureHeight"] * 10) # 0.1 m 눈금
assert case["lengthM"] - case["cut"] < 0.1 # 눈금 탓 남는 차만 표기 올림
assert float(case["lengthM"]).is_integer()
assert case["lengthM"] == round(case["cut"], 1) # 225-1 — 길이는 벽 자리에서 셈(올림 없음)
def test_높이가_실제로_바뀜(out: dict) -> None:
"""대조 — 1.3 m 넓히면 유출 벽 높이가 2.0 → 1.8 로 역산됨(시험이 높이 길을 실제로 잼)."""
assert out["shifted2"]["pureHeight"] == pytest.approx(1.8)
"""225-1 — 길이 맞춤 높이 역산 없앰: 1.3 m 넓혀도 유출 벽 높이는 기본 2.0 그대로."""
assert out["shifted2"]["pureHeight"] == pytest.approx(2.0)
+2 -1
View File
@@ -63,7 +63,8 @@ def test_저장값_사본_면적(out: dict) -> None:
"""접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만."""
assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3)
assert out["stored"]["fill"] == 0
assert out["auto"]["cut"] == pytest.approx(16.8211, abs=1e-3)
# 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929)
assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3)
def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None:
+117
View File
@@ -0,0 +1,117 @@
"""225-1 B06 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 · B08 집계 정수.
화면 TS 를 그 자리에서 돌린 값(`helper_225_1_pipe_round.cjs`) + 파이썬 집계(`M02_Table_Fill`).
짝(반올림) = TS `pipeLengthWhole`(`B06_Section_UI_Cross_Culvert_Const.ts`) ↔
파이썬 `pipe_lengths_from_designs` — 사사오입 · 0.5 올림.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from M02_MasterTemplete.M02_Table_Fill import pipe_lengths_from_designs # noqa: E402
HELPER = Path(__file__).parent / "helper_225_1_pipe_round.cjs"
VALUES = [0.5, 1.4, 1.5, 2.5, 7.4, 7.5, 7.6, 10.0]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER), json.dumps(VALUES)],
cwd=str(ROOT),
capture_output=True,
text=True,
encoding="utf-8",
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_관_정보_글은_노폭_라벨_바로_위(out: dict) -> None:
"""노폭 · 노견 라벨(y 180) 위 줄 = 180 − 13 · 세월교 글이 또 있으면 한 줄 더 위 · 같은 x."""
pipe, ford = out["infos"]
assert (pipe["x"], pipe["y"], pipe["inSvg"]) == (200, 167, True)
assert pipe["text"] == "관매설 Φ600m/m, L=8 m (7.6)"
assert (ford["x"], ford["y"]) == (200, 154)
def test_노폭_라벨_없으면_옛_자리_또는_안_그림(out: dict) -> None:
"""B07 덧그림(라벨 없음) — 관 글은 안 그림 · 세월교 글은 옛 자리."""
assert out["bareInfos"] == [{"x": 10, "y": 20, "text": "세월교"}]
def test_관보호공은_0_1m_걸음_세월교_흙막이만_1m(out: dict) -> None:
assert out["steps"] == {
"pipeOutlet": True,
"pipeInlet": True,
"fordGuard": False,
"hiddenOwn": True,
}
def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None:
"""d 빈 칸 그대로 x 만 넣으면 옛 기본 d(경사길이 4 m)로 튐 — 배관 기준벽만 묶음."""
assert out["patches"] == [
{"key": "outlet", "x": 0.1, "d": 0.4},
{"key": "extra0", "x": 0.1},
]
def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None:
"""유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음."""
lengths = [case["lengthM"] for case in out["moved"]]
assert lengths == [7.9, 8.0, 8.1, 8.2]
for case in out["moved"]:
assert case["lengthM"] == round(case["cut"], 1)
def test_표시는_정수_괄호_소수_첫째(out: dict) -> None:
assert out["text"][VALUES.index(7.6)] == "8 m (7.6)"
assert out["text"][VALUES.index(7.4)] == "7 m (7.4)"
assert out["text"][VALUES.index(7.5)] == "8 m (7.5)"
def test_B08_집계_값은_사사오입_정수() -> None:
designs = [
{"chainage_m": 10, "design": {"pipe_length_m": 7.6}},
{"chainage_m": 20, "design": {"pipe_length_m": 7.5}},
{"chainage_m": 30, "design": {"pipe_length_m": 7.4}},
]
assert pipe_lengths_from_designs(designs) == {10.0: 8, 20.0: 8, 30.0: 7}
def test_반올림_짝_거울(out: dict) -> None:
"""TS `pipeLengthWhole` ↔ 파이썬 집계 — 같은 값마다 같은 정수(0.5 올림)."""
designs = [{"chainage_m": i, "design": {"pipe_length_m": v}} for i, v in enumerate(VALUES)]
py = pipe_lengths_from_designs(designs)
assert [py[float(i)] for i in range(len(VALUES))] == out["whole"]
assert out["whole"] == [1, 1, 2, 3, 7, 8, 8, 10]
ts = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text(
encoding="utf-8"
)
assert "Math.floor(lengthM + 0.5)" in ts and "M02_Table_Fill.pipe_lengths_from_designs" in ts
src = (ROOT / "M02_MasterTemplete" / "M02_Table_Fill.py").read_text(encoding="utf-8")
assert "int(length + 0.5)" in src and "pipeLengthWhole" in src
def test_카드_글과_좌측_패널이_같은_표시를_씀() -> None:
card = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert.ts").read_text(encoding="utf-8")
assert (
"L=${pipeLengthText(pipe.lengthM)}" in card and "appendPipeInfoText(layer, pipeInfo" in card
)
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text(
encoding="utf-8"
)
assert '"횡단 설계 뒤 셈" : pipeLengthText(length)' in panel
+2 -2
View File
@@ -111,11 +111,11 @@ def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None:
assert pipe is not None
# 벽 윗면 = 관 윗면(139-1) · 관 위 토피 0.5 → 유입 벽 윗면 = 노견 − 0.5 = 사면 d 0.6(1:1.2)
assert pipe["d"]["inlet"] == pytest.approx(0.6, abs=1e-6)
# 유출은 관 m 단위 맞춤이 사면 아래로 민 만큼만 내려감(종전 기본 = 경사길이 4 m · d 3.07)
# 유출은 기본 자리(225-1 — 관 길이 맞춤 이동 없음 · 종전 기본 = 경사길이 4 m · d 3.07)
assert pipe["d"]["outlet"] < 1.0
inlet = next(w for w in pipe["walls"] if w["role"] == "inlet")
assert inlet["topBelowEdge"] == pytest.approx(0.5, abs=0.05)
assert float(pipe["lengthM"]).is_integer()
assert round(pipe["lengthM"], 1) == pipe["lengthM"] # 225-1 — 실제 길이 0.1 m
def test_저장_x_있는_관_보호공은_종전_자리_그대로(shape: dict) -> None: