feat(design): 노출형 횡단수로 · 개거(겉도랑) = 기준 측점 + 종류 단위 잇기만(표 05 · 52-5)

- 개거 옛 옵션(규격 · 연장)을 정의에서 뺌 — 저장된 값은 지난 정의 값으로 남음 · 정의 판 7 · 이력 판_6
- 두 종류 모두 컨테이너 0 · 옵션 0 · 기준 측점 바로 아래 구조물 조합 잇기 1(52-1 공용 틀)
- 시험: 레지스트리 정책에 옵션 없음 한 줄 · ui_checks/52-5_횡단수로_개거 (B05 · B06 같음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-05 23:33:24 +09:00
co-authored by Claude Sonnet 5.5
parent d9bcb0cb5c
commit 9e4b4abdfb
4 changed files with 3520 additions and 25 deletions
+2 -25
View File
@@ -1,5 +1,5 @@
{
"판": 6,
"판": 7,
"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`가 새 키로 옮긴다.",
"구조물군": {
@@ -676,30 +676,7 @@
"cross_section",
"quantity"
],
"options": [
{
"key": "ditch_spec",
"label": "규격",
"input": "select",
"choices": [
"콘크리트 개거 150×200",
"L형수로 H=0.2"
],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
},
{
"key": "length_m",
"label": "연장",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
}
]
"options": []
},
{
"type_id": "ditch_side",
File diff suppressed because it is too large Load Diff
@@ -366,6 +366,7 @@ def test_a6_a7_types_exist_for_manual_placement():
item = by_id[type_id]
assert item.group == "A" and item.managed_by is None
assert item.placement == "point"
assert not item.options, f"{type_id} 옵션 없음 — 기준 측점 + 종류 단위 잇기만(PLAN 52-5)"
def test_ford_bridge_shares_pipe_kind_and_diameter():
@@ -0,0 +1,158 @@
/* 52-5 — B05 · B06 좌측 「구조물 배치」 노출형 횡단수로 · 개거(겉도랑) = 사용자 구조물 표 05
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) · 폼 비어 있음 · 로그인 상태
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m525 = "단계";` 를 붙여
* orca eval --expression "window.__m525='edit'; $(cat 이 파일)" → 두 종류 모양 · 추가 · 잇기 · [저장]
* (orca reload 로 다시 열기)
* orca eval --expression "window.__m525='verify'; $(cat 이 파일)" → 저장 값 그대로 · 다시 고름
* (B06 으로 옮겨 — 같은 모양)
* orca eval --expression "window.__m525='verify'; $(cat 이 파일)" → B06 에서도 같은 값 · 같은 모양
* orca eval --expression "window.__m525='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장]
* 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움)
* 기대 수치:
* shape(종류마다) — optionInputs 0 · containers 0 · summaryRows 1 · summaryVisible true · summaryRightAfterPosition true ·
* anchor true · stale false
* edit — added 둘 · 잇기 이름 값 있음 · 저장 뒤 목록 2 늘어남
* verify — 같은 모양 · 잇기 이름 그대로 · 옵션 값 없음
*/
(async () => {
const MODE = window.__m525 || "edit";
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 vis = (n) => !!n && n.offsetParent !== null;
const sec = () => document.querySelector(".b05-structure-section");
const body = () => sec().querySelector(".b05-route__panel-body");
const selects = () => sec().querySelectorAll(".b05-structure__grid select");
const fire = (el, value) => {
el.value = value;
el.dispatchEvent(new Event("change", { bubbles: true }));
};
const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const NAMES = { cross_drain_exposed: "노출형 횡단수로", open_ditch: "개거(겉도랑)" };
const AT = { cross_drain_exposed: [2, 7], open_ditch: [6, 3] };
const shape = (typeId) => {
const kids = [...body().children];
const position = kids.find((c) => c.classList.contains("b05-structure__position-row"));
const summary = kids.find((c) => c.classList.contains("b05-structure__summary"));
const optionRow = kids.filter((c) => c.classList.contains("b05-structure__grid"))[1];
const after = kids.slice(kids.indexOf(position) + 1).find((c) => vis(c));
const anchorWrap = position.querySelectorAll(":scope > label")[2];
return {
typeId,
optionInputs: vis(optionRow) ? optionRow.querySelectorAll("input, select").length : 0,
containers: [...sec().querySelectorAll("[data-spec-container]")].filter(vis).length,
summaryRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length,
summaryVisible: vis(summary),
summaryRightAfterPosition: after === summary,
anchor: vis(anchorWrap),
stale: vis(sec().querySelector(".b05-structure__stale")),
linked: summary.querySelector(".b05-structure__summary-name")?.textContent ?? "",
};
};
const pickType = async (typeId) => {
const [group, type] = selects();
fire(group, "A");
await w(300);
fire(type, typeId);
await w(500);
};
const linkFirstLeaf = async () => {
sec().querySelector("[data-b05-summary=pick]").click();
await until(() => document.querySelector(".ui-item-picker"));
await w(600);
const modal = document.querySelector(".ui-item-picker");
// 끝 항목 = ▸ · ▾ 로 안 시작하고 모달 조작 단추가 아닌 첫 단추
const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"];
const leaf = [...modal.querySelectorAll("button")].find((b) => {
const t = b.textContent.trim();
return t && !/^[▸▾]/.test(t) && !ctl.includes(t);
});
leaf?.click();
await w(300);
[...modal.querySelectorAll("button")].find((b) => b.textContent.trim() === "적용")?.click();
await w(500);
return !!leaf;
};
const clickBtn = async (text) => {
[...document.querySelectorAll("button")]
.find((b) => b.textContent.trim() === text && vis(b))
?.click();
await w(1500);
};
const out = { mode: MODE, items: [] };
if (MODE === "edit") {
await until(() => listItems().length > 0);
out.before = listItems().length;
for (const typeId of Object.keys(NAMES)) {
await pickType(typeId);
const row = { ...shape(typeId), phase: "edit" };
const [no, dist] = sec()
.querySelectorAll(".b05-structure__position-row > label")[2]
.querySelectorAll("input");
fire(no, String(AT[typeId][0]));
fire(dist, String(AT[typeId][1]));
await w(200);
row.picked = await linkFirstLeaf();
row.linked = shape(typeId).linked;
sec().querySelector(".is-primary").click(); // 추가
await w(800);
out.items.push(row);
}
out.after = listItems().length;
await clickBtn("저장");
out.listed = listItems()
.map((l) => l.textContent.trim())
.filter((t) => /횡단수로|개거/.test(t));
} else if (MODE === "verify") {
await until(() => listItems().length > 0);
for (const typeId of Object.keys(NAMES)) {
const li = listItems().find((l) => l.textContent.includes(NAMES[typeId]));
if (!li) {
out.items.push({ typeId, missing: true });
continue;
}
(li.querySelector("button") ?? li).click();
await w(800);
// B06 은 다른 항목이 골라져 있으면 첫 눌림이 풀기만 함 — 안 불러와졌으면 한 번 더
if (selects()[1].value !== typeId) {
(li.querySelector("button") ?? li).click();
await w(800);
}
out.items.push({ ...shape(typeId), phase: "verify", title: li.title || "" });
(li.querySelector("button") ?? li).click(); // 재선택 = 해제
await w(300);
}
} else if (MODE === "restore") {
await until(() => listItems().length > 0);
for (const typeId of Object.keys(NAMES)) {
const li = listItems().find((l) => l.textContent.includes(NAMES[typeId]));
if (!li) continue;
(li.querySelector("button") ?? li).click();
await w(600);
await clickBtn("삭제");
}
await clickBtn("저장");
out.left = listItems().length;
}
const rows = out.items.filter((r) => !r.missing);
out.shapeOk =
rows.length === 2 &&
rows.every(
(r) =>
r.optionInputs === 0 &&
r.containers === 0 &&
r.summaryRows === 1 &&
r.summaryVisible &&
r.summaryRightAfterPosition &&
r.anchor &&
!r.stale,
);
return JSON.stringify(out);
})();