Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4

This commit is contained in:
2026-10-09 14:59:56 +09:00
14 changed files with 414 additions and 44 deletions
@@ -0,0 +1,96 @@
/* 136-1 폼 → 캐시 chute 헬퍼 — `applyPipeOptionsToCache`(B06_Section_UI_Page_Pipe_Options.ts)를 그 자리에서
* 트랜스파일해 돌림 · 도수로/돌붙임 상자가 관 · BOX · 세월교 · 물넘이 포장 제원 `chute` 에 바로 얹히는지 ·
* 다시 그린 카드. 짝 = `test_136_chute_form_cache.py`. 로더는 `helper_b06_draft_pipe_replay.cjs` 와 같음.
*/
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 session = new Map();
const fakeStorage = {
getItem: (k) => session.get(k) ?? null,
setItem: (k, v) => session.set(k, String(v)),
removeItem: (k) => session.delete(k),
};
globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage };
globalThis.sessionStorage = fakeStorage;
globalThis.localStorage = fakeStorage;
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 (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
if (request.startsWith("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
if (request.startsWith("@config/"))
return loadTs(path.join(ROOT, "config", `${request.slice(8)}.ts`));
return 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 options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"));
const box = (at) => ({
chainage_m: 120,
top_thickness_m: 0.3,
inner_height_m: 2,
slab_thickness_m: 0.3,
wing_in: {},
wing_out: {},
_at: at,
});
const detail = {
cross_sections: [
{ chainage_m: 85, culvert: { chainage_m: 85.05, diameter_m: 1, inlet: {}, outlet: {} } },
{ chainage_m: 118, box: box(118) },
{ chainage_m: 120, box: box(120) },
{ chainage_m: 160, ford: { chainage_m: 160, diameter_m: 1, wing_in: {}, wing_out: {} } },
{ chainage_m: 196, ford_pavement: { chainage_m: 200, span_m: 9, depth_m: 0.3 } },
{ chainage_m: 200, ford_pavement: { chainage_m: 200, span_m: 9, depth_m: 0.3 } },
],
};
let refreshed = [];
const ctx = {
detail: () => detail,
ford: { setPipe: () => {}, setWing: () => {} },
box: { setBody: () => {}, setWing: () => {} },
revetOffset: { update: () => {}, adjustFor: () => ({ h: null, m: null }) },
overrideOptions: () => {},
refreshCard: (at) => refreshed.push(at),
};
const at = (i) => detail.cross_sections[i];
const step = (chainage, patch, whole = true) => {
refreshed = [];
options.applyPipeOptionsToCache(ctx, chainage, patch, whole);
return {
pipe: at(0).culvert.chute ?? null,
box: [at(1).box.chute ?? null, at(2).box.chute ?? null],
ford: at(3).ford.chute ?? null,
pave: [at(4).ford_pavement.chute ?? null, at(5).ford_pavement.chute ?? null],
refreshed: [...new Set(refreshed)].sort((a, b) => a - b),
};
};
const out = {
pipeAdd: step(85.05, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 40 }),
pipeSame: step(85.05, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 40 }),
replayNoChute: step(85.05, { pipe_diameter_mm: 1000 }, false),
pipeRemove: step(85.05, { pipe_diameter_mm: 1000 }),
boxAdd: step(120, { chute_add_1_structure: "도수로" }),
fordAdd: step(160, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 50 }),
paveAdd: step(200, { ford_chute_1_structure: "돌붙임", chute_add_1_structure: "도수로" }),
};
process.stdout.write(JSON.stringify(out));
@@ -200,7 +200,13 @@ form.write(PIPE_SPEC, {
soil_add_1_form: "콘크리트",
soil_add_2_form: "돌망태",
soil_add_2_height: 1.5,
chute_add_1_structure: "돌붙임",
});
// 상자 이름표 단추(136-3) — 글 「삭제」 + title
out.remove = form.root
.querySelectorAll("button")
.filter((b) => b.dataset.specRemove)
.map((b) => ({ id: b.dataset.specRemove, text: b.textContent, title: b.title }));
const boxes = () =>
form.root.children.filter((k) => k.dataset?.specContainer === "soil_add").length;
const soilKeys = () =>
@@ -82,7 +82,14 @@ POINTS = [
{
"chainage_m": 500.0,
"facility": "ford_pavement",
"options": {"ford_width_m": 8, "ford_height_m": 1.0},
"options": {
"ford_width_m": 8,
"ford_height_m": 1.0,
# 물넘이 포장 도수로/돌붙임(136-2) — 관 · 박스 · 세월교와 같은 열
"ford_chute_1_structure": "돌붙임",
"ford_chute_1_bond": "찰붙임",
"ford_chute_1_area": 12,
},
},
]
STRUCTURES = [
@@ -177,6 +184,7 @@ def test_물넘이_H별_월류폭(out):
pave = _col(out, "pipe:pave|1.0")
assert pave["group"] == ["물넘이포장", "월류 폭", "H=1.0"] and pave["label"] == "m"
assert _cell(out, 500, "pipe:pave|1.0") == 8
assert _cell(out, 500, "pipe:chute|찰붙임") == 12
def test_개거_노출형_맹암거_개소(out):
@@ -0,0 +1,44 @@
"""136-3 상자 이름표 [빼기] → [삭제] · 「구조물 조합」 [선택]과 같은 작은 단추 틀 ·
흐림은 이름표 글에만.
공용 명세 폼(`design_structure_form.ts`)을 그 자리에서 돌린 값
(`helper_54_10_soil_tiers.cjs` 의 `remove`) + CSS 규칙(`B05_Profile_UI_Style_Structures.css`).
"""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
CSS = (ROOT / "B05_Profile" / "B05_Profile_UI_Style_Structures.css").read_text(encoding="utf-8")
def _rule(selector: str) -> str:
"""선택자부터 그 규칙 끝(`}`)까지."""
at = CSS.index(selector)
return CSS[at : CSS.index("}", at)]
@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치")
def test_이름표_단추_글은_삭제():
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_54_10_soil_tiers.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
)
assert done.returncode == 0, done.stderr
remove = json.loads(done.stdout)["remove"]
assert {"id": "chute_add", "text": "삭제", "title": "도수로/돌붙임 상자 삭제"} in remove
assert all(b["text"] == "삭제" for b in remove)
def test_이름표_단추는_작은_단추_틀_흐림은_글에만():
small = _rule(".b05-structure__group > legend > button")
assert ".b05-structure__group-body button:not(.b05-structure__step)" in CSS
assert "height: var(--b05-control-h)" in small and "padding-block: 0" in small
legend = _rule(".b05-structure__group > legend {")
assert "opacity" not in legend and "currentColor 80%" in legend
@@ -0,0 +1,55 @@
"""136-1 폼 → 캐시 chute — 좌측 패널 도수로/돌붙임 상자가 [저장] 전에 횡단 제원에 바로 얹힘.
관 · BOX(폭 걸친 측점 둘) · 세월교 · 물넘이 포장(`ford_chute_*`) · 지움 · 일부만 온 되얹기.
화면 TS 를 그 자리에서 돌린 값(`helper_136_chute_form_cache.cjs`).
"""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
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(HERE / "helper_136_chute_form_cache.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_관_상자_넣으면_바로_chute_와_카드(out: dict) -> None:
assert out["pipeAdd"]["pipe"] == {"structure": "돌붙임", "thickness_m": 0.4}
assert 85 in out["pipeAdd"]["refreshed"]
# 같은 값이면 chute 때문에 다시 그리진 않음(관 길의 카드 갱신만)
assert out["pipeSame"]["pipe"] == out["pipeAdd"]["pipe"]
def test_되얹기는_상자_없으면_그대로_칸_전부면_지움(out: dict) -> None:
assert out["replayNoChute"]["pipe"] == {"structure": "돌붙임", "thickness_m": 0.4}
assert out["pipeRemove"]["pipe"] is None
def test_박스는_걸친_측점_모두_세월교도(out: dict) -> None:
assert out["boxAdd"]["box"] == [{"structure": "도수로", "thickness_m": 0.3}] * 2
assert {118, 120} <= set(out["boxAdd"]["refreshed"])
assert out["fordAdd"]["ford"] == {"structure": "돌붙임", "thickness_m": 0.5}
assert out["fordAdd"]["refreshed"] == [160]
# 다른 관 지점은 안 건드림
assert out["fordAdd"]["pipe"] is None and out["fordAdd"]["box"][0] is not None
def test_물넘이_포장은_ford_chute_만(out: dict) -> None:
assert out["paveAdd"]["pave"] == [{"structure": "돌붙임", "thickness_m": 0.3}] * 2
assert out["paveAdd"]["refreshed"] == [196, 200]
@@ -131,3 +131,70 @@ def test_정의_그룹_경사각도():
) as f:
groups = json.load(f)["그룹"]["ford_pavement"]
assert [(g["id"], g["이름"]) for g in groups] == [("gfordpaveslope", "경사각도")]
OVERLAY = r"""
const fs = require("fs"), path = require("path");
const ROOT = process.argv[1];
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
const load = (file) => {
if (cache.has(file)) return cache.get(file);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(file, box.exports);
const req = (r) => (r.startsWith(".") ? load(path.join(path.dirname(file), `${r}.ts`)) : ({}));
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
};
class Node {
constructor(tag) { this.tag = tag; this.attrs = {}; this.children = []; }
setAttribute(k, v) { this.attrs[k] = String(v); }
getAttribute(k) { return this.attrs[k] ?? null; }
append(...kids) { this.children.push(...kids); }
set textContent(v) { this.text = v; }
}
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
const pave = load(path.join(ROOT, "B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts"));
// +offset = 좌측 · 좌 성토 1:1.5 · 우 절토 오름 — 유입 = 좌(left_cut 아님) → 유출 = 우
const design = (mode) => ({
section_mode: mode, cross_slope_pct: 2, design_elevation_m: 100, pavement_thickness_m: 0.2,
road_edges: { left: { offset_m: 2.5, elevation_m: 100 }, right: { offset_m: -2.5, elevation_m: 100 } },
design_line: [
{ offset_m: 7, elevation_m: 97 }, { offset_m: 2.5, elevation_m: 100 },
{ offset_m: -2.5, elevation_m: 100 }, { offset_m: -5.5, elevation_m: 98 },
{ offset_m: -6.5, elevation_m: 98 }, { offset_m: -8, elevation_m: 99 },
],
});
const spec = { span_m: 9, depth_m: 0.3, slope_pct: 2, chute: { structure: "돌붙임", thickness_m: 0.3 } };
const run = (mode, withChute = true) => {
const svg = new Node("g");
pave.appendFordPavementOverlay(svg, withChute ? spec : { ...spec, chute: undefined }, design(mode), (o) => o, (e) => e);
const band = svg.children.find((k) => k.attrs.class === "b06-chart__culvert-pitching");
return band ? band.attrs.points : null;
};
process.stdout.write(JSON.stringify({
outLeft: pave.outletFillLine(design("left_cut")), outRight: pave.outletFillLine(design("right_cut")),
band: run("left_cut"), none: run("left_cut", false),
}));
"""
def test_유출측_성토사면_띠_136_2():
proc = subprocess.run(
["node", "-e", OVERLAY, ROOT], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert proc.returncode == 0, proc.stderr
got = json.loads(proc.stdout)
# 유입 = 좌 → 유출 = 우(− 바깥) · 노견에서 내려가는 동안만(평지 · 오름에서 멈춤)
assert got["outLeft"] == [
{"offset": -2.5, "elevation": 100},
{"offset": -5.5, "elevation": 98},
{"offset": -6.5, "elevation": 98},
]
# right_cut = 유입 우 → 유출 좌 성토 1:1.5
assert got["outRight"] == [{"offset": 2.5, "elevation": 100}, {"offset": 7, "elevation": 97}]
assert got["band"] and got["band"].startswith("-2.5,100 -5.5,98")
assert got["none"] is None
@@ -100,7 +100,14 @@ POINTS = [
{
"chainage_m": 140.0,
"facility": "ford_pavement",
"options": {"ford_height_m": 0.3, "ford_slope_deg": 5},
"options": {
"ford_height_m": 0.3,
"ford_slope_deg": 5,
# 물넘이 포장 도수로/돌붙임(136-2) — `ford_chute_*` · 관 키(`chute_add_*`)는 안 읽음
"ford_chute_1_structure": "돌붙임",
"ford_chute_1_thickness": 40,
"chute_add_1_structure": "도수로",
},
},
{"chainage_m": 160.0, "facility": "ford_pavement", "options": {"ford_height_m": 0.2}},
]
@@ -213,3 +220,5 @@ def test_ford_box_guard_attached_as_culvert() -> None:
assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3}
assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
assert "chute" not in sections[40.0]["culvert"]
assert sections[140.0]["ford_pavement"]["chute"] == {"structure": "돌붙임", "thickness_m": 0.4}
assert "chute" not in sections[160.0]["ford_pavement"]