Merge remote-tracking branch 'origin/dev' into main_desktop_1

This commit is contained in:
2026-10-06 16:59:35 +09:00
128 changed files with 16780 additions and 1474 deletions
+135 -85
View File
@@ -1,5 +1,5 @@
{
"판": 14,
"판": 17,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -75,8 +75,9 @@
"기슭막이",
"집수정"
],
"default": "기슭막이",
"required": false
"default": "집수정",
"required": false,
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
},
{
"key": "wing_wall_type",
@@ -147,6 +148,16 @@
"required": false,
"phase": "detail"
},
{
"key": "inlet_basin_height_m",
"label": "집수정 높이",
"input": "number",
"unit": "m",
"default": 1.5,
"required": false,
"phase": "detail",
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
},
{
"key": "inlet_revet_form",
"label": "유입 기슭막이 형태",
@@ -194,10 +205,11 @@
"label": "유입 기슭막이 높이",
"input": "number",
"unit": "m",
"default": null,
"default": 1.5,
"required": false,
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)",
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
},
{
"key": "outlet_type",
@@ -221,9 +233,10 @@
"통나무·목재틀",
"바자"
],
"default": "돌쌓기(메)",
"default": "돌쌓기(찰)",
"required": false,
"phase": "detail"
"phase": "detail",
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
},
{
"key": "outlet_revet_length_m",
@@ -256,10 +269,11 @@
"label": "유출 기슭막이 높이",
"input": "number",
"unit": "m",
"default": null,
"default": 1.5,
"required": false,
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)",
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
},
{
"key": "revet_foundation",
@@ -975,15 +989,15 @@
],
"options": [
{
"key": "height_m",
"label": "높이",
"input": "number",
"unit": "m",
"default": 2,
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false,
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
"key": "wall_form",
"label": "형태",
"input": "select",
"choices": [
"콘크리트"
],
"default": "콘크리트",
"default_basis": "표 09 — 선택지 하나(콘크리트)",
"required": false
},
{
"key": "length_m",
@@ -994,6 +1008,17 @@
"required": false,
"phase": "b05"
},
{
"key": "height_m",
"label": "높이",
"input": "number",
"unit": "m",
"default": 2,
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false,
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "before_m",
"label": "기준측점 전",
@@ -1525,13 +1550,21 @@
],
"options": [
{
"key": "height_m",
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"key": "form",
"label": "형태",
"input": "select",
"choices": [
"돌쌓기(찰)",
"돌쌓기(메)",
"콘크리트",
"돌망태",
"통나무·목재틀",
"바자"
],
"default": "돌쌓기(찰)",
"required": false,
"phase": "b05"
"empty_means": "비우면 통나무·목재틀로 그림(횡단 기본 형태) — B05 로 옮긴 칸이라 필수로 안 막음(옛 흙막이 저장 보호)",
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
},
{
"key": "length_m",
@@ -1542,6 +1575,16 @@
"required": false,
"phase": "b05"
},
{
"key": "height_m",
"label": "높이",
"input": "number",
"unit": "m",
"default": 1.5,
"required": false,
"phase": "b05",
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
},
{
"key": "before_m",
"label": "기준측점 전",
@@ -1560,24 +1603,6 @@
"required": false,
"phase": "b05"
},
{
"key": "form",
"label": "형식",
"input": "select",
"choices": [
"콘크리트",
"돌(찰)",
"돌(메)",
"블록쌓기",
"돌망태",
"흙포대",
"통나무쌓기",
"떼"
],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
@@ -1969,6 +1994,24 @@
"quantity"
],
"options": [
{
"key": "width_m",
"label": "소단 폭",
"input": "number",
"unit": "m",
"default": 0.5,
"required": false,
"phase": "b05"
},
{
"key": "height_m",
"label": "소단 높이",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비워도 놓임 — 아직 계산(소단 간격 · 횡단)에 안 쓰임(표 09 새 칸)"
},
{
"key": "length_m",
"label": "길이",
@@ -1996,15 +2039,6 @@
"required": false,
"phase": "b05"
},
{
"key": "width_m",
"label": "폭",
"input": "number",
"unit": "m",
"default": 0.5,
"required": false,
"phase": "b05"
},
{
"key": "interval_m",
"label": "간격(사면길이)",
@@ -3313,8 +3347,6 @@
"outlet.before": "기준측점 전 (m)",
"outlet.after": "기준측점 후 (m)",
"soil_add": "흙막이 추가",
"soil_add.structure": "구조",
"soil_add.structure=기슭막이": "흙막이",
"soil_add.form": "형태",
"soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)",
"soil_add.form=돌쌓기(메)": "돌 쌓기(메)",
@@ -3396,11 +3428,51 @@
"동작": [
"포장_물넘이_겹침"
]
},
"soil_guard": {
"이름": {
"form=돌쌓기(찰)": "돌 쌓기(찰)",
"form=돌쌓기(메)": "돌 쌓기(메)",
"form=콘크리트": "콘크리트",
"form=돌망태": "돌망태",
"form=통나무·목재틀": "통나무/목재틀",
"form=바자": "바자"
}
}
},
"표시": {
"pipe": {
"pipe_kind": false
},
"soil_guard": {
"side": false,
"tiers": false,
"lift_m": false,
"shift_m": false,
"thickness_top_m": false,
"thickness_bottom_m": false,
"back_len_cm": false,
"stone_kind": false,
"stone_supply": false,
"stone_coeff_basis": false,
"fill_concrete_mpa": false,
"face_slope_ratio": false,
"foundation": false,
"weep_hole_diameter_mm": false,
"weep_hole_area_m2": false
},
"retaining_wall": {
"form": false,
"side": false,
"weep_hole_diameter_mm": false,
"weep_hole_area_m2": false
},
"berm": {
"length_m": false,
"before_m": false,
"after_m": false,
"interval_m": false,
"slope_deg": false
}
},
"그룹": {
@@ -3521,16 +3593,13 @@
"retaining_wall": [
{
"id": "gretaindetail",
"이름": "상세 내용",
"이름": "옹벽",
"옵션": [
"height_m",
"wall_form",
"length_m",
"height_m",
"before_m",
"after_m",
"form",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2"
"after_m"
],
"형상": {}
}
@@ -3592,28 +3661,13 @@
"soil_guard": [
{
"id": "gsoilgdetail",
"이름": "상세 내용",
"이름": "흙막이",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"form",
"side",
"tiers",
"lift_m",
"shift_m",
"thickness_top_m",
"thickness_bottom_m",
"back_len_cm",
"stone_kind",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"foundation",
"weep_hole_diameter_mm",
"weep_hole_area_m2"
"length_m",
"height_m",
"before_m",
"after_m"
],
"형상": {}
}
@@ -3647,12 +3701,8 @@
"id": "gbermdetail",
"이름": "상세 내용",
"옵션": [
"length_m",
"before_m",
"after_m",
"width_m",
"interval_m",
"slope_deg"
"height_m"
],
"형상": {}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
/* 배관 유출 「추가 흙막이(단)」 칸(PLAN 54-27) 단위검증 헬퍼 — 실제 시설 폼(`createFacilityOptionsForm`)을
* 그 자리에서 트랜스파일해 Node 로 돌린다. 단 수 읽기 · 쓰기(`design_structure_pipe_calc`)만 흉내 —
* 쓰면 그 수가 곧 지형이 세운 단 수(한도 2). 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_27_soil_tier_field.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.parentElement = null;
this.hidden = false;
this.disabled = false;
this._classes = new Set();
this._listeners = {};
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("");
}
get childElementCount() {
return this.children.length;
}
append(...kids) {
kids.forEach((k) => {
const node = typeof k === "string" ? { textContent: k } : k;
if (node.parentElement)
node.parentElement.children.splice(node.parentElement.children.indexOf(node), 1);
node.parentElement = this;
this.children.push(node);
});
}
replaceChildren(...kids) {
this.children.forEach((k) => (k.parentElement = null));
this.children = [];
this.append(...kids);
}
add(kid) {
this.append(kid);
}
setAttribute(n, v) {
this[n] = v;
}
addEventListener(type, fn) {
(this._listeners[type] ??= []).push(fn);
}
dispatchEvent(event) {
(this._listeners[event.type] ?? []).forEach((fn) => fn(event));
}
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),
createComment: () => new FakeNode("#comment"),
};
globalThis.Event = function (type) {
this.type = type;
};
globalThis.queueMicrotask = (fn) => fn();
globalThis.Option = function (text, value) {
const o = new FakeNode("option");
o.textContent = text;
o.value = value;
return o;
};
// 단 수 자리 흉내 — 쓰면 지형 한도(2)까지만 섬
const LIMIT = 2;
let stored = 0;
const writes = [];
const PIPE_CALC = {
guardHeightHint: () => "",
pipeDrawn: () => null,
pipeLengthM: () => null,
pipeExtraCount: () => Math.min(stored, LIMIT),
setPipeExtraCount: (chainage, count) => {
writes.push([chainage, count]);
stored = count;
return Promise.resolve();
},
};
const STUBS = {
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
"../A00_Common/design/design_structure_pipe_calc": PIPE_CALC,
// 관 보호공 [연동] · [경사] 줄(54-30) — 여기서는 자리만
"./B05_Profile_UI_Drainage_Revet_Choice": {
revetChoiceRow: () => ({ paint: () => new FakeNode("div") }),
},
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": {
buildSummaryRow: () => {
const root = new FakeNode("div");
const label = new FakeNode("span");
label.className = "b05-structure__summary-label";
root.append(label);
return { root, set: () => {}, bind: () => {} };
},
},
};
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 { createFacilityOptionsForm } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Facility.ts"),
);
const form = createFacilityOptionsForm();
form.setChainage(100);
form.setFacility("pipe", { pipe_diameter_mm: 1000 });
const spec = form.root.querySelector(".b05-structure__spec");
const boxesOf = (id) => spec.children.filter((k) => k.dataset?.specContainer === id);
const tierRow = () =>
boxesOf("outlet")[0]
?.querySelectorAll("label")
.find((l) => l.dataset.specItem === "outlet.tiers");
const tierInput = () => tierRow()?.querySelector("input");
const snap = () => ({
value: tierInput()?.value ?? null,
boxes: boxesOf("soil_add").length,
// 상자 첫 칸 이름 — 구조 고르기 없이 형태부터(54-30 · 사면안정 흙막이와 같은 칸)
structures: boxesOf("soil_add").map(
(b) => b.querySelectorAll("label")[0]?.children[0]?.textContent,
),
});
async function main() {
const out = { label: tierRow()?.children[0]?.textContent ?? null, start: snap() };
for (const n of ["2", "1", "5", "0"]) {
const input = tierInput();
input.value = n;
input.dispatchEvent(new Event("change"));
await Promise.resolve();
await Promise.resolve();
out[`set${n}`] = snap();
}
// 횡단 조정에서 바꿈 → 좌측 칸 · 상자 따라감(B06 이 recount 를 부름)
stored = 2;
form.recount();
out.fromCross = snap();
out.writes = writes;
// 다른 구조(옛 값)면 칸을 가림
form.setFacility("pipe", { pipe_diameter_mm: 1000, outlet_type: "옛값" });
out.otherHidden = tierRow()?.hidden ?? null;
process.stdout.write(JSON.stringify(out));
}
main().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
@@ -34,6 +34,7 @@ function loadTs(file) {
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".json")) return require(path.join(path.dirname(full), request));
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} });
return require(request);
@@ -199,7 +199,6 @@ const built = panel.createDesignPanel(
settings: { nodes: [text("a"), text("b")] },
route: {
plannedRouteButton: text("plan"),
calcNodes: [text("calc")],
selectedNodes: [text("sel")],
bindSelectedRoot: (root) => {
root.hidden = true;
@@ -28,7 +28,7 @@ def test_하나만_컨테이너와_여러개_컨테이너():
out = _run()
assert out["max"] == {
"pipe.soil_add": None,
"pipe.chute_add": None,
"pipe.chute_add": 1, # 54-48 사용자 10-6: 배관 도수로/돌붙임도 하나만
"ford_pavement.chute": 1,
"etc.detail": 1,
}
@@ -66,9 +66,10 @@ def test_옛_키로_읽고_같은_키로_씀_옛_칸은_안_씀(out):
def test_선택_컨테이너는_인스턴스마다_차례대로_다시_매김(out):
assert [i["structure"] for i in out["state"]["soil_add"]] == ["기슭막이", ""]
# 흙막이 상자 = 구조 고르기 없음(54-30) — 옛 구조 키만 든 상자도 자리는 지킴
assert [i["form"] for i in out["state"]["soil_add"]] == ["바자", ""]
assert out["back"]["soil_add_1_form"] == "바자"
assert "soil_add_2_form" not in out["back"]
assert out["back"]["soil_add_2_form"] == "" # 빈 상자 자리 표식 = 첫 칸(형태) 빈 글
assert out["back"]["chute_add_1_structure"] == "도수로"
+20 -39
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""54-14 종단 구조물 알약 정보 라벨 — 중요한 것만.
• 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값 한 줄(컨테이너가 없으면 앞 두 줄만)
• 2행 — 1행 = 이름 + 기준 측점 · 2행 = 컨테이너마다 첫 옵션 값만 「 · 」 로 이음(54-46 · 제목 없음)
• 컨테이너 = 좌측 패널 폼과 같은 상자(명세 컨테이너 · 선택 컨테이너는 인스턴스마다 · 정의 옵션 그룹)
• 빈 값 = 등록부 기본값 「(기본)」 · 값 auto = 「(자동)」 · 그것도 없으면 「—」 · 메모 · 그 밖 옵션은 안 씀
• 빈 값은 건너뜀 · 값 auto = 「(자동)」 · 값이 하나도 없으면 1행만 · 메모 · 그 밖 옵션은 안 씀
• 라벨은 ▲▼ 단추층 위 · 단추 높이만큼 위로 비킴 · B05 · B06 은 같은 레인 — 실제 화면은 ORCA 로 따로 본다.
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_14_balloon_lines.cjs`).
"""
@@ -33,56 +33,37 @@ def out():
return json.loads(proc.stdout)
def test_배관_컨테이너마다_첫_값_한_줄(out):
assert out["pipeFull"] == [
"이름",
"5+0",
"관: 관경 1200",
"관 보호공(유입): 구조 집수정",
"관 보호공(유출): 구조 흙막이",
"흙막이 1: 구조 흙막이",
"도수로/돌붙임 1: 구조 도수로",
]
def test_배관_컨테이너마다_첫_값만_한_줄로_이음(out):
# 흙막이 1 은 구조 칸 없음 — 형태(54-30)
assert out["pipeFull"] == ["이름 5+0", "1200 · 집수정 · 흙막이 · 돌망태 · 도수로"]
def test_빈_값은_등록부_기본값에_기본_표시(out):
assert out["pipeBlank"] == [
"이름",
"5+0",
"관: 관경 1000 (기본)",
"관 보호공(유입): 구조 흙막이 (기본)",
"관 보호공(유출): 구조 —",
]
def test_빈_값은_건너뜀(out):
assert out["pipeBlank"] == ["이름 5+0"]
def test_흙막이_상자_수는_밖에서_정한_단_수(out):
assert out["pipeCount"][-2:] == ["흙막이 1: 구조 흙막이", "흙막이 2: 구조 —"]
assert len(out["pipeCount"]) == 2 + 3 + 2
# 저장 1 벌(형태 빈 값) · 모자란 둘째도 빈 값 — 둘 다 건너뜀
assert out["pipeCount"] == ["이름 5+0"]
def test_박스_본체_규격은_폭_높이로_되찾음(out):
assert out["boxPreset"] == [
"이름",
"5+0",
"본체: 본체 규격 3.0 × 3.0",
"유입 구조: 구조 날개벽",
"유출 구조: 구조 —",
]
assert out["boxBlank"][2] == "본체: 본체 규격 2.0 × 2.0 (기본)"
assert out["boxPreset"] == ["이름 5+0", "3.0 × 3.0 · 날개벽"]
assert out["boxBlank"] == ["이름 5+0"]
def test_정의_옵션_그룹은_그룹마다_첫_켜진_옵션(out):
assert out["underdrain"] == ["이름", "5+0", "맹암거: 높이 0.6m"]
assert out["underdrainBlank"] == ["이름", "5+0", "맹암거: 높이 —"]
# 끈 칸(높이)은 건너뜀 · 빈 값은 기본값
assert out["wall"] == ["이름", "5+0", "상세 내용: 길이 10m (기본)"]
assert out["wallAuto"] == ["이름", "5+0", "상세 내용: 길이 (자동)"]
assert out["underdrain"] == ["이름 5+0", "0.6m"]
assert out["underdrainBlank"] == ["이름 5+0"]
# 끈 칸(높이)은 건너뜀
assert out["wall"] == ["이름 5+0"]
assert out["wallAuto"] == ["이름 5+0", "(자동)"]
def test_컨테이너가_없으면_이름과_기준_측점만(out):
assert out["mirror"] == ["이름", "5+0"]
assert out["etcEmpty"] == ["이름", "5+0"]
assert out["etcNote"] == ["이름", "5+0", "상세 내용 1: 비고 시험 비고"]
def test_컨테이너가_없으면_1행만(out):
assert out["mirror"] == ["이름 5+0"]
assert out["etcEmpty"] == ["이름 5+0"]
assert out["etcNote"] == ["이름 5+0", "시험 비고"]
def test_라벨은_단추층_위로_비켜_뜬다():
+109
View File
@@ -0,0 +1,109 @@
"""54-22 보안 — H1 구조물 id 경로 넘기 · H2 지면 필터 · 표현 이름 경로 넘기.
못박는 것
- H1: 구조물 저장 `structure_id` 는 안전 이름만(어기면 검사 실패 = 422) · 복사본 맞춤은
규칙 밖 · `base` 밖 폴더를 지우지도 쓰지도 않음.
- H2: `filter_key` · `method` 는 허용 목록만(스키마) · 파일 이름 만드는 곳에서도 한 번 더.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from B05_Profile.B05_Profile_Engine_Skeleton import load_or_build_skeleton
from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _source_npz_paths
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteSolveRequest
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from common_util.common_util_surface_sampler import build_surface_sampler, check_surface_keys
from M02_MasterTemplete import M02_Item_Snapshot as snap
BAD_IDS = ["../../..", "..", "a/b", "a\\b", "C:/Windows", "/etc", ".hidden", "", "x" * 65]
BAD_KEYS = ["../other/dtm_csf", "csf/../../x", "C:/x", "", "CSF"]
def _structure(structure_id):
return StructureInstance(
structure_id=structure_id, type_id="x", placement="point", chainage_m=1
)
@pytest.mark.parametrize("bad", BAD_IDS)
def test_H1_악성_id_거절(bad):
with pytest.raises(ValidationError):
_structure(bad)
@pytest.mark.parametrize("good", [None, "0c3d99cc5d8f49139c871d662537da42", "abc_1-2", "s1__g1"])
def test_H1_정상_id_통과(good):
assert _structure(good).structure_id == good
def test_H1_safe_folder(tmp_path):
base = tmp_path / "templates" / "structures"
base.mkdir(parents=True)
assert snap.safe_folder(base, "abc123") == base / "abc123"
assert snap.safe_folder(base, "pipe@40.0__gin") == base / "pipe@40.0__gin"
for bad in BAD_IDS[:-1] + [str(tmp_path)]: # 길이는 스키마 몫
assert snap.safe_folder(base, bad) is None
def test_H1_맞춤이_base_밖을_안_건드림(tmp_path, monkeypatch):
root = tmp_path / "proj"
base = snap.structures_dir(root)
base.mkdir(parents=True)
victim = root / "victim" # base / "../../victim"
victim.mkdir()
(victim / "keep.txt").write_text("살아야 함", encoding="utf-8")
item = {"layer": "master", "owner": "", "key": "k"}
monkeypatch.setattr(
snap, "assignments", lambda _root: {"../../victim": item, str(victim): item, "ok1": item}
)
def fake_write(target: Path, *_args):
target.mkdir(parents=True, exist_ok=True)
return {}
monkeypatch.setattr(snap, "write_snapshot", fake_write)
result = snap.sync_snapshots(root)
assert (victim / "keep.txt").is_file()
assert set(result["못만듦"]) == {"../../victim", str(victim)}
assert result["만듦"] == ["ok1"]
assert result["지움"] == []
@pytest.mark.parametrize("bad", BAD_KEYS)
def test_H2_스키마_허용_목록(bad):
body = {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}}
with pytest.raises(ValidationError):
RouteSolveRequest(filter_key=bad, **body)
with pytest.raises(ValidationError):
RouteSolveRequest(filter_key="csf", method=bad, **body)
with pytest.raises(ValidationError):
RouteConfirmRequest(filter_key=bad)
with pytest.raises(ValidationError):
RouteConfirmRequest(method=bad)
def test_H2_스키마_정상값_통과():
assert RouteConfirmRequest(filter_key="grid_min_z", method="tin").method == "tin"
assert RouteConfirmRequest().filter_key is None
@pytest.mark.parametrize("bad", BAD_KEYS)
def test_H2_파일_이름_만드는_곳(bad, tmp_path):
with pytest.raises(ValueError):
check_surface_keys(bad)
with pytest.raises(ValueError):
check_surface_keys("csf", bad)
with pytest.raises(ValueError):
build_surface_sampler(tmp_path, bad, "dtm", False)
with pytest.raises(ValueError):
_load_dtm_grid(tmp_path, bad, False)
with pytest.raises(ValueError):
_source_npz_paths(tmp_path, "csf", bad, False)
with pytest.raises(ValueError):
load_or_build_skeleton(tmp_path, bad, "dtm", False)
check_surface_keys("classification", "meshfree")
@@ -0,0 +1,87 @@
"""PLAN 54-26 — 사용자 구조물 표 09 사면안정 › 흙막이 · 옹벽 · 소단.
· 컨테이너 = 흙막이 「흙막이」 · 옹벽 「옹벽」 · 소단 「상세 내용」 — 칸은 표대로만 · 표 밖 옛 칸은 숨김(값 · 기본값 그대로)
· 흙막이 형태 = 배관 흙막이 6 · 옹벽 형태 = 콘크리트 하나(새 칸 `wall_form` · 옛 형식 `form` 값은 그대로 통과)
"""
import json
from pathlib import Path
from B05_Profile.B05_Profile_Structures_Repository import _validate_types
from B05_Profile.B05_Profile_Structures_Schema import (
DEFINITION_PATH,
StructureInstance,
load_structure_types,
)
from M02_MasterTemplete.StructOptions import M02_SO_Definition
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys
TABLE = {
"soil_guard": (
"gsoilgdetail",
"흙막이",
["form", "length_m", "height_m", "before_m", "after_m"],
),
"retaining_wall": (
"gretaindetail",
"옹벽",
["wall_form", "length_m", "height_m", "before_m", "after_m"],
),
"berm": ("gbermdetail", "상세 내용", ["width_m", "height_m"]),
}
SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]
def _types():
return {t.type_id: t for t in load_structure_types()}
def test_컨테이너_하나_칸은_표대로_차례대로():
groups = groups_of()
types = _types()
hidden = hidden_keys()
for type_id, (gid, name, keys) in TABLE.items():
assert groups[type_id] == [{"id": gid, "name": name, "options": keys}]
options = [o.key for o in types[type_id].options]
assert options[: len(keys)] == keys # 칸 차례 = 표 차례
assert hidden[type_id] == set(options) - set(keys) # 표 밖 = 숨김(값은 남음)
def test_형태_선택지():
types = _types()
soil = {o.key: o for o in types["soil_guard"].options}
pipe = {o.key: o for o in types["pipe"].options}
assert soil["form"].choices == SOIL_FORMS == pipe["outlet_revet_form"].choices
assert soil["form"].phase == "b05" # B05 = B06
wall = {o.key: o for o in types["retaining_wall"].options}
assert wall["wall_form"].choices == ["콘크리트"]
assert "반중력식" in wall["form"].choices # 옛 형식 값 그대로 통과
doc = json.loads(Path(DEFINITION_PATH).read_text("utf-8"))
assert doc["종류"]["soil_guard"]["이름"]["form=통나무·목재틀"] == "통나무/목재틀"
def test_옛_값_저장_막지_않음():
def item(type_id, **options):
return StructureInstance(
type_id=type_id,
placement="interval",
chainage_m=20,
start_m=20,
end_m=30,
options={"length_m": 10, "before_m": 0, "after_m": 10, **options},
)
_validate_types(
[
item("soil_guard", height_m=2), # 형태 없는 옛 흙막이
item("retaining_wall", form="반중력식", height_m=2),
item("berm", width_m=0.5, interval_m=3, height_m=5),
]
)
def test_정의_검사_통과_판_이력():
doc = json.loads(Path(DEFINITION_PATH).read_text("utf-8"))
assert M02_SO_Definition.check(doc) == []
assert M02_SO_Definition.history_path(doc["판"] - 1).is_file()
@@ -0,0 +1,80 @@
"""배관 유출 「추가 흙막이(단)」 칸 (PLAN 54-27).
짝: `B05_Profile/B05_Profile_UI_Drainage_Facility.ts`(유출 상자 안 단 칸 · 흙막이 상자 구조 「흙막이 (기본)」) ·
`A00_Common/design/design_structure_pipe_calc.ts`(`setPipeExtraCount` = 횡단 조정 단 수와 같은 자리).
실제 TS 를 `helper_54_27_soil_tier_field.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_54_27_soil_tier_field.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_유출_상자_안에_단_칸이_있음(out) -> None:
assert out["label"] == "추가 흙막이(단)"
assert out["start"] == {"value": "0", "boxes": 0, "structures": []}
def test_단_수_N_이면_흙막이_상자_N(out) -> None:
assert (out["set2"]["value"], out["set2"]["boxes"]) == ("2", 2)
assert (out["set1"]["value"], out["set1"]["boxes"]) == ("1", 1)
assert (out["set0"]["value"], out["set0"]["boxes"]) == ("0", 0)
def test_지형_한도를_넘으면_세운_만큼(out) -> None:
assert (out["set5"]["value"], out["set5"]["boxes"]) == ("2", 2)
def test_좌측_칸은_횡단_단_수_자리에_씀(out) -> None:
assert out["writes"] == [[100, 2], [100, 1], [100, 5], [100, 0]]
def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None:
assert (out["fromCross"]["value"], out["fromCross"]["boxes"]) == ("2", 2)
def test_늘어난_흙막이_상자는_구조_칸_없이_형태부터(out) -> None:
"""54-30 — 사면안정 흙막이와 같은 칸(형태 · 길이 · 높이 · 전 · 후) · 구조 고르기 없음."""
assert out["set2"]["structures"] == ["형태", "형태"]
def test_흙막이_아닌_구조면_칸을_가림(out) -> None:
assert out["otherHidden"] is True
def _src(rel: str) -> str:
with open(os.path.join(HERE, "..", "..", rel), encoding="utf-8") as f:
return f.read()
def test_자동_자리_읽기_글이_없음() -> None:
"""54-28 — 관 길이 · 좌우 바깥 · 상하(사면) 자동 자리 글을 만드는 뿌리(문구 · 줄)가 없음."""
locale = _src("ui_template/ui_template_locale_b2.ts")
for key in ("B06_Cross_Revet_Auto", "B06_Cross_Revet_Pipe", "B06_Cross_Lateral_Label"):
assert key not in locale
assert "lateralLine" not in _src("B06_Section/B06_Section_UI_Cross_Structure_Panel.ts")
def test_배관_유출_칸은_옛_단_수_줄을_안_받음() -> None:
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
# 54-29 — 배관 유출 칸 = 집수정 내공 줄만(단 수 · 읽기 · 옵션 줄 안 받음)
assert 'const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";' in dock
@@ -0,0 +1,70 @@
"""횡단에서 고르기 · 목록에서 고르기 좌측 패널 같은 글 (PLAN 54-29).
짝: `B06_Section/B06_Section_UI_Adjust_Dock.ts`(배관 칸 = 이름표 그대로 · 옵션 · 읽기 · 유출 단 수 줄 안 받음) ·
`B06_Section/B06_Section_UI_Cross_Structure_Panel.ts`(배관 유입 벽 단 수 줄 = 선 단이 있을 때만) ·
`B06_Section/B06_Section_UI_Page_Structures_Panel.ts`(폼을 다시 그린 뒤 강조 다시).
화면 확인은 ORCA(목록 · 횡단 innerText · 칸 수 · 값 같음 · 강조 상자 하나만 다름).
"""
from __future__ import annotations
import os
import re
HERE = os.path.dirname(__file__)
def _src(rel: str) -> str:
with open(os.path.join(HERE, "..", "..", rel), encoding="utf-8") as f:
return f.read()
def test_배관_칸은_옵션_읽기_줄을_안_받음() -> None:
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
inlet = re.search(r"const PIPE_INLET_ROWS = \[(.*?)\]", dock, re.S)
assert inlet
assert "struct--options" not in inlet.group(1)
assert "struct--readout" not in inlet.group(1)
assert 'const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";' in dock
def test_배관_칸_이름표는_그대로() -> None:
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
assert 'setGroupLabel(group, pipe ? "" : (panelRoot.dataset.panelTitle ?? ""));' in dock
def test_배관_유입_단_수_줄은_선_단이_있을_때만() -> None:
panel = _src("B06_Section/B06_Section_UI_Cross_Structure_Panel.ts")
assert "!isBasin && !deps.hiddenPipe()" in panel
assert "(pipeInlet && tiers.count === 0)" in panel
def test_폼을_다시_그리면_강조_다시() -> None:
"""54-30 — 상자를 새로 세울 때마다(단 수 · 유입 형식 · 벽 조작 · 저장 · 넘김) 한 곳에서 강조를 다시 얹음."""
form = _src("A00_Common/design/design_structure_form.ts")
assert 'root.dispatchEvent?.(new Event("specpaint", { bubbles: true }));' in form
page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts")
assert 'addEventListener("specpaint", () => refreshAdjustSlots(false))' in page
def test_상자_칸을_고치면_그_부분을_고름() -> None:
"""54-30 — 칸 고침 = 횡단에서 그 벽을 누른 것과 같은 길 · 단 수 칸은 고른 단을 지킴."""
page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts")
assert '[data-spec-item="outlet.tiers"]' in page
assert "`extra${Number(box.dataset.specIndex)}`" in page
assert "refreshCard?.(target.chainage_m);" in page
def test_흙막이_상자는_사면안정_흙막이와_같은_칸() -> None:
pipe = _src("A00_Common/design/design_structure_pipe.ts")
soil = pipe[pipe.index('id: "soil_add"') :]
assert soil[: soil.index("},")].count("items: guardItems(),") == 1
def test_관_보호공_연동_경사_단추() -> None:
row = _src("B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts")
assert 'toggleButton("연동"' in row and 'toggleButton("경사"' in row
assert "b05-structure__half-pair" in row
session = _src("B06_Section/B06_Section_UI_Page_Link_Session.ts")
assert "revetLink.setFollowGrade(owner, on)" in session
assert "revetLink.setLinked(s.chainage_m, on)" in session
@@ -20,6 +20,7 @@ KINDS = {
"boulder_masonry": "gboulderdetail",
"berm": "gbermdetail",
}
TABLE_09 = {"soil_guard", "retaining_wall", "berm"}
def test_C군_종류마다_컨테이너_하나_옵션_전부():
@@ -27,6 +28,8 @@ def test_C군_종류마다_컨테이너_하나_옵션_전부():
assert sorted(t.type_id for t in types) == sorted(KINDS)
groups = groups_of()
for item in types:
if item.type_id in TABLE_09: # 표 09 로 바뀜 — test_54_26_slope_table09
continue
assert groups[item.type_id] == [
{
"id": KINDS[item.type_id],
@@ -55,4 +58,4 @@ def test_잇기는_컨테이너마다_집계_한_줄():
]
)
assert [r["복사본"] for r in rows] == ["s1", "s1__gretaindetail"]
assert rows[1]["summary_item"] == link and rows[1]["그룹"] == "상세 내용"
assert rows[1]["summary_item"] == link and rows[1]["그룹"] == "옹벽" # 표 09 컨테이너 이름
+15 -23
View File
@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
"""54-9 종단 알약 글 — 기타가 여럿이어도 알약끼리 구별됨.
"""54-9 → 54-49 종단 알약 글 = 좌측 「종류」 칸 · 목록과 같은 이름.
• 손 이름 앞 3글자 · 같은 이름이면 목록과 같은 번호 · 빈 이름 = 「기타 N」
• 머리가 같은 다른 이름은 갈릴 때까지 늘림
• 다른 종류 알약(약호 + 중복 번호) · 툴팁 · 목록 이름(손 이름 그대로)은 그대로
• 종류 이름(정의 이름) · 기타는 손 이름 · 같은 이름 여럿이면 목록과 같은 번호 · 빈 이름 = 「기타 N」
• 약호 · 머리 자르기(옛 54-9 규칙) 안 씀 — 알약 = 툴팁 이름
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_9_etc_pill.cjs`).
B05 · B06 은 같은 알약 레인(`buildStructureLane`)을 씀 — 실제 화면은 ORCA 로 따로 본다.
"""
@@ -31,38 +30,31 @@ def out():
return json.loads(proc.stdout)
def test_53_2_기타_다섯_알약이_서로_다르다(out):
def test_53_2_기타_다섯_알약이_손_이름과_번호(out):
pills = {key: value["pill"] for key, value in out["scenario"].items()}
assert pills == {
"e1": "배수로 1",
"e1": "배수로정비 1",
"e2": "울타리",
"e3": "기타 1",
"e4": "배수로 2",
"e4": "배수로정비 2",
"e5": "기타 2",
}
assert len(set(pills.values())) == 5
def test_툴팁_이름은_손_이름_그대로(out):
names = [value["name"] for value in out["scenario"].values()]
assert names == ["배수로정비 1", "울타리", "기타 1", "배수로정비 2", "기타 2"]
def test_알약은_툴팁_이름과_같다(out):
for group in out.values():
for value in group.values():
assert value["pill"] == value["name"]
def test_머리가_같은_다른_이름은_갈릴_때까지_늘린다(out):
pills = {key: value["pill"] for key, value in out["prefix"].items()}
assert pills == {
"p1": "콘크리트블",
"p2": "콘크리트옹",
"p3": "배수로",
"p4": "배수로공",
"p5": "기타시",
"p6": "기타",
}
def test_머리가_같은_이름도_자르지_않는다(out):
pills = [value["pill"] for value in out["prefix"].values()]
assert pills == ["콘크리트 블록", "콘크리트 옹벽", "배수로", "배수로공", "기타 시설", "기타"]
def test_다른_종류_알약은_약호와_번호_그대로(out):
def test_다른_종류_알약은_종류_이름과_번호(out):
pills = {key: value["pill"] for key, value in out["others"].items()}
assert pills == {"w1": "옹 1", "w2": "옹 2", "x1": "가로등", "x2": "기타"}
assert pills == {"w1": "옹벽 1", "w2": "옹벽 2", "x1": "가로등", "x2": "기타"}
def test_알약_레인이_글을_한_곳에서_받는다():
+147
View File
@@ -0,0 +1,147 @@
import asyncio
import httpx
import pytest
from fastapi import HTTPException
from B01_Dashboard import B01_Dashboard_Knowledge_Chat as chat
def test_greeting_answers_without_search_or_api_key(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.setattr(
chat, "search", lambda _question: (_ for _ in ()).throw(AssertionError("searched"))
)
result = asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="안녕!"), {}))
assert result == {
"answer": "안녕하세요! 법령이나 지침에 대해 궁금한 점을 물어보세요.",
"sources": [],
}
def test_natural_question_finds_term_without_particle(monkeypatch):
monkeypatch.setattr(
chat,
"_sections",
lambda: [("original/원가계산/품셈.md", "# 일위대가\n공사 한 단위에 필요한 비용을 계산")],
)
assert chat.search("일위대가가 뭐야")[0]["path"] == "original/원가계산/품셈.md"
def test_missing_api_key_is_reported_before_search(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.setattr(chat, "search", lambda _question: [])
with pytest.raises(HTTPException) as error:
asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="일위대가가 뭐야"), {}))
assert error.value.status_code == 503
def test_busy_gemini_is_retried_and_reported(monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
monkeypatch.setattr(chat, "search", lambda _question: [])
calls = []
class Client:
def __init__(self, timeout):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
pass
async def post(self, *_args, **_kwargs):
calls.append(1)
return type("Response", (), {"status_code": 503})()
async def no_wait(_seconds):
pass
monkeypatch.setattr(chat.httpx, "AsyncClient", Client)
monkeypatch.setattr(chat.asyncio, "sleep", no_wait)
with pytest.raises(HTTPException) as error:
asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="일위대가가 뭐야"), {}))
assert len(calls) == 3
assert "요청이 몰려" in error.value.detail
def test_gemini_timeout_is_reported(monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
monkeypatch.setattr(chat, "search", lambda _question: [])
class Client:
def __init__(self, timeout):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
pass
async def post(self, *_args, **_kwargs):
raise httpx.ReadTimeout("timed out")
monkeypatch.setattr(chat.httpx, "AsyncClient", Client)
with pytest.raises(HTTPException) as error:
asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="일위대가가 뭐야"), {}))
assert error.value.status_code == 504
assert "시간 초과" in error.value.detail
def test_chat_searches_all_knowledge_markdown_except_excluded_folders(monkeypatch, tmp_path):
public = tmp_path / "original" / "법률"
guide = tmp_path / "technical_info"
private = tmp_path / "original" / "실무문서"
stmate = tmp_path / "original" / "원가계산" / "STmate"
dasan = tmp_path / "original" / "원가계산" / "다산소프트"
public.mkdir(parents=True)
guide.mkdir()
private.mkdir()
stmate.mkdir(parents=True)
dasan.mkdir()
(public / "현행.md").write_text("# 임도 기준\n" + "임도 설치 기준 내용 " * 10, encoding="utf-8")
(guide / "_검토.md").write_text("# 임도 기준\n기술정보 안내 " * 10, encoding="utf-8")
(private / "사례.md").write_text("# 임도 기준\n비공개 사례 " * 10, encoding="utf-8")
(stmate / "자료.md").write_text("# 임도 기준\nSTmate 자료 " * 10, encoding="utf-8")
(dasan / "자료.md").write_text("# 임도 기준\n다산소프트 자료 " * 10, encoding="utf-8")
monkeypatch.setattr(chat, "KNOWLEDGE", tmp_path)
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
chat._sections.cache_clear()
sent = {}
class Response:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {"candidates": [{"content": {"parts": [{"text": "설명 [1]"}]}}]}
class Client:
def __init__(self, timeout):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
pass
async def post(self, url, *, headers, json):
sent["prompt"] = json["contents"][0]["parts"][0]["text"]
return Response()
monkeypatch.setattr(chat.httpx, "AsyncClient", Client)
result = asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="임도 기준"), {}))
assert result["answer"] == "설명 [1]"
paths = {source["path"] for source in result["sources"]}
assert paths == {"original/법률/현행.md"}
assert "실무문서" not in sent["prompt"]
assert "비공개 사례" not in sent["prompt"]
assert "STmate" not in sent["prompt"]
assert "다산소프트" not in sent["prompt"]
assert "기술정보 안내" not in sent["prompt"]
chat._sections.cache_clear()
@@ -0,0 +1,70 @@
"""화면 취향 저장 — 지워진 프로젝트 id 가 박힌 키는 버림 · 나머지 키는 그대로."""
import asyncio
import json
import B01_Dashboard.B01_Dashboard_Repository as repo
LIVE = "226fc805-26c8-4f5a-9956-bacb200e10a5"
DEAD = "936be972-11bc-46c2-8bf3-b15d8de7df0d"
class _Cursor:
def __init__(self, store):
self.store = store
self.rows = []
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql, args):
if sql.startswith("SELECT id FROM projects"):
self.rows = [(i,) for i in args if i == LIVE]
else:
self.store["saved"] = json.loads(args[1])
async def fetchall(self):
return self.rows
class _Conn:
def __init__(self, store):
self.store = store
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
def cursor(self):
return _Cursor(self.store)
async def commit(self):
pass
class _Pool:
def __init__(self, store):
self.store = store
def acquire(self):
return _Conn(self.store)
def test_dead_project_keys_dropped(monkeypatch):
store = {}
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(store))
prefs = {
f"aislo:pref:routeedit-view:{LIVE}": "a",
f"aislo:pref:routeedit-view:{DEAD}": "b",
"aislo:pref:table-open": "true",
}
asyncio.run(repo.save_user_ui_prefs(3, prefs))
assert store["saved"] == {
f"aislo:pref:routeedit-view:{LIVE}": "a",
"aislo:pref:table-open": "true",
}
@@ -33,9 +33,10 @@ def _defaults(type_id: str) -> dict:
def test_새_관만_기본값_빈_키에만():
fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800})
basins._apply_new_defaults([fresh])
expected = {**_defaults("pipe"), "pipe_diameter_mm": 800} # 추천(먼저 들어간 값)이 이김
# 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39)
expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L"}
assert fresh.options == expected
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "기슭막이"
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정"
box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert")
basins._apply_new_defaults([box])
assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "날개벽"
@@ -62,7 +63,8 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch):
_, points = basins._build("p", Ctx(), [old])
by = {p.chainage_m: p for p in points}
assert by[100.0].options is None # 옛 빈칸 그대로
assert by[300.0].options == _defaults("pipe") # 새로 보충된 관 = 기본값
# 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39)
assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L"}
def test_월류_폭_기본값은_등록부_엔진과_같음():
@@ -71,7 +71,7 @@ def test_request_wins_over_stored():
def test_schema_defaults_to_none_and_ships_the_flag():
"""스키마 기본은 None(미지정) — 저장값을 덮어쓰지 않는다."""
request = RouteSolveRequest(
filter_key="f",
filter_key="csf",
bp={"x": 0.0, "y": 0.0},
ep={"x": 100.0, "y": 0.0},
)
@@ -42,6 +42,12 @@ def test_TS_가_쓰는_칸이_파일에_있다() -> None:
def test_B05_높이_칸은_제안_없이_설계자_입력() -> None:
# 독립 기슭막이 높이 = 기본값 없음 · 「설계자 입력」 안내(제안값 단추 걷음 · PLAN 46-7)
assert 'createRevetmentFields(keys, { height: "" })' in FIELDS and '"설계자 입력"' in FIELDS
# 독립 기슭막이 높이 = 기본값 없음 · 빈 칸 그대로(54-43 · 자리표시 글 없음)
assert (
"createRevetmentFields(keys)" in FIELDS
and "placeholder"
not in FIELDS.split("createRevetmentFields(keys: RevetmentKeys)")[1].split(
"createBasinSplitFields"
)[0]
)
assert "기슭막이 높이 없음 — 근입만 그림" in CHROME
@@ -259,7 +259,12 @@ def test_slope_group_computes_range_from_length():
options = {option.key: option for option in item.options}
# ⚠ 옹벽만 높이 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐(2026-09-14 A4 브레인 판정).
expected = {
"height_m": 2.0 if type_id == "retaining_wall" else 2.5,
# 흙막이 높이 1.5 — 10-6 배관 기본값 표와 한 벌(54-36 · 사면안정 흙막이 일원화)
"height_m": 2.0
if type_id == "retaining_wall"
else 1.5
if type_id == "soil_guard"
else 2.5,
"length_m": 10,
"before_m": 5,
}
@@ -270,7 +275,9 @@ def test_slope_group_computes_range_from_length():
assert options[key].default == default, f"{type_id}.{key} 기본값"
assert options[key].input == "number" and options[key].unit == "m", key
keys = [option.key for option in item.options if option.phase == "b05"]
assert keys[:3] == ["height_m", "length_m", "before_m"], f"{type_id} 옵션 순서"
# 표 09 흙막이 · 옹벽 = 형태 → 길이 → 높이 → 전 → 후(54-26 · test_54_26_slope_table09)
if type_id not in ("soil_guard", "retaining_wall"):
assert keys[:3] == ["height_m", "length_m", "before_m"], f"{type_id} 옵션 순서"
def test_retaining_wall_renamed_plain():
@@ -433,7 +440,7 @@ def test_pipe_accessory_options_defined():
for gone in ("inlet_revet_material", "inlet_revet_finish", "outlet_revet_finish"):
assert gone not in options, f"pipe.{gone}는 형태로 합쳐졌다"
assert options["inlet_type"].choices == ["기슭막이", "집수정"]
assert options["inlet_type"].default == "기슭막이"
assert options["inlet_type"].default == "집수정" # 10-6 배관 기본값 표(54-36)
assert options["pipe_diameter_mm"].default == "1000"
assert options["pipe_diameter_mm"].choices == ["800", "1000", "1200", "1500"]
# 돌붙임은 바닥 구조라 형태 목록에서 뺐다 — 별도 항목이 있다(2026-08-17 지시 1).
@@ -446,7 +453,7 @@ def test_pipe_accessory_options_defined():
"바자",
]
assert options["inlet_revet_form"].default == "돌쌓기(찰)"
assert options["outlet_revet_form"].default == "돌쌓기(메)"
assert options["outlet_revet_form"].default == "돌쌓기(찰)" # 10-6 배관 기본값 표(54-36)
# 유출구도 구조 항목을 둔다 — 선택지는 기슭막이뿐이지만 양식을 맞춘다(지시 2).
assert options["outlet_type"].choices == ["기슭막이"]
# 구 두 축(있음/없음 + 표면처리)은 남아 있으면 안 된다.
@@ -459,11 +466,10 @@ def test_pipe_accessory_options_defined():
"outlet_pitching_area_m2",
):
assert gone not in options, f"pipe.{gone}는 보호공으로 합쳐졌다"
# 기본 치수 — 길이 10m. 높이는 **기본값 없음**(2026-09-14 브레인 판정): 비우면 관경 기준 최소
# 높이로 횡단도·구조물도·표가 같은 값을 씀(옛 2.5 는 횡단도가 안 쓰던 근거 없는 값이라 걷음).
# 기본 치수 — 길이 10m · 높이 1.5(10-6 배관 기본값 표 · 54-36 · 근거 default_basis).
for side in ("inlet", "outlet"):
assert options[f"{side}_revet_length_m"].default == 10
assert options[f"{side}_revet_height_m"].default is None
assert options[f"{side}_revet_height_m"].default == 1.5
assert "관경 기준" in options[f"{side}_revet_height_m"].empty_means
# 유무·길이·관경·유입 구조 = b05, 형태·치수 제원 = detail.
for key in ("inlet_type", "inlet_revet_length_m", "outlet_revet_length_m"):
+26 -22
View File
@@ -33,44 +33,48 @@ def test_cross_reference_constants_match_plan():
assert "min_slope" not in _culvert_set(None)
def test_empty_options_fall_back_to_registry_defaults():
"""옵션이 빈 저장분은 레지스트리 기본값(Ø1000·L10)으로 채운다.
높이는 2026-09-14 브레인 판정으로 **관경 기준 최소 높이**(Ø1000 → 1.5 + 근입 0.5 = 2.0) —
횡단도가 그리는 그 높이. 옛 기본 2.5 는 횡단도가 안 쓰던 값이라 걷음.
"""
def test_empty_options_have_no_members():
"""빈 고르기 칸 = 그 부재 없음(54-39) — 구조 · 형태 · 관종 · 기초 빈 값."""
spec = _culvert_set(None)
assert spec["type"] == "pipe"
assert spec["diameter_m"] == 1.0
assert spec["min_cover_m"] == MIN_PIPE_COVER_M
for side in ("inlet", "outlet"):
part = spec[side]
assert part["structure"] == "기슭막이"
assert part["revet_height_m"] == 2.0
assert part["revet_length_m"] == 10
assert part["face_slope"] == REVET_FACE_SLOPE
# 보호공 = 낙차고(2.0) × 2 = 4.0m, 두께 0.45m(사용자 확정).
assert part["apron_length_m"] == 4.0
assert part["apron_thickness_m"] == 0.45
# 레지스트리 기본 형태: 유입 찰 / 유출 메.
assert spec["inlet"]["revet_form"] == "돌쌓기(찰)"
assert spec["outlet"]["revet_form"] == "돌쌓기(메)"
assert spec["pipe_kind"] is None
assert spec["foundation"] is None
assert spec["inlet"] == {"role": "inlet", "structure": ""}
assert spec["outlet"] == {"role": "outlet", "structure": ""}
def test_saved_values_drive_members():
"""고른 값만 부재 — 빈 높이 = 관경 기준 최소(Ø1000 → 2.0) · 빈 형태 = 없음."""
spec = _culvert_set({"inlet_type": "집수정", "inlet_structure": "L", "outlet_type": "기슭막이"})
assert spec["inlet"]["basin_form"] == "L"
assert spec["inlet"]["basin_height_m"] is None
part = spec["outlet"]
assert part["structure"] == "기슭막이"
assert part["revet_form"] is None
assert part["revet_height_m"] == 2.0
assert part["face_slope"] == REVET_FACE_SLOPE
# 보호공 = 낙차고(2.0) × 2 = 4.0m, 두께 0.45m(사용자 확정).
assert part["apron_length_m"] == 4.0
assert part["apron_thickness_m"] == 0.45
def test_basin_inlet_skips_revet_and_apron():
"""유입구 = 집수정이면 그쪽 기슭막이·보호공을 만들지 않는다(라벨만)."""
spec = _culvert_set({"inlet_type": "집수정"})
spec = _culvert_set({"inlet_type": "집수정", "outlet_type": "기슭막이"})
assert spec["inlet"]["structure"] == "집수정"
assert "revet_height_m" not in spec["inlet"]
assert "apron_length_m" not in spec["inlet"]
# 유출측은 그대로 세트를 갖춘다.
assert spec["outlet"]["structure"] == "기슭막이"
assert spec["outlet"]["apron_length_m"] == 4.0 # 관경 기준 높이 2.0 × 2
assert spec["outlet"]["apron_length_m"] == 4.0 # 빈 높이 = 관경 기준 최소 2.0 × 2
def test_string_diameter_and_custom_height():
"""관경 문자열 저장분("800")과 사용자 높이 변경이 그대로 반영된다."""
spec = _culvert_set({"pipe_diameter_mm": "800", "outlet_revet_height_m": 1.5})
spec = _culvert_set(
{"pipe_diameter_mm": "800", "outlet_type": "기슭막이", "outlet_revet_height_m": 1.5}
)
assert spec["diameter_m"] == 0.8
assert spec["outlet"]["revet_height_m"] == 1.5
assert spec["outlet"]["apron_length_m"] == 3.0 # 1.5 × 2
@@ -28,7 +28,7 @@ TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
# 다섯 시설 × 기본값 폴백/명시값 섞기. 물넘이포장은 폭 절반이 옆 측점까지 걸친다.
POINTS = [
{"chainage_m": 20.0, "facility": "pipe", "options": None},
{"chainage_m": 20.0, "facility": "pipe", "options": {"pipe_diameter_mm": 1000}},
{
"chainage_m": 40.0,
"facility": "pipe",
+101
View File
@@ -0,0 +1,101 @@
"""원지반 잠금 풀기(54-45) — 흙막이 자리 · 다단 · 높이를 원지반이 막지 않음.
남는 규칙: 벽 윗면 노면 위 금지(d ≥ 0) · 관 보호공 흙막이 m 단위 자리(관 길이 정수).
서버 면적은 같은 TS 를 Node 로 돌림(`B06_Section_Server_Calc_Node.ts`) — 번들도 같은 판인지 봄.
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
B06 = PROJECT_ROOT / "B06_Section"
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
BUNDLE = PROJECT_ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js"
_RUNNER = """
import { writeFileSync } from "node:fs";
import { placePipeWall } from "./B06_Section_UI_Cross_Culvert_Solve.js";
import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra.js";
const pipe = (d, x) => placePipeWall({
autoOffset: 10, outward: 1, height: 2, baseElevation0: 98, edgeElevation: 100,
invertCap: Number.POSITIVE_INFINITY, adjust: { x, d, h: null, m: null }, defaultD: 4.8,
});
const extras = (ground, d) => buildOutletExtras({
start: { offset: 10, elevation: 95 }, startBottomElevation: 94.5, outward: 1,
groundAt: () => ground, limitOffset: 40,
adjusts: [{ x: 0, d, h: 2, m: null }, { x: 0, d, h: 2, m: null }],
});
const buried = extras(100, 1);
const high = extras(80, 3);
writeFileSync(process.argv[2], JSON.stringify({
deep: pipe(6, 2), up: pipe(-5, 0),
buriedCount: buried.walls.length, buriedD: buried.appliedAdjusts.map((a) => a.d),
highCount: high.walls.length, highD: high.appliedAdjusts.map((a) => a.d),
}));
"""
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_원지반이_자리_다단을_막지_않고_노면_위만_막음(tmp_path: Path) -> None:
out = tmp_path / "js"
for name in ("Culvert_Solve", "Culvert_Extra"):
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(B06 / f"B06_Section_UI_Cross_{name}.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--skipLibCheck",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Culvert_Solve.js"))
(base / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(base / "runner.mjs"), str(result)], check=True, capture_output=True
)
got = json.loads(result.read_text(encoding="utf-8"))
assert (got["deep"]["d"], got["deep"]["x"]) == (6, 2), "관 흙막이 깊이 · 좌우가 되돌아감"
assert got["up"]["d"] == pytest.approx(0), "관 흙막이 윗면이 노면 위로 올라감"
assert got["up"]["topElevation"] == pytest.approx(100), "노면 멈춤 자리 틀림"
assert got["buriedCount"] == 2, "원지반에 묻힌 다단이 끊김"
assert got["buriedD"] == [1, 1], "묻힌 다단의 높이가 되돌아감"
assert got["highCount"] == 2, "원지반 위 다단이 끊김"
assert got["highD"] == [3, 3], "뜬 다단의 높이가 되돌아감"
def test_화면_서버_같은_판() -> None:
for name in ("Culvert_Solve", "Culvert_Geom", "Revetment"):
source = (B06 / f"B06_Section_UI_Cross_{name}.ts").read_text(encoding="utf-8")
assert "requireCrossing" not in source, f"{name} 에 매몰 자리 금지가 남음"
assert "outletReceivable" not in source, f"{name} 에 유출 수용 가드가 남음"
assert "const placeable" not in (B06 / "B06_Section_UI_Cross_Revetment.ts").read_text(
encoding="utf-8"
), "사면안정 흙막이 · 옹벽 매몰 자리 금지가 남음"
assert "const placeable" not in (B06 / "B06_Section_UI_Cross_Culvert_Extra.ts").read_text(
encoding="utf-8"
), "추가 흙막이 매몰 자리 금지가 남음"
bundle = BUNDLE.read_text(encoding="utf-8")
assert "outletReceivable" not in bundle and "requireCrossing" not in bundle, (
"서버 번들이 옛 판 — npm run build:server-calc"
)
@@ -0,0 +1,94 @@
"""높이 제약 풀기(54-51) — 관 흙막이 · 추가 흙막이 · 사면안정 흙막이 · 옹벽 · 집수정 높이를 사용자 값 그대로.
남는 규칙: 그림이 안 서는 값(순수 높이 0 이하)만 0.1 에서 멈춤 · 토스트 없음.
서버 면적은 같은 TS 를 Node 로 돌림(`B06_Section_Server_Calc_Node.ts`) — 번들도 같은 판인지 봄.
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
B06 = PROJECT_ROOT / "B06_Section"
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
BUNDLE = PROJECT_ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js"
_RUNNER = """
import { writeFileSync } from "node:fs";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const.js";
import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra.js";
const spec = { revet_form: "찰쌓기", revet_height_m: 1.5 };
const pipe = (h) => revetWallSpec(spec, { h, m: null }, false, 0.8).pureHeight;
const own = (h) => revetWallSpec(spec, { h, m: null }, true, 0).pureHeight;
const extras = buildOutletExtras({
start: { offset: 10, elevation: 95 }, startBottomElevation: 94.5, outward: 1,
groundAt: () => 80, limitOffset: 40,
adjusts: [{ x: 0, d: 0, h: 0.3, m: null }],
});
writeFileSync(process.argv[2], JSON.stringify({
pipeLow: pipe(0.7), pipeAuto: pipe(null), pipeZero: pipe(0),
ownLow: own(0.3), ownNeg: own(-1),
extraCount: extras.walls.length,
extraPure: extras.walls.map((w) => w.topBack.elevation - w.bottomBack.elevation),
}));
"""
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_높이는_입력_그대로_0이하만_멈춤(tmp_path: Path) -> None:
out = tmp_path / "js"
for name in ("Culvert_Const", "Culvert_Extra"):
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(B06 / f"B06_Section_UI_Cross_{name}.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--skipLibCheck",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Culvert_Const.js"))
(base / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(base / "runner.mjs"), str(result)], check=True, capture_output=True
)
got = json.loads(result.read_text(encoding="utf-8"))
assert got["pipeLow"] == pytest.approx(0.7), "관 흙막이가 관경 기준 최소로 되돌아감"
assert got["pipeAuto"] == pytest.approx(1.8), "빈 칸 기본값(관경 + 1.0) 틀림"
assert got["pipeZero"] == pytest.approx(0.1), "0 이하가 그림 하한에서 안 멈춤"
assert got["ownLow"] == pytest.approx(0.3), "흙막이 0.3 이 근입 기준 최소로 되돌아감"
assert got["ownNeg"] == pytest.approx(0.1), "음수 높이가 그림 하한에서 안 멈춤"
assert got["extraCount"] == 1, "낮은 추가 흙막이가 안 섬"
assert got["extraPure"][0] == pytest.approx(0.3), "추가 흙막이 0.3 이 되돌아감"
def test_높이_토스트_없음_서버_같은_판() -> None:
for name in ("UI_Cross_Culvert_Wire", "UI_Page_Pipe_Options"):
source = (B06 / f"B06_Section_{name}.ts").read_text(encoding="utf-8")
assert "B06_Cross_Height_Floor" not in source, f"{name} 에 최소 높이 토스트가 남음"
assert "높이는 최소" not in source, f"{name} 에 최소 높이 토스트가 남음"
panel = (B06 / "B06_Section_UI_Cross_Structure_Panel.ts").read_text(encoding="utf-8")
assert "Math.min(\n 2," not in panel, "집수정 내공 2m 상한이 남음"
bundle = BUNDLE.read_text(encoding="utf-8")
assert "EXTRA_WALL_MIN_HEIGHT_M" not in bundle, "서버 번들이 옛 판 — npm run build:server-calc"
assert "WALL_MIN_PURE_HEIGHT_M" in bundle, "서버 번들이 옛 판 — npm run build:server-calc"
+10 -10
View File
@@ -34,12 +34,12 @@ def test_registry_defines_basin_before_after():
def test_basin_side_spec_carries_before_after():
"""유입이 집수정이면 세트 제원에 길이와 전/후가 함께 실린다(레지스트리 기본값 경유)."""
"""유입이 집수정이면 세트 제원에 길이와 전/후가 함께 실린다 — 빈 전/후 = 값 없음(54-39)."""
spec = _culvert_set({"inlet_type": "집수정"})["inlet"]
assert spec["structure"] == "집수정"
assert spec["basin_length_m"] == 2.0
assert spec["basin_before_m"] == 1.0
assert spec["basin_after_m"] == 1.0
assert spec["basin_before_m"] is None
assert spec["basin_after_m"] is None
# 집수정 쪽에는 기슭막이가 없다 — 벽 제원이 섞이면 링크 판정이 10m로 번진다.
assert "revet_length_m" not in spec
@@ -60,10 +60,10 @@ def test_basin_before_after_follow_saved_options():
def test_revet_span_defaults_unchanged():
"""기슭막이 기본은 그대로 길이 10m·전/후 5·5다(이번 변경이 건드리지 않는다)."""
culvert = _culvert_set(None)
for role in ("inlet", "outlet"):
spec = culvert[role]
assert spec["revet_length_m"] == 10.0
assert spec["revet_before_m"] == 5.0
assert spec["revet_after_m"] == 5.0
"""빈 길이 · 전 · 후 = 값 없음(54-39 · 정의 기본값으로 안 읽음) · 빈 유출 구조 = 벽 없음."""
culvert = _culvert_set({"inlet_type": "기슭막이"})
spec = culvert["inlet"]
assert spec["revet_length_m"] is None
assert spec["revet_before_m"] is None
assert spec["revet_after_m"] is None
assert culvert["outlet"] == {"role": "outlet", "structure": ""}
@@ -0,0 +1,98 @@
"""횡단 벽 형상 한 벌 — 관 유입·유출 흙막이 · 추가 흙막이 · 사면안정 흙막이 · 옹벽.
규칙(10-6 사용자):
① 네 벽 모두 공용 `buildRevetWallGeometry` 로 선다 — 형태는 안 무늬만 바꿈(외곽 같음).
② 형태별 높이 한도(메 2.0 · 찰 3.0 · 콘크리트 5.0) · 원지반 맞춤 자동 자르기 없음.
③ 벽 윗면은 노면 위로 올라오지 않음(넘으면 노면에서 멈춤).
서버 면적은 같은 TS 를 Node 로 돌리므로(`B06_Section_Server_Calc_Node.ts`) 이 함수가 곧 서버 기하.
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
B06 = PROJECT_ROOT / "B06_Section"
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall.js";
import { revetWallSpec, REVET_FORMS } from "./B06_Section_UI_Cross_Culvert_Const.js";
const anchor = { offset: 5, elevation: 100 };
const outline = (form, role, extra = {}) =>
buildRevetWallGeometry({ anchor, outward: 1, height: 2.5, material: "dry", role, form, ...extra })
.points.map((p) => [p.offset, p.elevation]);
writeFileSync(process.argv[2], JSON.stringify({
forms: REVET_FORMS.map((form) => outline(form, "outlet")),
roles: ["inlet", "outlet", "extra"].map((role) => outline("돌쌓기(메)", role)),
capped: buildRevetWallGeometry({
anchor, outward: 1, height: 5, material: "dry", role: "outlet", roadElevation: 103,
}).topJoint.elevation,
under: buildRevetWallGeometry({
anchor, outward: 1, height: 2, material: "dry", role: "outlet", roadElevation: 103,
}).topJoint.elevation,
dry: revetWallSpec({ revet_form: "돌쌓기(메)" }, { h: 3.0 }, true, 0.3).pureHeight,
concrete: revetWallSpec({ revet_form: "콘크리트" }, { h: 7.5 }, true, 0.3).pureHeight,
}));
"""
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_벽_외곽_한_벌_높이_입력_그대로_노면_멈춤(tmp_path: Path) -> None:
out = tmp_path / "js"
# 형(type) import 가 끌어오는 페이지 파일은 별칭을 못 풀어 오류를 내지만 산출물은 나옴.
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(B06 / "B06_Section_UI_Cross_Wall.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--skipLibCheck",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Wall.js"))
(base / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(base / "runner.mjs"), str(result)], check=True, capture_output=True
)
got = json.loads(result.read_text(encoding="utf-8"))
assert all(outline == got["forms"][0] for outline in got["forms"]), "형태마다 외곽이 다름"
assert all(outline == got["roles"][0] for outline in got["roles"]), "벽 갈래마다 외곽이 다름"
assert got["capped"] == pytest.approx(103.0), "노면 위로 올라옴"
assert got["under"] == pytest.approx(102.0), "노면 아래 벽이 잘림"
assert got["dry"] == pytest.approx(3.0), "돌쌓기(메) 3.0 이 잘림"
assert got["concrete"] == pytest.approx(7.5), "콘크리트 7.5 가 잘림"
def test_네_벽이_공용_함수로_서고_형태_한도가_없다() -> None:
for name in ("Culvert_Geom", "Culvert_Extra", "Revetment"):
source = (B06 / f"B06_Section_UI_Cross_{name}.ts").read_text(encoding="utf-8")
assert "buildRevetWallGeometry(" in source, f"{name} 가 공용 벽 함수를 안 씀"
for path in B06.glob("*.ts"):
source = path.read_text(encoding="utf-8")
assert "revetHeightLimit" not in source, f"{path.name} 에 형태 높이 한도가 남음"
assert "materialLimit" not in source, f"{path.name} 에 재질 높이 한도가 남음"
extra = (B06 / "B06_Section_UI_Cross_Culvert_Extra.ts").read_text(encoding="utf-8")
assert "fitted" not in extra, "추가 흙막이가 원지반에 맞춰 늘어남"
@@ -39,7 +39,7 @@ def test_두_보기가_같은_종단도와_같은_판():
"""종단도는 한 페이지 한 벌(계획서 24-3b) — 두 보기는 같은 노드에 제 알림만 건다."""
shared = _read("A00_Common/design/design_shared.ts")
assert shared.count("createDesignProfile(") == 1
assert "side: drainageSide" in shared and "sideTool: drainageSideTool" in shared
assert "side: drainageSide" in shared and "drainageSideTool(board)" in shared
b05 = _read("B05_Profile/B05_Profile_UI_Profile_Panel.ts")
b06 = _read("B06_Section/B06_Section_UI_Section_View_Profile.ts")
for source, view in ((b05, "profile"), (b06, "cross")):
+1 -2
View File
@@ -59,7 +59,7 @@ def results():
def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results):
assert results["sameNodes"] == {"cross": True, "back": True}
# 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만.
assert results["childCount"] == 1 + 5 + 2 # 아래 고정 영역 + 종단 칸 다섯 + 횡단 칸 둘
assert results["childCount"] == 1 + 4 + 2 # 아래 고정 영역 + 종단 칸 넷 + 횡단 칸 둘
def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results):
@@ -80,7 +80,6 @@ def test_보기마다_제_칸만_보인다(results):
"구조물 배치",
"페이지 설정",
"계획노선",
"경로 계산 설정",
"선택 포인트 상세 설정",
]
assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"]
+6 -2
View File
@@ -220,8 +220,12 @@ def test_저장된_표준단면이_브라우저까지_간다():
# 값으로 재는 확인은 화면 실측에 있음(보조 창 b269ea34 81.77m — 단 0 → 1 에서 성토
# 10.9027 → 10.6743㎡). 계획서에 그 수치가 남아 있다.
def test_다단_성토부선이_면적_트림에_들어간다():
source = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text(
encoding="utf-8"
# 다단 · 묻힌 구간 거르기는 `_Culvert_Tiers` 로 나뉘어 있어 두 파일을 함께 읽음.
source = "".join(
(PROJECT_ROOT / "B06_Section" / f"B06_Section_UI_Cross_Culvert_{name}.ts").read_text(
encoding="utf-8"
)
for name in ("Geom", "Tiers")
)
assert "const extendTrimSlope = (" in source, "트림 확장 자리가 사라짐"
# 유출측·집수정 계류측 **둘 다** — 한쪽만 넣으면 그쪽 단만 물량에 잡힌다.
@@ -0,0 +1,255 @@
/* 54-13 — 좌측 폼 고르기 칸이 그림이 쓰는 값(기본값 · 자동 규칙 결과)을 보이는가 · 보여 주기만으로 값이 안 바뀌는가
* 시작 화면: B06 횡단설계(#/b06-section) 또는 B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 로그인
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5413 = "단계";` · 결과 `sessionStorage.getItem("__5413_out_{단계}")`
* check → 목록의 배관 · 세월교 · BOX 전부를 열어 폼 글 ↔ 그림(B06 카드 SVG 글 · 벽 툴팁 · 상세 세트) 대조 ·
* B05 에서 부르면 B06 에서 읽은 폼 글과 같은지 · 초안(관 지점 세션) 안 생김
* old → 4+5 배관을 옛 「자동(규칙)」(유입 기슭막이 · 형식 없음)으로 초안에 넣고 새로 고침
* oldpick→ (새로 고친 뒤) 폼 = 그림 · 관경만 고르면 유입 형식 안 굳음 · 흙막이 고르면 revet · 그림 벽 ·
* 12+18 배관 집수정 높이 고침 = 집수정을 고른 것
* box → 17+12 세월교(빈 칸) 대조 · 세션에서만 BOX 로 바꾸고 칸을 비워 규격 · 유입 · 유출 = 기본 · 저장 칸 없음
* restore→ 초안 지우고 새로 고침 · 서버 관 지점 = 처음 사진
* 쓰는 것: sessionStorage 만(서버 저장 없음)
* 기대 수치: 모든 단계 bad = []
*/
(async () => {
const MODE = window.__m5413 || "check";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const pid = localStorage.getItem("frd_current_project_id");
const DRAFT = `aislo:draft:pipes:${pid}`;
const B06 = location.hash.includes("b06");
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const flat = (s) => String(s ?? "").replace(/\s+/g, "");
const out = { mode: MODE, page: B06 ? "B06" : "B05", bad: [] };
const bad = (what) => out.bad.push(what);
const done = () => sessionStorage.setItem(`__5413_out_${MODE}`, JSON.stringify(out));
const api = async () =>
(await (await fetch(`/api/projects/${pid}/drainage/pipe-points`)).json()).pipe_points;
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const rowOf = (head) => rows().find((li) => txt(li).startsWith(head));
const draft = (at) => {
const raw = sessionStorage.getItem(DRAFT);
const list = raw ? (JSON.parse(raw).value ?? JSON.parse(raw)) : null;
return list?.find((p) => Math.abs(p.chainage_m - at) < 0.51) ?? null;
};
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((s) => s.offsetParent);
/** 폼 고르기 칸 `{항목: 보이는 글}` · 꼬리 표시(data-spec-shown) 칸 목록. */
const formOf = () => {
const fields = {};
const tagged = [];
for (const wrap of spec()?.querySelectorAll("[data-spec-item]") ?? []) {
const sel = wrap.querySelector("select");
if (!sel) continue;
fields[wrap.dataset.specItem] = sel.options[sel.selectedIndex]?.text ?? "";
if (wrap.dataset.specShown) tagged.push(wrap.dataset.specItem);
}
return { fields, tagged };
};
const detailAt = (at) => {
const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail"));
const raw = key ? JSON.parse(sessionStorage.getItem(key)) : null;
const list = (raw?.value ?? raw)?.cross_sections ?? [];
return list.reduce(
(a, b) => (Math.abs(b.chainage_m - at) < Math.abs((a?.chainage_m ?? 1e9) - at) ? b : a),
null,
);
};
/** 그림 — 고른 카드 SVG 글 · 벽 툴팁(역할 + 형태). */
const drawn = () => {
const svg = document.querySelector(".b06-cross-card--selected svg");
if (!svg) return null;
return {
labels: [...svg.querySelectorAll(".b06-chart__culvert-label")].map((t) => t.textContent),
walls: [...svg.querySelectorAll(".b06-chart__culvert-revet title")].map(
(t) => t.textContent.split(" H=")[0],
),
};
};
const open = async (head) => {
const row = await until(() => rowOf(head)).then(() => rowOf(head));
if (!row) return (bad(`${head}: 목록에 없음`), false);
row.click();
await until(() => spec() && (!B06 || document.querySelector(".b06-cross-card--selected svg")));
await w(1200);
return true;
};
const fire = (sel, value) => {
sel.value = value;
sel.dispatchEvent(new Event("change", { bubbles: true }));
};
const selectOf = (item) => spec()?.querySelector(`[data-spec-item="${item}"] select`);
const SHAPE = { I형: "I형", L형: "ㄴ형", ㄷ형: "ㄷ형" };
/** 폼 글 ↔ 그림 — 종류마다 그림이 쓰는 값을 따로 읽어 대조. */
const compare = (head, at, kind) => {
const { fields, tagged } = formOf();
const pic = B06 ? drawn() : null;
const set = detailAt(at);
const got = { head, fields, tagged, pic };
const blank = Object.entries(fields).filter(
([k, v]) => v === "— 안 정함 —" && !/^(soil_add|chute_add)/.test(k),
);
if (blank.length) bad(`${head}: 빈 칸 글 ${blank.map(([k]) => k)}`);
if (!pic) return got; // B05 — 글 대조는 B06 사진과
const head0 = (v) => flat(v).replace(/\((기본|자동)\)$/, "");
if (kind === "pipe") {
const basin = pic.labels.find((l) => l.startsWith("집수정"));
const inlet = head0(fields["inlet.structure"]);
if ((inlet === "집수정") !== !!basin) bad(`${head}: 유입 ${inlet} ↔ 그림 ${basin ?? "벽"}`);
const form = head0(fields["inlet.form"]);
if (basin && !basin.includes(SHAPE[form] ?? "?")) bad(`${head}: 모양 ${form} ↔ ${basin}`);
for (const [side, role] of [
["inlet", "유입"],
["outlet", "유출"],
]) {
const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`));
if (side === "inlet" && basin) continue;
const f = head0(fields[`${side}.form`]);
if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`))
bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`);
}
const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000);
if (tagged.includes("pipe.diameter") && head0(fields["pipe.diameter"]) !== String(d))
bad(`${head}: 관경 ${fields["pipe.diameter"]} ↔ ${d}`);
}
if (kind === "ford") {
const label = pic.labels.find((l) => l.includes("Ø")) ?? "";
if (!label.includes(`Ø${head0(fields["ford.diameter"])}`))
bad(`${head}: 관경 ${fields["ford.diameter"]} ↔ ${label}`);
for (const side of ["in", "out"]) {
const wing = set?.ford?.[`wing_${side}`]?.installed;
const text = head0(fields[`${side === "in" ? "inlet" : "outlet"}.structure`]);
if (wing !== undefined && (text === "날개벽") !== wing)
bad(`${head}: ${side} ${text} ↔ 설치 ${wing}`);
}
}
if (kind === "box") {
const label = pic.labels.find((l) => l.startsWith("BOX")) ?? "";
if (flat(label) !== `BOX${head0(fields["body.size"])}`)
bad(`${head}: 규격 ${fields["body.size"]} ↔ ${label}`);
}
return got;
};
const KIND = { 배관: "pipe", 세월교: "ford", BOX암거: "box" };
try {
if (MODE === "check") {
if (!sessionStorage.getItem("__5413_orig"))
sessionStorage.setItem("__5413_orig", JSON.stringify(await api()));
const before = sessionStorage.getItem(DRAFT);
const heads = rows()
.map(txt)
.filter((t) => /^\d+\+\d+ (배관|세월교|BOX암거)/.test(t));
const b06 = JSON.parse(sessionStorage.getItem("__5413_b06") ?? "{}");
out.rows = [];
for (const t of heads) {
const head = t.split(" ").slice(0, 2).join(" ");
if (!(await open(head))) continue;
const station = head.split(" ")[0].split("+").map(Number);
const got = compare(head, station[0] * 20 + station[1], KIND[t.split(" ")[1]]);
out.rows.push(`${head} ${JSON.stringify(got.fields)} | ${JSON.stringify(got.pic)}`);
if (B06) b06[t] = got.fields;
else if (b06[t] && JSON.stringify(b06[t]) !== JSON.stringify(got.fields))
bad(`${head}: B05 ${JSON.stringify(got.fields)} ≠ B06 ${JSON.stringify(b06[t])}`);
else if (!b06[t]) bad(`${head}: B06 사진 없음 — B06 에서 check 먼저`);
}
if (B06) sessionStorage.setItem("__5413_b06", JSON.stringify(b06));
if (sessionStorage.getItem(DRAFT) !== before) bad("열기만 했는데 관 초안이 바뀜");
out.count = heads.length;
} else if (MODE === "old") {
const list = (await api()).map(({ lonlat, ...rest }) => rest);
const pipe = list.find((p) => Math.abs(p.chainage_m - 85.05) < 0.51);
pipe.options = { ...pipe.options, inlet_type: "기슭막이" };
delete pipe.options.inlet_structure;
sessionStorage.setItem(DRAFT, JSON.stringify(list));
out.note = "초안 넣음 — 새로 고친 뒤 oldpick";
done();
location.reload();
return;
} else if (MODE === "oldpick") {
await open("4+5 배관");
out.start = compare("4+5 옛 자동", 85.05, "pipe");
if (!flat(out.start.fields["inlet.structure"]).endsWith("(자동)"))
bad(`옛 자동 유입 글 ${out.start.fields["inlet.structure"]}`);
// 관경만 고름 — 유입 형식은 그대로(자동 유지)
fire(selectOf("pipe.diameter"), "1200");
await w(2500);
out.afterDiameter = draft(85.05)?.options;
if (out.afterDiameter?.inlet_structure !== undefined)
bad(`관경만 골랐는데 형식 ${out.afterDiameter.inlet_structure}`);
if (out.afterDiameter?.inlet_type !== "기슭막이")
bad(`유입 저장 값 바뀜 ${out.afterDiameter?.inlet_type}`);
out.stillAuto = formOf().fields["inlet.structure"];
// 흙막이를 고름 = 형식 revet · 그림 벽
fire(selectOf("inlet.structure"), "기슭막이");
await w(3500);
out.afterPick = draft(85.05)?.options;
if (out.afterPick?.inlet_structure !== "revet")
bad(`흙막이 골랐는데 형식 ${out.afterPick?.inlet_structure}`);
out.picked = compare("4+5 흙막이 고름", 85.05, "pipe");
if (out.picked.fields["inlet.structure"] !== "흙막이")
bad(`고른 글 ${out.picked.fields["inlet.structure"]}`);
// 다른 관(자동 집수정) — 집수정 높이 고침 = 집수정을 고른 것
await open("12+18 배관");
const shown = formOf().fields["inlet.structure"];
out.other = shown;
if (flat(shown) === "집수정(자동)") {
const h = spec().querySelector('[data-spec-item="inlet.height"] input');
h.value = "1.5";
h.dispatchEvent(new Event("change", { bubbles: true }));
await w(3000);
const o = draft(258.12)?.options;
out.basinEdit = o;
if (o?.inlet_type !== "집수정" || o?.inlet_basin_height_m !== 1.5)
bad(`집수정 높이 고침 저장 ${JSON.stringify(o)}`);
if (formOf().fields["inlet.structure"] !== "집수정")
bad(`높이 고친 뒤 글 ${formOf().fields["inlet.structure"]}`);
}
} else if (MODE === "box") {
await open("17+12 세월교");
out.ford = compare("17+12 세월교", 352.14, "ford");
const type = document.querySelectorAll(
".b05-structure-section .b05-structure__grid select",
)[1];
fire(type, "box_culvert");
await until(() => rowOf("17+12 BOX암거"));
await w(3000);
// 새 종류는 정의 기본값이 값으로 실림(46-7) — 칸을 비워 옛 빈 저장과 같게
for (const k of ["body.size", "inlet.structure", "outlet.structure"]) {
fire(selectOf(k), "");
await w(1500);
}
await w(2000);
out.box = compare("17+12 BOX", 352.14, "box");
const f = out.box.fields;
if (flat(f["body.size"]) !== "2.0×2.0(기본)") bad(`BOX 규격 ${f["body.size"]}`);
for (const k of ["inlet.structure", "outlet.structure"])
if (flat(f[k]) !== "날개벽(기본)") bad(`BOX ${k} ${f[k]}`);
const o = draft(352.14)?.options ?? {};
if (["body_width_m", "wing_in", "wing_out"].some((k) => k in o))
bad(`보여 주기만 했는데 BOX 저장 칸 ${JSON.stringify(o)}`);
} else if (MODE === "restore") {
sessionStorage.removeItem(DRAFT);
// 고르기 단계가 남긴 B06 유입 형식 세션 · 그걸 얹은 상세 캐시(다시 받음)
for (const key of Object.keys(sessionStorage))
if (key.includes(`inletstruct:${pid}`) || key.includes(`section-detail:${pid}`))
sessionStorage.removeItem(key);
const orig = JSON.parse(sessionStorage.getItem("__5413_orig") ?? "null");
const now = await api();
out.same = JSON.stringify(now) === JSON.stringify(orig);
if (!out.same) bad("서버 관 지점이 처음 사진과 다름");
done();
location.reload();
return;
}
} catch (error) {
bad(String(error?.stack ?? error));
}
done();
})();
@@ -0,0 +1,242 @@
/* 54-15 — 구조물 목록 잇기 램프(측점 왼쪽 · 3행 × 열 · 초록 이음 · 빨강 필수 미지정 · 노랑 선택 미지정)
* 시작 화면: B06(#/b06-section) 또는 B05 · 검증 프로젝트(sub4_검증) · 로그인
* 부르는 법: orca eval --expression "window.__m5415={mode:'단계'}; $(cat 이 파일)"
* look → 줄마다 램프 = 폼 잇기 줄(수 · 색) 대조 · 줄 높이(램프 끈 때와 같음) · 글 시작 x · 칸 폭
* live → ARGS.row(기본 「배관 1」) 빨강 하나 잇기 → 초록 → 해제 → 빨강 · 「도수로/돌붙임」 추가 →
* 노랑 → 잇기 → 초록 → 해제 → 노랑 → 빼기 → 램프 수 되돌아옴(저장 안 함)
* sig → 줄마다 램프 색 글(B05 · B06 대조 · 저장 · 다시 열기 대조용)
* (결과는 window.__m5415_out 에도 — orca eval 응답이 끊기면 그것을 읽음)
* link / unlink → ARGS.row 첫 빨강 잇기 / 첫 초록 해제 → [저장] → sig
*/
(async () => {
const ARGS = window.__m5415 || { mode: "look" };
const ROW = ARGS.row || "배관 1";
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 modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
// 명세 모듈 주소 — 자원 목록에 없으면(먼저 불린 계산 모듈 주소에서) 이름만 바꿔 씀
const specUrl =
modUrl("/design/design_structure_pipe.ts") ??
modUrl("/design/design_structure_pipe_calc.ts").replace("_calc.ts", ".ts");
const { STRUCTURE_SPECS } = await import(specUrl);
const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const nameOf = (li) => li.querySelector("span")?.textContent ?? "";
const rowOf = (name) => items().find((li) => nameOf(li) === name);
const color = (dot) =>
dot.classList.contains("is-linked") ? "G" : dot.classList.contains("is-required") ? "R" : "Y";
const lampsOf = (li) => [...li.querySelectorAll(".b05-route__lamps i")].map(color).join("");
const visible = (n) => n.offsetParent !== null;
/** 폼에 보이는 잇기 줄 — 색 글(같은 차례 · 위 → 아래). */
const formLinks = () =>
[...document.querySelectorAll(".b05-structure__summary")].filter(visible).map((row) => {
if (row.querySelector(".b05-structure__summary-name.is-assigned")) return "G";
const box = row.closest("[data-spec-container]");
if (!box) return "R"; // 종류 단위 · 정의 그룹 상자 = 필수
const c = Object.values(STRUCTURE_SPECS)
.flatMap((s) => s.containers)
.find(
(c) => c.id === box.dataset.specContainer && row.dataset.specLink?.startsWith(c.link),
);
return c && !c.required ? "Y" : "R";
});
const sorted = (s) => [...s].sort().join("");
const select = async (name) => {
await until(() => items().length > 0);
const li = rowOf(name);
if (!li) return false;
if (!li.classList.contains("is-selected")) {
li.click();
await until(() => rowOf(name)?.classList.contains("is-selected"), 5000);
}
await w(700);
return true;
};
const release = async () => {
const li = document.querySelector(".b05-route__irregular-list li.is-selected");
if (li) li.click();
await w(500);
};
const sig = () =>
items().map((li) => `${li.querySelector("strong")?.textContent}${nameOf(li)}:${lampsOf(li)}`);
const pickLink = async (row) => {
row.querySelector('[data-b05-summary="pick"]').click();
await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row"));
for (let k = 0; k < 8; k++) {
const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")];
const leaf = rows.find((r) => !r.classList.contains("is-branch"));
if (leaf) {
leaf.click();
break;
}
rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click();
await w(300);
}
await w(300);
[...document.querySelectorAll(".ui-item-picker__actions button")]
.find((b) => b.textContent.includes("적용"))
.click();
await w(1500);
};
const linkRows = () => [...document.querySelectorAll(".b05-structure__summary")].filter(visible);
const save = async () => {
document.querySelector('[data-dock-action="save"]').click();
await w(500);
const ok = await until(() => !document.querySelector(".ui-overlay.is-active"), 90000);
await w(1500);
return ok;
};
const out = { mode: ARGS.mode };
if (ARGS.mode === "look") {
await release();
const list = document.querySelector(".b05-route__irregular-list");
const heights = () =>
items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10);
const withLamps = heights();
list.classList.add("m5415-off");
const style = document.createElement("style");
style.textContent = ".m5415-off .b05-route__lamps{display:none}";
document.head.append(style);
const without = heights();
style.remove();
list.classList.remove("m5415-off");
const xs = items().map(
(li) => Math.round(li.querySelector("strong").getBoundingClientRect().left * 10) / 10,
);
const widths = items().map(
(li) => li.querySelector(".b05-route__lamps").getBoundingClientRect().width,
);
const dot = items()
.map((li) => li.querySelector(".b05-route__lamps i"))
.find(Boolean)
?.getBoundingClientRect();
// 줄마다 폼을 열어 잇기 줄과 대조
const rows = [];
for (const li of items()) {
const name = nameOf(li);
const lamps = lampsOf(li);
await select(name);
const form = formLinks().join("");
rows.push({ name, lamps, form, ok: sorted(lamps) === sorted(form) });
await release();
}
const cols = Math.max(...rows.map((r) => Math.ceil(r.lamps.length / 3)));
Object.assign(out, {
rows: rows.length,
mismatch: rows.filter((r) => !r.ok),
lampTotal: rows.reduce((n, r) => n + r.lamps.length, 0),
colors: Object.fromEntries(
["G", "R", "Y"].map((c) => [
c,
rows
.map((r) => r.lamps)
.join("")
.split(c).length - 1,
]),
),
byKind: [...new Set(rows.map((r) => `${r.name.replace(/ \d+$/, "")}=${r.lamps.length}`))],
heightSame: JSON.stringify(withLamps) === JSON.stringify(without),
heights: [...new Set(withLamps)],
textX: [...new Set(xs)],
lampWidth: [...new Set(widths)],
cols,
dot: dot && [dot.width, dot.height],
dotColors: ["is-linked", "is-required", "is-optional"].map((c) => {
const d = document.querySelector(`.b05-route__lamps i.${c}`);
return d ? getComputedStyle(d).backgroundColor : null;
}),
});
}
if (ARGS.mode === "live") {
await select(ROW);
const lamps = () => lampsOf(rowOf(ROW));
const steps = [["start", lamps(), formLinks().join("")]];
const red = linkRows().find(
(r) =>
!r.querySelector(".is-assigned") &&
!r.closest("[data-spec-container]")?.querySelector("[data-spec-remove]"),
);
if (red) {
await pickLink(red);
steps.push(["link", lamps(), formLinks().join("")]);
red.querySelector('[data-b05-summary="clear"]').click();
await w(1500);
steps.push(["clear", lamps(), formLinks().join("")]);
}
const add = [...document.querySelectorAll("[data-spec-add]")].find(visible);
if (add) {
const id = add.dataset.specAdd;
add.click();
await w(1500);
steps.push([`add ${id}`, lamps(), formLinks().join("")]);
// 램프 4 개 → 2 열 — 칸 폭 · 글 시작 x · 줄 높이가 목록 전체에서 하나
const box2 = (sel) => [
...new Set(
items().map(
(li) =>
Math.round(
li.querySelector(sel).getBoundingClientRect()[sel === "strong" ? "left" : "width"] *
10,
) / 10,
),
),
];
out.twoCols = {
lampWidth: box2(".b05-route__lamps"),
textX: box2("strong"),
heights: [
...new Set(items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10)),
],
};
const box = [...document.querySelectorAll(`[data-spec-container="${id}"]`)]
.filter(visible)
.at(-1);
await pickLink(box);
steps.push(["link", lamps(), formLinks().join("")]);
box.querySelector('[data-b05-summary="clear"]').click();
await w(1500);
steps.push(["clear", lamps(), formLinks().join("")]);
[...document.querySelectorAll(`[data-spec-container="${id}"]`)]
.filter(visible)
.at(-1)
.querySelector("[data-spec-remove]")
.click();
await w(1500);
steps.push(["remove", lamps(), formLinks().join("")]);
}
out.steps = steps.map(([s, l, f]) => `${s}: ${l} (폼 ${f})`);
out.back = steps[0][1] === steps.at(-1)[1];
await release();
}
if (ARGS.mode === "link" || ARGS.mode === "unlink") {
await select(ROW);
const target = linkRows().find((r) =>
ARGS.mode === "link" ? !r.querySelector(".is-assigned") : r.querySelector(".is-assigned"),
);
if (ARGS.mode === "link") await pickLink(target);
else {
target.querySelector('[data-b05-summary="clear"]').click();
await w(1500);
}
out.row = lampsOf(rowOf(ROW));
out.saved = await save();
await release();
}
out.sig = sig();
window.__m5415_out = JSON.stringify(out); // 응답이 끊겨도 뒤에 읽음
return window.__m5415_out;
})();
@@ -0,0 +1,83 @@
/* 54-20 — B02~B09 좌측 패널 아래 공통 단추 묶음([?] · [초기화] · [저장] · [확정] · [횡단 보기] …) 치수
* 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트)
* 부르는 법: orca eval --expression "$(cat 이 파일)" · 1분 넘으면 끊기므로
* `window.__btn5420 = ["b05-profile", …];` 를 앞에 붙여 나눠 부름
* 쓰는 것: 없음(주소 이동 · 재기만 · 끝나면 처음 주소로)
* 기대 수치:
* - 줄마다 보이는 단추 h 하나 · fs 하나 · ff 하나 · 줄 안 gap 하나 · wrap = false · over = false
* - [?] = 정사각(w = h)
* - 좁은 패널(narrow = true 로 패널 폭을 최소로 줄여 다시 잼)에서도 wrap · over 없음
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const vis = (n) => n && n.offsetParent !== null;
const start = location.hash;
const routes = window.__btn5420 ?? [
"b02-proj-register",
"b03-file-input",
"b04-preprocess",
"b05-profile",
"b06-section",
"b07-design-detail",
"b08-quantity",
"b09-estimation",
];
const measure = () => {
const help = [...document.querySelectorAll(".ui-help-btn")].find(vis);
const rows = help
? [help.closest(".ui-sidebar-actions") ?? help.parentElement]
: [...document.querySelectorAll(".ui-sidebar-actions")].filter(vis);
return rows.map((row) => {
const btns = [...row.querySelectorAll("button")].filter(vis);
const rr = row.getBoundingClientRect();
return {
row: row.className,
rowW: Math.round(rr.width),
gap: getComputedStyle(help?.parentElement ?? row).gap,
btns: btns.map((b) => {
const s = getComputedStyle(b);
const r = b.getBoundingClientRect();
const text = document.createRange();
text.selectNodeContents(b);
return {
t: b.innerText.trim(),
cls: b.className,
h: +r.height.toFixed(1),
w: +r.width.toFixed(1),
fs: s.fontSize,
fw: s.fontWeight,
ff: s.fontFamily.split(",")[0],
pad: s.padding,
bd: s.borderWidth + " " + s.borderStyle,
rad: s.borderRadius,
top: +(r.top - rr.top).toFixed(1),
wrap: text.getBoundingClientRect().height > parseFloat(s.fontSize) * 1.6,
// 글이 단추 안쪽(여백 뺀 폭)을 넘거나 단추가 줄 밖으로 나감
over:
text.getBoundingClientRect().width >
b.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight) + 0.5 ||
r.right > rr.right + 1,
};
}),
};
});
};
const out = [];
for (const route of routes) {
location.hash = `#/${route}`;
await w(4500);
const res = { page: route.slice(0, 3).toUpperCase(), rows: measure() };
const panel = document.querySelector(".ui-workflow-overlay__panel--title");
if (window.__btn5420narrow && panel) {
const was = panel.style.width;
panel.style.width = "var(--wf-left-panel-narrow-width)"; // 좁은 창(860px 아래) 폭
await w(400);
res.narrowW = Math.round(panel.getBoundingClientRect().width);
res.narrow = measure();
panel.style.width = was;
}
out.push(res);
}
location.hash = start;
return JSON.stringify(out);
})();
@@ -0,0 +1,83 @@
/* 54-22 — 종단 점 ▲ 누름 멈춤(긴 작업) 재기: B06 은 120ms 디바운스 · 종단도 한 번 · B05 는 실시간 그대로
* 시작 화면: B06(#/b06-section) 또는 B05(#/b05-profile) · 검증 프로젝트 · 로그인
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5422 = "단계";` · 긴 단계는 `void` 로 띄우고
* `sessionStorage.getItem("__5422_out_{단계}")` 로 읽음
* snap → 처음 초안(세션 원문)을 `__5422_orig` 에 남김(이미 있으면 그대로)
* one → 가운데 측점 ▲ 한 번 — 긴 작업 최장 · 합 · 횡단 재계산까지 걸린 때
* hold → 가운데 측점 ▲ 2.5초 길게 — 긴 작업 최장 · 합 · 재계산 횟수
* restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload
* 판정: B06 one 최장 ≈200ms 안팎 · hold 합 ≈0.5초 안팎(고치기 전 587 / 2,684ms) ·
* B05 one 재계산 때 = 16ms 디바운스 근처(실시간)
* 쓰는 것: sessionStorage(계획선 초안) — 정본(서버)은 안 씀
*/
(async () => {
const MODE = window.__m5422 || "one";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const DRAFTS = /^aislo:draft:/;
const out = { mode: MODE, page: location.hash };
const save = () => sessionStorage.setItem(`__5422_out_${MODE}`, JSON.stringify(out));
if (MODE === "snap") {
if (!sessionStorage.getItem("__5422_orig")) {
const orig = Object.fromEntries(
Object.keys(sessionStorage)
.filter((k) => DRAFTS.test(k))
.map((k) => [k, sessionStorage.getItem(k)]),
);
sessionStorage.setItem("__5422_orig", JSON.stringify(orig));
}
out.keys = Object.keys(JSON.parse(sessionStorage.getItem("__5422_orig")));
save();
return JSON.stringify(out);
}
if (MODE === "restore") {
const orig = JSON.parse(sessionStorage.getItem("__5422_orig") || "null");
if (!orig) return "no orig";
for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k);
for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v);
for (const k of Object.keys(sessionStorage))
if (k.startsWith("__5422")) sessionStorage.removeItem(k);
return JSON.stringify({ restored: Object.keys(orig) });
}
const ups = [...document.querySelectorAll(".b05-profile-edit__btn.is-up")];
const button = ups[Math.floor(ups.length / 2)];
if (!button) {
out.error = "▲ 없음";
save();
return JSON.stringify(out);
}
out.button = button.title.split("\n")[0];
const tasks = [];
const observer = new PerformanceObserver((list) =>
list.getEntries().forEach((e) => tasks.push(Math.round(e.duration))),
);
observer.observe({ type: "longtask" });
// 횡단 재계산이 카드 · 캐시에 닿은 때 — 계획선 초안 말고 다른 캐시 쓰기
const writes = [];
const t0 = performance.now();
const origSet = Storage.prototype.setItem;
Storage.prototype.setItem = function (k, v) {
if (this === sessionStorage && !k.startsWith("__5422"))
writes.push([Math.round(performance.now() - t0), k.replace(/:[0-9a-f-]{36}.*/, "")]);
return origSet.call(this, k, v);
};
// 화면이 바뀐 때(ms) — 누른 뒤 재계산 반영이 닿는 때를 봄(B05 ≈16ms 디바운스 · B06 ≈120ms)
const domAt = new Set();
const mo = new MutationObserver(() => domAt.add(Math.round(performance.now() - t0)));
mo.observe(document.body, { subtree: true, childList: true, characterData: true });
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1 }));
await w(MODE === "hold" ? 2500 : 60);
window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
await w(2500);
Storage.prototype.setItem = origSet;
observer.disconnect();
mo.disconnect();
out.domAt = [...domAt].slice(0, 12);
out.longTasks = tasks;
out.max = tasks.length ? Math.max(...tasks) : 0;
out.sum = tasks.reduce((a, b) => a + b, 0);
out.writes = writes;
save();
return JSON.stringify(out);
})();
@@ -0,0 +1,242 @@
/* 54-26 — B05 · B06 좌측 「구조물 배치」 사면안정 표 09(흙막이 · 옹벽 · 소단)
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조)
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5426 = "단계";` 를 붙여
* seed → 원래 목록을 sessionStorage 에 두고 흙막이(20+0 · 잇기 하나) · 소단을 심음(PUT) · 그 뒤 orca reload
* check → 세 종류 컨테이너 1(이름) · 칸 = 표대로 · 밖 칸 0 · 잇기 1 · 램프 · 형태 선택지 6/1
* B05 에서 한 번(값을 sessionStorage 에 둠) · B06(#/b06-section) 에서 한 번(B05 = B06)
* split → 흙막이 길이 10 · 전 3 → 후 7(B05 · 실시간 반영 · 저장 안 함)
* height → B06 — 좌측 높이 1.8 · 2.3 → 20+0 횡단 벽 그림 높이(H=) 같은 값
* save → [저장] — 파일 = 형태 · 길이 10 · 전 3 · 후 7 · 높이 2.3 · 그 뒤 orca reload
* verify → 다시 연 흙막이 칸 = 파일 값(B05 · B06 각각)
* restore → 원래 목록으로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 원래 값으로 다시 씀)
*/
(async () => {
const MODE = window.__m5426 || "check";
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 projectId = localStorage.getItem("frd_current_project_id");
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts"));
const LINK = { layer: "system", owner: null, key: "SS000001", name: "54-26 잇기" };
const KINDS = {
soil_guard: ["gsoilgdetail", "흙막이", /흙막이$/, 6], // 심은 것 하나 · 전 3 이면 19+17~20+7
retaining_wall: ["gretaindetail", "옹벽", /^10\+0~10\+10옹벽/, 1],
berm: ["gbermdetail", "상세 내용", /^28\+0~28\+10소단/, 0],
};
const TABLE = {
soil_guard: ["form", "length_m", "height_m", "before_m", "after_m"],
retaining_wall: ["wall_form", "length_m", "height_m", "before_m", "after_m"],
berm: ["width_m", "height_m"],
};
const section = () => document.querySelector(".b05-structure-section");
const selects = () => section().querySelectorAll(".b05-structure__grid select");
const visible = (n) => n.offsetParent !== null;
const listItem = (re) =>
[...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
re.test(l.textContent.trim()),
);
const open = async (re) => {
await until(() => listItem(re));
const li = listItem(re);
(li.querySelector("button") ?? li).click();
await w(800);
if (!selects()[1].value) (li.querySelector("button") ?? li).click(); // 재선택 = 해제 — 한 번 더
await until(() => !!selects()[1].value);
await w(400);
};
const reset = async () => {
section().querySelector(".b05-route__irregular-actions button:last-child").click();
await w(400);
};
const field = (key) =>
section().querySelector(
`[data-option-keys="${key}"] input, [data-option-keys="${key}"] select`,
);
const set = async (key, value) => {
const input = field(key);
input.value = String(value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
await w(500);
};
const shape = (type) => {
const boxes = [...section().querySelectorAll("[data-b05-group]")].filter(visible);
const box = boxes[0];
const form = box?.querySelector(
'[data-option-keys="form"] select, [data-option-keys="wall_form"] select',
);
return {
boxes: boxes.map((b) => `${b.dataset.b05Group}:${b.querySelector("legend")?.textContent}`),
fields: box
? [...box.querySelectorAll("[data-option-keys]")].map((n) => n.dataset.optionKeys)
: [],
loose: [...section().querySelectorAll("[data-option-keys]")].filter(
(n) => !n.closest("[data-b05-group]") && visible(n),
).length,
links: box ? box.querySelectorAll(".b05-structure__summary").length : 0,
twoRow: box ? box.querySelectorAll("[data-b05-summary=row]").length : 0,
link: box?.querySelector(".b05-structure__summary-name")?.value.trim(),
choices: form ? [...form.options].filter((o) => o.value).map((o) => o.textContent) : [],
lamps: [...(listItem(KINDS[type][2])?.querySelectorAll(".b05-route__lamps > *") ?? [])].map(
(d) => d.className,
),
};
};
const ok = (type, s) =>
s.boxes.length === 1 &&
s.boxes[0] === `${KINDS[type][0]}:${KINDS[type][1]}` &&
JSON.stringify(s.fields) === JSON.stringify(TABLE[type]) &&
s.loose === 0 &&
s.links === 1 &&
s.twoRow === 1 &&
s.choices.length === KINDS[type][3] &&
s.lamps.length === 1;
const save = async () => {
document.querySelector('[data-dock-action="save"]').click();
await w(500);
return until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
};
const soilFile = async () =>
(await api.fetchStructures(projectId)).structures.find((s) => s.type_id === "soil_guard");
const out = { mode: MODE, page: location.hash };
if (MODE === "seed") {
const now = await api.fetchStructures(projectId);
sessionStorage.setItem("__5426_original", JSON.stringify(now.structures));
const add = (type_id, at, options, extra = {}) => ({
structure_id: null,
type_id,
placement: "interval",
chainage_m: at,
start_m: at,
end_m: at + 10,
options: { length_m: 10, before_m: 0, after_m: 10, ...options },
memo: "54-26",
...extra,
});
const next = [
...now.structures,
add(
"soil_guard",
400,
{ form: "돌쌓기(찰)", height_m: 1.5 },
{ group_items: { gsoilgdetail: LINK } },
),
add("berm", 560, { width_m: 1, height_m: 5 }),
];
await api.saveStructures(projectId, now.revision, next);
for (const k of Object.keys(sessionStorage))
if (k.includes("draft:structures") || k.includes("result:section-detail"))
sessionStorage.removeItem(k);
out.seeded = (await api.fetchStructures(projectId)).structures
.filter((s) => s.type_id in KINDS)
.map((s) => s.type_id);
}
if (MODE === "check") {
await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0);
const r = {};
for (const [type, [, , re]] of Object.entries(KINDS)) {
await open(re);
r[type] = shape(type);
r[type].ok = ok(type, r[type]);
}
await reset();
out.allOk = Object.values(r).every((s) => s.ok);
out.read = r;
if (!location.hash.includes("b06")) sessionStorage.setItem("__5426_b05", JSON.stringify(r));
else out.sameAsB05 = sessionStorage.getItem("__5426_b05") === JSON.stringify(r);
}
if (MODE === "split") {
await open(KINDS.soil_guard[2]);
await set("length_m", 10);
await set("before_m", 3);
out.after = field("after_m").value;
out.ok = Number(out.after) === 7;
}
if (MODE === "height") {
// 독립 벽 조정창은 위치 전용(8-29 지시) — 높이 = 좌측 칸 하나 · 횡단 그림이 그 값을 따라감
await open(KINDS.soil_guard[2]);
await w(1500);
const card = () =>
[...document.querySelectorAll(".b06-cross-card")].find((c) =>
/^\s*20\+0400\.0m/.test(c.textContent),
);
const drawn = () => {
const tip = card()?.querySelector(".b06-chart__culvert-revet title")?.textContent ?? "";
return Number(tip.match(/H=([\d.]+)m/)?.[1]);
};
const near = (a, b) => Math.abs(a - b) < 0.01;
out.steps = [];
for (const h of [1.8, 2.3]) {
await set("height_m", h);
await until(() => near(drawn(), h), 8000);
out.steps.push({ left: Number(field("height_m").value), drawn: drawn() });
}
out.ok = out.steps.every((s) => near(s.left, s.drawn));
}
if (MODE === "save") {
out.saved = await save();
const soil = await soilFile();
out.file = soil.options;
out.ok =
soil.options.form === "돌쌓기(찰)" &&
soil.options.length_m === 10 &&
soil.options.before_m === 3 &&
soil.options.after_m === 7 &&
soil.options.height_m === 2.3 &&
JSON.stringify(soil.group_items) === JSON.stringify({ gsoilgdetail: LINK });
}
if (MODE === "verify") {
await open(KINDS.soil_guard[2]);
const soil = await soilFile();
out.form = TABLE.soil_guard.map((k) => field(k).value);
out.file = TABLE.soil_guard.map((k) => String(soil.options[k]));
out.ok = out.form.every((v, i) => Number(v) === Number(out.file[i]) || v === out.file[i]);
await reset();
}
if (MODE === "restore") {
const original = JSON.parse(sessionStorage.getItem("__5426_original") || "null");
if (!original) return JSON.stringify({ error: "원래 값 없음" });
const now = await api.fetchStructures(projectId);
await api.saveStructures(projectId, now.revision, original);
for (const k of Object.keys(sessionStorage))
if (
k.includes("draft:structures") ||
k.includes("result:section-detail") ||
k.startsWith("__5426_")
)
sessionStorage.removeItem(k);
const after = await api.fetchStructures(projectId);
const key = (list) =>
JSON.stringify(
list.map((s) => [
s.type_id,
s.start_m,
s.chainage_m,
s.end_m,
s.options,
s.group_items ?? null,
]),
);
out.restored = key(after.structures) === key(original);
}
return JSON.stringify(out);
})();
@@ -0,0 +1,152 @@
/* 54-32 고른 구조물에서 군 바꿈 = 새로 놓기 · 같은 군 종류 바꿈 = 새 종류 기본값 + 종류별 캐시
* 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4) · 로그인
* 부르는 법: 단계마다 `window.__m5432 = "단계";` 뒤 이 파일 본문을 eval
* a (B06) 돌쌓기(찰) 1: 칸 고침 → 다른 종류 → 돌아옴 = 고친 값 · 군 바꿈 = 고름 0
* 4+5 배관 1: 관경 고침 → BOX(칸 고침) → 배관 = 고친 관경 · 군 바꿈 = 고름 0 · 캐시 있음
* b (B05) 배관 → BOX = B06 에서 고친 BOX 값(캐시 공유) → 배관 · 관경 · 돌쌓기 칸 원래 값으로
* c [저장] → 캐시 비움 · 배관 → BOX = BOX 기본 · → 배관 = 저장 관경 · 다시 [저장]
* 결과는 sessionStorage `__5432_out_{단계}` · 기대: fails 0
*/
(async () => {
const MODE = window.__m5432 || "a";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const pid = localStorage.getItem("frd_current_project_id");
const t = (e) => e.innerText.replace(/\s+/g, " ").trim();
const fails = [];
const out = {};
const fail = (m) => fails.push(m);
const sec = () => document.querySelector(".b05-structure-section");
const grid = () => sec().querySelectorAll(".b05-structure__grid select");
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const row = (head) => rows().find((li) => t(li).startsWith(head));
const picked = () => rows().filter((li) => li.classList.contains("is-selected")).length;
const read = (name) => {
const raw = sessionStorage.getItem(`aislo:draft:${name}:${pid}`);
const v = raw ? JSON.parse(raw) : null;
return v?.value ?? v;
};
const pipe = () => (read("pipes") ?? []).find((p) => Math.abs(p.chainage_m - 85.05) < 0.51);
const fire = (el, value) => {
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
};
const pick = async (head) => {
for (let k = 0; k < 40 && !row(head); k++) await w(300);
if (!row(head)?.classList.contains("is-selected")) row(head)?.click();
await w(2000);
};
const setType = async (id) => (fire(grid()[1], id), await w(2500));
const setGroup = async (g) => (fire(grid()[0], g), await w(2000));
/** 종류 칸 아래 첫 고를 수 있는 선택 칸(관경 · BOX 폭 등) */
const firstSelect = () =>
[...sec().querySelectorAll("select")]
.slice(2)
.find(
(s) => s.offsetParent && !s.disabled && [...s.options].filter((o) => o.value).length > 1,
);
const other = (s) => [...s.options].map((o) => o.value).find((v) => v && v !== s.value);
const firstNumber = () =>
[...sec().querySelectorAll("input")].find(
(i) =>
i.offsetParent &&
!i.disabled &&
i.value !== "" &&
!isNaN(Number(i.value)) &&
!/측점/.test(t(i.closest("label") ?? i)),
);
const S = "4+0~4+10 돌쌓기(찰)";
const P = "4+5 배관";
const ck = () => sessionStorage.getItem(`aislo:draft:structure-type-cache:${pid}`);
const memo = JSON.parse(sessionStorage.getItem("__5432_memo") || "{}");
if (MODE === "a") {
await pick(S);
const t0 = grid()[1].value;
const num = firstNumber();
memo.sLabel = t(num.closest("label"));
memo.s0 = num.value;
const s1 = String(Number(num.value) + 0.5);
fire(num, s1);
await w(1200);
const t1 = [...grid()[1].options].map((o) => o.value).find((v) => v && v !== t0);
await setType(t1);
out.afterSwitch = {
type: grid()[1].value,
picked: picked(),
primary: t(sec().querySelector("button")),
};
if (picked() !== 1) fail(`종류 바꿈 뒤 고름 ${picked()}`);
await setType(t0);
const back = firstNumber();
out.structBack = back?.value;
if (Number(back?.value) !== Number(s1)) fail(`돌쌓기 되살림 ${back?.value} ≠ ${s1}`);
fire(back, memo.s0);
await w(1200);
await setGroup("B");
out.structGroup = { picked: picked(), type: grid()[1].value, row: !!row(S) };
if (picked() !== 0 || grid()[1].value !== "")
fail(`돌쌓기 군 바꿈 ${JSON.stringify(out.structGroup)}`);
await pick(P);
const d = firstSelect();
memo.p0 = d.value;
memo.p1 = other(d);
fire(d, memo.p1);
await w(2500);
await setType("box_culvert");
out.boxType = pipe()?.facility;
if (pipe()?.facility !== "box_culvert") fail(`BOX 초안 ${pipe()?.facility}`);
const b = firstSelect();
memo.bDefault = b.value;
memo.b1 = other(b);
fire(b, memo.b1);
await w(2500);
await setType("pipe");
out.pipeBack = firstSelect()?.value;
if (firstSelect()?.value !== memo.p1) fail(`배관 되살림 ${firstSelect()?.value} ≠ ${memo.p1}`);
await setGroup("C");
out.pipeGroup = { picked: picked(), type: grid()[1].value, facility: pipe()?.facility };
if (picked() !== 0 || pipe()?.facility !== "pipe")
fail(`배관 군 바꿈 ${JSON.stringify(out.pipeGroup)}`);
out.cache = !!ck();
if (!ck()) fail("캐시 없음");
}
if (MODE === "b") {
await pick(P);
await setType("box_culvert");
out.boxFromCache = firstSelect()?.value;
if (firstSelect()?.value !== memo.b1) fail(`B05 BOX 캐시 ${firstSelect()?.value} ≠ ${memo.b1}`);
await setType("pipe");
out.pipeFromCache = firstSelect()?.value;
if (firstSelect()?.value !== memo.p1)
fail(`B05 배관 캐시 ${firstSelect()?.value} ≠ ${memo.p1}`);
fire(firstSelect(), memo.p0);
await w(2500);
out.pipeRestored = pipe()?.options?.diameter_mm ?? firstSelect()?.value;
}
if (MODE === "c") {
const save = async () => {
[...document.querySelectorAll("button")].find((x) => t(x) === "저장").click();
await w(15000);
};
await save();
out.cacheAfterSave = ck();
if (ck()) fail("저장 뒤 캐시 남음");
await pick(P);
await setType("box_culvert");
out.boxAfterSave = firstSelect()?.value;
if (firstSelect()?.value !== memo.bDefault)
fail(`저장 뒤 BOX ${firstSelect()?.value} ≠ 기본 ${memo.bDefault}`);
await setType("pipe");
out.pipeAfterSave = firstSelect()?.value;
if (firstSelect()?.value !== memo.p0) fail(`저장 뒤 배관 ${firstSelect()?.value} ≠ ${memo.p0}`);
await save();
out.cacheEnd = ck();
}
sessionStorage.setItem("__5432_memo", JSON.stringify(memo));
sessionStorage.setItem(`__5432_out_${MODE}`, JSON.stringify({ fails, out, memo }));
})();
@@ -0,0 +1,80 @@
/* 54-38 고른 구조물에서 군 바꿈 = 횡단 강조(카드 · 벽 · 부재 · 조정 칸)도 풂
* 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4) · 로그인
* 부르는 법: 단계마다 `window.__m5438 = "단계";` 뒤 이 파일 본문을 eval
* a (B06) 배관 1 → 유입 벽 고름 → 군 바꿈 = 강조 0 · 돌쌓기(찰) 1 → 군 바꿈 = 강조 0
* b (B06) 배관 1 → 벽 고름 → B05 로 → 군 바꿈 → B06 로 = 강조 0 · 선택 칸 비어 있음
* 결과는 sessionStorage `__5438_out_{단계}` · 기대: fails 0
*/
(async () => {
const MODE = window.__m5438 || "a";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const pid = localStorage.getItem("frd_current_project_id");
const t = (e) => e.innerText.replace(/\s+/g, " ").trim();
const fails = [];
const out = {};
const fail = (m) => fails.push(m);
const sec = () => document.querySelector(".b05-structure-section");
const grid = () => sec().querySelectorAll(".b05-structure__grid select");
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const row = (head) => rows().find((li) => t(li).startsWith(head));
const fire = (el, value) => {
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
};
const pick = async (head) => {
for (let k = 0; k < 40 && !row(head); k++) await w(300);
if (!row(head)?.classList.contains("is-selected")) row(head)?.click();
await w(2000);
};
const setGroup = async (g) => (fire(grid()[0], g), await w(2000));
/** 횡단 강조 수 — 고른 카드 · 그림 부재 · 조정 칸 */
const lit = () => ({
card: document.querySelectorAll(".b06-cross-card--selected").length,
part: document.querySelectorAll(".b06-cross-card svg .is-active").length,
box: document.querySelectorAll("fieldset[data-spec-container].is-active").length,
});
const total = (o) => o.card + o.part + o.box;
/** 고른 카드의 유입 벽을 누름 — 횡단에서 벽을 고른 길 */
const pickWall = async () => {
const shape = document.querySelector(".b06-cross-card--selected .b06-chart__culvert-revet-hit");
shape?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(1500);
return !!shape;
};
const go = async (hash) => ((location.hash = hash), await w(5000));
const pickState = () => sessionStorage.getItem(`aislo:draft:structure-pick:${pid}`);
if (MODE === "a") {
await pick("4+5 배관 1");
out.wallClicked = await pickWall();
out.pipeBefore = lit();
if (total(out.pipeBefore) === 0) fail("배관 고름 강조 없음");
await setGroup("C");
out.pipeAfter = lit();
if (total(out.pipeAfter) !== 0) fail(`배관 군 바꿈 강조 ${JSON.stringify(out.pipeAfter)}`);
await pick("4+0~4+10 돌쌓기(찰) 1");
out.structBefore = lit();
if (total(out.structBefore) === 0) fail("돌쌓기 고름 강조 없음");
await setGroup("B");
out.structAfter = lit();
if (total(out.structAfter) !== 0)
fail(`돌쌓기 군 바꿈 강조 ${JSON.stringify(out.structAfter)}`);
out.pick = pickState();
}
if (MODE === "b") {
await pick("4+5 배관 1");
out.wallClicked = await pickWall();
out.before = lit();
await go("#/b05-profile");
await pick("4+5 배관 1");
await setGroup("C");
out.pick = pickState();
await go("#/b06-section");
out.after = lit();
if (total(out.after) !== 0) fail(`B05 군 바꿈 뒤 B06 강조 ${JSON.stringify(out.after)}`);
}
sessionStorage.setItem(`__5438_out_${MODE}`, JSON.stringify({ fails, out }));
})();
@@ -0,0 +1,48 @@
/* 54-43 — 구조물 배치 상자: 빈 칸 = 그냥 빈 칸 · 필수 칸 빈 보기 없음 · 빈 옛 값 = 기본값
* 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계(#/b06-section) · 검증 프로젝트(sub5_검증) · 로그인
* 부르는 법: 한 번 — 결과 `sessionStorage.getItem("__5443_out")`
* 목록의 구조물 전부를 차례로 열어 「구조물 배치」 상자 글 · 보기 글 · 자리표시 글을 셈
* 쓰는 것: 없음(열기만 · 저장 없음)
* 기대 수치: words = 0 · requiredBlank = 0 · blankText = 0 · requiredEmpty = 0 · optionalBlank > 0(선택 칸 빈 보기 그대로)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const WORDS = /선택 안 함|안 정함|비우면|기본/;
const OPTIONAL = new Set(["soil_add", "chute_add", "chute"]);
const out = { rows: 0, words: [], requiredBlank: [], blankText: [], requiredEmpty: [], optionalBlank: 0, seen: {} };
for (let i = 0; i < 50 && !document.querySelector(".b05-route__irregular-list li"); i++) await w(200);
const rows = [...document.querySelectorAll(".b05-route__irregular-list li")];
for (const row of rows) {
const head = txt(row).slice(0, 24);
row.click();
await w(1200);
const panel = document.querySelector(".b05-structure-section");
if (!panel) continue;
out.rows += 1;
const shown = (el) => !!el.offsetParent;
// 보이는 글 · 고르기 보기 글 · 자리표시 글
const texts = [
txt(panel),
...[...panel.querySelectorAll("select")].filter(shown).flatMap((s) => [...s.options].map((o) => o.text)),
...[...panel.querySelectorAll("input")].filter(shown).map((i) => i.placeholder),
];
for (const t of texts) {
const hit = t.match(WORDS);
if (hit) out.words.push(`${head}: …${t.slice(Math.max(0, hit.index - 12), hit.index + 14)}…`);
}
for (const sel of [...panel.querySelectorAll("select")].filter(shown)) {
const wrap = sel.closest("[data-spec-item]");
const box = sel.closest("[data-spec-container]");
const blank = [...sel.options].find((o) => o.value === "");
const name = wrap?.dataset.specItem ?? sel.closest("[data-option-keys]")?.dataset.optionKeys ?? "?";
out.seen[name] = (out.seen[name] ?? 0) + 1;
if (blank && blank.text !== "" && !wrap?.dataset.specShown) out.blankText.push(`${head}: ${name} 「${blank.text}」`);
if (box && !OPTIONAL.has(box.dataset.specContainer)) {
if (blank && !wrap?.dataset.specShown) out.requiredBlank.push(`${head}: ${name}`);
if (!sel.value && !wrap?.dataset.specShown) out.requiredEmpty.push(`${head}: ${name}`);
} else if (blank) out.optionalBlank += 1;
}
}
sessionStorage.setItem("__5443_out", JSON.stringify(out));
})();