Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
@@ -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"] == "도수로"
|
||||
|
||||
|
||||
|
||||
@@ -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_라벨은_단추층_위로_비켜_뜬다():
|
||||
|
||||
@@ -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 컨테이너 이름
|
||||
|
||||
@@ -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_알약_레인이_글을_한_곳에서_받는다():
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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")):
|
||||
|
||||
@@ -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"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"]
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
Reference in New Issue
Block a user