Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
This commit is contained in:
@@ -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));
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user