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

This commit is contained in:
2026-10-06 11:11:54 +09:00
11 changed files with 580 additions and 18 deletions
@@ -0,0 +1,216 @@
/* 다단 흙막이 ↔ 흙막이 컨테이너(PLAN 54-10) 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* ① 폼 → 다단 그림(`applyPipeOptionsToCache`) ② 밖에서 정한 개수보다 많은 컨테이너 값은 가리기만(`createSpecForm`).
* 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_10_soil_tiers.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();
// ── 화면 그릇 흉내 — 태그 · 클래스 · data 만 ─────────────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this.style = {};
this._classes = new Set();
this._text = "";
this.value = "";
const self = this;
this.classList = {
add: (...n) => n.forEach((c) => self._classes.add(c)),
remove: (...n) => n.forEach((c) => self._classes.delete(c)),
toggle: (c, f) =>
(f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c),
contains: (c) => self._classes.has(c),
};
}
set className(v) {
this._classes = new Set(String(v).split(/\s+/).filter(Boolean));
}
set textContent(v) {
this._text = String(v);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent ?? "").join("");
}
append(...kids) {
kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k));
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
add(kid) {
this.children.push(kid);
}
setAttribute(n, v) {
this[n] = v;
}
addEventListener() {}
closest() {
return null;
}
matches(sel) {
return sel.split(",").some((s) => {
s = s.trim();
return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase();
});
}
querySelectorAll(sel) {
const found = [];
const walk = (n) =>
(n.children ?? []).forEach((k) => {
if (k.matches?.(sel)) found.push(k);
walk(k);
});
walk(this);
return found;
}
querySelector(sel) {
return this.querySelectorAll(sel)[0] ?? null;
}
}
globalThis.document = { createElement: (t) => new FakeNode(t) };
globalThis.queueMicrotask = (fn) => fn(); // 폼 되돌림을 바로 받아 봄
globalThis.Option = function (text, value) {
const o = new FakeNode("option");
o.textContent = text;
o.value = value;
return o;
};
const toasts = [];
const STUBS = {
"@ui/ui_template_elements": { showToast: (msg) => toasts.push(msg) },
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
"./B06_Section_UI_Page_Ford_Controls": {
applyFordFormOptions: () => {},
applyBoxFormOptions: () => {},
},
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null },
};
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.startsWith("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
return loadTs(path.join(path.dirname(full), `${request}.ts`));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const out = {};
// ── ① 폼 → 다단 그림 ──────────────────────────────────────────────────────
const { applyPipeOptionsToCache } = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"),
);
const adjusts = {};
const updates = [];
const tiers = [];
const overrides = [];
const section = {
chainage_m: 100,
culvert: { diameter_m: 1, hidden_pipe: false, inlet: {}, outlet: { revet_form: "돌쌓기(찰)" } },
};
const ctx = {
detail: () => ({ cross_sections: [section] }),
ford: {},
box: {},
revetOffset: {
adjustFor: (_s, role) => adjusts[role] ?? { x: 0, d: null, h: null, m: null },
update: (_c, role, patch) => {
updates.push({ role, ...patch });
const now = adjusts[role] ?? { x: 0, d: null, h: null, m: null };
const m = patch.m === undefined ? now.m : patch.m;
// 기하 되받기(syncApplied) 흉내 — 형태 한계로 자름(돌쌓기(메) 2.0)
const h =
patch.h == null
? patch.h === undefined
? now.h
: null
: Math.min(patch.h, m === "돌쌓기(메)" ? 2.0 : 5.0);
adjusts[role] = { ...now, h, m };
},
},
structureSpan: {
tierValuesFor: () => ({ lengthM: 10, beforeM: 5, afterM: 5 }),
updateTier: (_s, key, patch) => tiers.push({ key, ...patch }),
},
soilTiers: () => 2,
overrideOptions: (_c, values) => overrides.push(values),
refreshCard: () => {},
};
applyPipeOptionsToCache(ctx, 100, {
soil_add_1_form: "콘크리트",
soil_add_1_height: 1.8,
soil_add_1_length: 8,
soil_add_1_before: 3,
soil_add_1_after: 5,
soil_add_2_form: "돌쌓기(메)",
soil_add_2_height: 2.5,
soil_add_3_height: 1.2, // 지형이 못 세운 단 — 그림에 안 감
});
out.filled = { updates: updates.splice(0), tiers: tiers.splice(0), overrides: overrides.splice(0) };
out.toasts = toasts.splice(0);
// 같은 값을 다시 보내면 다시 그리지 않음
applyPipeOptionsToCache(ctx, 100, {
soil_add_1_form: "콘크리트",
soil_add_1_height: 1.8,
soil_add_2_form: "돌쌓기(메)",
soil_add_2_height: 2.0,
});
out.same = { updates: updates.splice(0), tiers: tiers.splice(0) };
// 비운 칸 = 자동(높이 · 형태) · 구간값은 그대로
applyPipeOptionsToCache(ctx, 100, {});
out.cleared = { updates: updates.splice(0), tiers: tiers.splice(0) };
// 독립 기슭막이(관 숨김)는 다단을 안 만짐
section.culvert.hidden_pipe = true;
applyPipeOptionsToCache(ctx, 100, { soil_add_1_height: 1.0 });
out.hidden = updates.splice(0);
// ── ② 개수보다 많은 컨테이너 값은 가리기만 · 값은 남김 ───────────────────────
const { createSpecForm } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"),
);
const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts"));
let count = 1;
const form = createSpecForm(null, { count: (c) => (c.id === "soil_add" ? count : undefined) });
form.write(PIPE_SPEC, {
pipe_diameter_mm: 1000,
soil_add_1_form: "콘크리트",
soil_add_2_form: "돌망태",
soil_add_2_height: 1.5,
});
const boxes = () =>
form.root.children.filter((k) => k.dataset?.specContainer === "soil_add").length;
const soilKeys = () =>
Object.keys(form.read())
.filter((k) => k.startsWith("soil_add_"))
.sort();
out.form = { shown1: boxes(), keys1: soilKeys() };
count = 0; // 단 수 0 저장 — 옛 흙막이 값도 남음
form.recount();
out.form.shown0 = boxes();
out.form.keys0 = soilKeys();
count = 3;
form.recount();
out.form.shown3 = boxes();
out.form.keys3 = soilKeys();
process.stdout.write(JSON.stringify(out));
+92
View File
@@ -0,0 +1,92 @@
"""다단 흙막이 ↔ 흙막이 컨테이너 (PLAN 54-10).
짝: `B06_Section/B06_Section_UI_Page_Pipe_Options.ts`(컨테이너 값 → 다단 조정값 · 단별 구간값) ·
`A00_Common/design/design_structure_form.ts`(개수보다 많은 컨테이너는 가리기만 · 값은 남김) ·
`B06_Section/B06_Section_UI_Adjust_Dock.ts`(다단 벽 고르기 → 그 단의 흙막이 컨테이너).
실제 TS 를 `helper_54_10_soil_tiers.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(__file__)
ROOT = Path(HERE).resolve().parents[1]
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_54_10_soil_tiers.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def extras(updates: list[dict]) -> list[dict]:
return [u for u in updates if u["role"].startswith("extra")]
def test_컨테이너_형태_높이가_그_단_조정값으로(out) -> None:
assert extras(out["filled"]["updates"]) == [
{"role": "extra0", "h": 1.8, "m": "콘크리트"},
{"role": "extra1", "h": 2.0, "m": "돌쌓기(메)"},
]
def test_지형이_못_세운_단은_그림에_안_감(out) -> None:
roles = {u["role"] for u in out["filled"]["updates"]}
assert "extra2" not in roles
def test_높이_한도는_형태_규칙_그대로_폼에_되돌림(out) -> None:
assert out["filled"]["overrides"] == [{"soil_add_2_height": 2}]
assert out["toasts"] == ["흙막이 2 높이는 형태 한계로 2.0m까지만 적용됩니다."]
def test_길이_전_후는_단별_구간값(out) -> None:
assert out["filled"]["tiers"] == [{"key": "extra0", "lengthM": 8, "beforeM": 3, "afterM": 5}]
def test_같은_값은_다시_그리지_않음(out) -> None:
assert extras(out["same"]["updates"]) == []
assert out["same"]["tiers"] == []
def test_비운_칸은_자동_구간값은_그대로(out) -> None:
assert extras(out["cleared"]["updates"]) == [
{"role": "extra0", "h": None, "m": None},
{"role": "extra1", "h": None, "m": None},
]
assert out["cleared"]["tiers"] == []
def test_독립_기슭막이는_다단을_안_만짐(out) -> None:
assert extras(out["hidden"]) == []
def test_개수보다_많은_컨테이너는_가리고_값은_남김(out) -> None:
form = out["form"]
kept = ["soil_add_1_form", "soil_add_2_form", "soil_add_2_height"]
assert (form["shown1"], form["keys1"]) == (1, kept)
assert (form["shown0"], form["keys0"]) == (0, kept) # 단 수 0 저장에도 남음
assert form["shown3"] == 3 and form["keys3"][:3] == kept
def test_다단_벽_고르기는_흙막이_컨테이너로() -> None:
dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert 'fieldset[data-spec-container="soil_add"]' in dock
assert "panelRoot.dataset.extraIndex" in dock
panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text(
encoding="utf-8"
)
assert "root.dataset.extraIndex" in panel
@@ -0,0 +1,107 @@
/* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너
* 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18)
* 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)"
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이)
* wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look
* set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / -
* 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안)
*/
(async () => {
const ARGS = window.__m5410 || { mode: "look" };
const PIPE = ARGS.pipe || "배관 2";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const visible = (n) => n.offsetParent !== null && !n.closest("[hidden]");
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
const soils = () => [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])];
const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const station = () =>
items()
.find((l) => l.textContent.trim().endsWith(PIPE))
?.textContent.trim()
.split(PIPE)[0];
const card = () =>
[...document.querySelectorAll(".b06-cross-card")].find((c) =>
c.textContent.startsWith(station()),
);
const pickPipe = async () => {
await until(() => items().length > 0);
const chosen = document.querySelector(".b05-route__irregular-list li.is-selected");
if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return;
const li = items().find((l) => l.textContent.trim().endsWith(PIPE));
(li.querySelector("button") ?? li).click();
await until(() => spec(), 5000);
await w(800);
};
const tierRow = () =>
spec()
?.querySelector('[data-spec-container="outlet"]')
?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)");
const extraShapes = () =>
[...(card()?.querySelectorAll(".b06-chart__culvert-revet") ?? [])]
.map((s) => s.querySelector("title")?.textContent ?? "")
.filter((t) => t.startsWith("추가"))
.map((t) => /^(.*? H=[\d.]+m)/.exec(t)?.[1] ?? t.slice(0, 40));
const look = () => {
const boxes = soils();
const rect = (b) => b.getBoundingClientRect();
return {
station: station(),
soilCount: boxes.length,
active: boxes
.map((b, i) => (b.classList.contains("is-active") ? i : -1))
.filter((i) => i >= 0),
activeInView: boxes
.filter((b) => b.classList.contains("is-active"))
.map((b) => rect(b).top >= 0 && rect(b).bottom <= innerHeight),
values: boxes.map((b) =>
Object.fromEntries(
[...b.querySelectorAll("[data-spec-item]")].map((n) => [
n.dataset.specItem.split(".")[1],
(n.querySelector("select, input, output") ?? {}).value ?? "",
]),
),
),
extraGroupShown: [...document.querySelectorAll("legend")].filter(
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
).length,
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
walls: extraShapes(),
};
};
await pickPipe();
const out = { mode: ARGS.mode, pipe: PIPE };
if (ARGS.mode === "wall") {
const hits = [...(card()?.querySelectorAll(".b06-chart__culvert-revet-hit") ?? [])].filter(
(h) => h.querySelector("title")?.textContent.startsWith("추가"),
);
out.hits = hits.length;
hits[ARGS.n]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(1500);
}
if (ARGS.mode === "set") {
const field = soils()[ARGS.n]?.querySelector(`[data-spec-item="soil_add.${ARGS.item}"]`);
const el = field?.querySelector("select, input");
out.found = !!el;
if (el) {
el.value = ARGS.value;
el.dispatchEvent(new Event("change", { bubbles: true }));
}
await w(1500);
}
if (ARGS.mode === "tier+" || ARGS.mode === "tier-") {
const btns = [...(tierRow()?.querySelectorAll("button") ?? [])];
btns.find((b) => b.textContent === (ARGS.mode === "tier+" ? "+" : "-"))?.click();
await w(1500);
}
Object.assign(out, look());
return JSON.stringify(out);
})();