Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"판": 7,
|
||||
"판": 8,
|
||||
"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`가 새 키로 옮긴다.",
|
||||
"구조물군": {
|
||||
@@ -678,6 +678,42 @@
|
||||
],
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"type_id": "cross_underdrain",
|
||||
"group": "A",
|
||||
"name": "맹암거",
|
||||
"placement": "point",
|
||||
"style": {
|
||||
"color": "#3aa0a8",
|
||||
"abbr": "횡맹"
|
||||
},
|
||||
"drawing_views": [
|
||||
"plan",
|
||||
"profile",
|
||||
"cross_section",
|
||||
"quantity"
|
||||
],
|
||||
"options": [
|
||||
{
|
||||
"key": "culvert_height_m",
|
||||
"label": "암거 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"required": false,
|
||||
"empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)"
|
||||
},
|
||||
{
|
||||
"key": "culvert_slope_pct",
|
||||
"label": "암거 경사",
|
||||
"input": "number",
|
||||
"unit": "%",
|
||||
"default": null,
|
||||
"required": false,
|
||||
"empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "ditch_side",
|
||||
"enabled": false,
|
||||
@@ -3329,6 +3365,17 @@
|
||||
"옵션": [],
|
||||
"형상": {}
|
||||
}
|
||||
],
|
||||
"cross_underdrain": [
|
||||
{
|
||||
"id": "gcrossud",
|
||||
"이름": "맹암거",
|
||||
"옵션": [
|
||||
"culvert_height_m",
|
||||
"culvert_slope_pct"
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"배치": {},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""52-6 횡단배수 › 맹암거(표 06 · 새 종류) — 정의 · 컨테이너 · 서버 저장 왕복.
|
||||
|
||||
새 키 `cross_underdrain`(횡단배수 · 점형) — 종단배수 맹암거(`underdrain`)와 따로.
|
||||
컨테이너 = 정의 그룹 「맹암거」(필수 · 늘 한 벌) — 암거 높이(m) · 암거 경사(%) 손 입력(비워 둘 수 있음) ·
|
||||
그룹 잇기(`group_items`)로 구조물 조합 항목. 저장은 임시 storage 에서만.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureInstance,
|
||||
is_station_planting_type,
|
||||
load_definition,
|
||||
structure_type_map,
|
||||
)
|
||||
|
||||
KEY = "cross_underdrain"
|
||||
GROUP = "gcrossud"
|
||||
|
||||
|
||||
def test_새_종류는_횡단배수_점형_종단배수_맹암거와_따로():
|
||||
types = structure_type_map()
|
||||
new, old = types[KEY], types["underdrain"]
|
||||
assert (new.group, new.placement, new.name, new.enabled) == ("A", "point", "맹암거", True)
|
||||
assert (old.group, old.placement) == ("B", "interval")
|
||||
assert new.style["abbr"] != old.style["abbr"] and new.style["color"] != old.style["color"]
|
||||
assert is_station_planting_type(new) # 횡단배수 = 그 측점에 횡단도
|
||||
|
||||
|
||||
def test_컨테이너_맹암거_높이_경사_손_입력():
|
||||
opts = {o.key: o for o in structure_type_map()[KEY].options}
|
||||
assert list(opts) == ["culvert_height_m", "culvert_slope_pct"]
|
||||
for key, label, unit in (
|
||||
("culvert_height_m", "암거 높이", "m"),
|
||||
("culvert_slope_pct", "암거 경사", "%"),
|
||||
):
|
||||
o = opts[key]
|
||||
assert (o.label, o.input, o.unit, o.default, o.required) == (
|
||||
label,
|
||||
"number",
|
||||
unit,
|
||||
None,
|
||||
False,
|
||||
)
|
||||
assert o.empty_means # 손 입력 · 기본값 근거 없음 — 비워 둘 수 있음(B05 단계 필수 금지)
|
||||
groups = load_definition()["그룹"][KEY]
|
||||
assert groups == [
|
||||
{"id": GROUP, "이름": "맹암거", "옵션": list(opts), "형상": {}},
|
||||
]
|
||||
|
||||
|
||||
def test_정의_검사_통과():
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
|
||||
assert definition.check(load_definition()) == []
|
||||
|
||||
|
||||
def _item(**options):
|
||||
return StructureInstance(
|
||||
type_id=KEY,
|
||||
placement="point",
|
||||
chainage_m=63.5,
|
||||
options=options,
|
||||
group_items={
|
||||
GROUP: {"layer": "system", "owner": None, "key": "SS000001", "name": "맹암거"}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_저장_다시_읽기_그대로(tmp_path):
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
|
||||
|
||||
item = _item(culvert_height_m=0.6, culvert_slope_pct=2.5)
|
||||
save_structures(str(tmp_path), [item], base_revision=0)
|
||||
_, loaded = load_structures(str(tmp_path))
|
||||
assert [(s.type_id, s.chainage_m, s.options) for s in loaded] == [
|
||||
(KEY, 63.5, {"culvert_height_m": 0.6, "culvert_slope_pct": 2.5})
|
||||
]
|
||||
assert loaded[0].group_items == item.group_items
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"options",
|
||||
[{"culvert_height_m": -1}, {"culvert_slope_pct": -0.5}],
|
||||
)
|
||||
def test_음수는_서버가_막음(tmp_path, options):
|
||||
from B05_Profile.B05_Profile_Structures_Repository import save_structures
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
save_structures(str(tmp_path), [_item(**options)], base_revision=0)
|
||||
@@ -74,7 +74,7 @@ def test_정의_덧칸_얹기(out):
|
||||
|
||||
|
||||
def test_판1_정의는_지금_화면과_같음(out):
|
||||
assert out["v1Types"] == 37
|
||||
assert out["v1Types"] == 38 # 52-6 횡단배수 맹암거 더함
|
||||
assert out["v1Same"] is True
|
||||
assert out["v1Columns"] == [2]
|
||||
assert out["v1Anchor"] is True
|
||||
|
||||
@@ -189,7 +189,7 @@ def test_quick_add_types_exclude_pipe_family():
|
||||
|
||||
|
||||
def test_all_enabled_non_managed_types_are_quick_addable():
|
||||
"""잔존 B05 선택지 = A군 2(노출형·개거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1.
|
||||
"""잔존 B05 선택지 = A군 3(노출형·개거·맹암거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1.
|
||||
|
||||
콘크리트 포장은 2026-08-28 사용자 지시로 되살렸다 — 포장 구간을 기준측점+길이+전/후로
|
||||
받는 정본이 됐다(종단경사 자동 포장 적용을 대체).
|
||||
@@ -202,7 +202,7 @@ def test_all_enabled_non_managed_types_are_quick_addable():
|
||||
그래서 C군 구간형 시설과 같은 자리에 선다. (측구쪽 `ditch_berm` 은 우클릭 추가 대상이
|
||||
아니라 여기 수에 안 잡힌다.)
|
||||
"""
|
||||
assert len(_quick_add_types()) == 23
|
||||
assert len(_quick_add_types()) == 24
|
||||
|
||||
|
||||
def test_removed_types_are_absent():
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/* 52-6 — 횡단배수 › 맹암거(표 06 · 새 종류 `cross_underdrain`) · B05 · B06 「구조물 배치」
|
||||
* 시작 화면: B05 종단설계(#/b05-profile) · 아무것도 안 고름 · 측점 3+7 이 비어 있음
|
||||
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m526 = "단계";` 를 붙여
|
||||
* orca eval --expression "window.__m526='edit'; $(cat 이 파일)" → 종류 목록 · 컨테이너 · 값 · 잇기 · [추가] · 다시 열기
|
||||
* orca eval --expression "window.__m526='b06'; $(cat 이 파일)" → B06 에서 같은 항목 다시 열기 · B05 와 같은 그리기
|
||||
* orca eval --expression "window.__m526='save'; $(cat 이 파일)" → [저장] 요청 가로챔(몸통만 봄)
|
||||
* orca eval --expression "window.__m526='restore'; $(cat 이 파일)" → sessionStorage 를 처음 그대로 · 뒤에 orca reload
|
||||
* 쓰는 것: 없음 — [저장] 은 fetch 를 가로채 서버로 안 보냄(공유 storage) · 서버 저장 왕복은 test_52_6_cross_underdrain
|
||||
* 기대 수치(edit): b05Types 에 cross_underdrain:맹암거 · underdrain 없음 · box 맹암거 · keys 높이 · 경사 ·
|
||||
* pending 높이 0.6 · 경사 2.5 · 잇기 gcrossud 지정 · reopened 같음
|
||||
* 기대 수치(b06): b06Types A 에 맹암거 · B 에 종단배수 맹암거 따로 · sameDrawing · 값 같음
|
||||
* 기대 수치(save): saved = PUT 몸통에 같은 값(67 m · 0.6 · 2.5 · gcrossud)
|
||||
*/
|
||||
(async () => {
|
||||
const MODE = window.__m526 || "edit";
|
||||
const KEY = "cross_underdrain";
|
||||
const GROUP = "gcrossud";
|
||||
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 vis = (n) => n && n.offsetParent !== null;
|
||||
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 sec = () => document.querySelector(".b05-structure-section");
|
||||
const sels = () => sec().querySelectorAll(".b05-structure__grid select");
|
||||
const choose = async (sel, value) => {
|
||||
sel.value = value;
|
||||
sel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await w(300);
|
||||
};
|
||||
const typesOf = async (group) => {
|
||||
await choose(sels()[0], group);
|
||||
return [...sels()[1].options].filter((o) => o.value).map((o) => `${o.value}:${o.text}`);
|
||||
};
|
||||
const box = () => sec().querySelector(`[data-b05-group="${GROUP}"]`);
|
||||
const input = (k) => box()?.querySelector(`[data-option-keys="${k}"] input`);
|
||||
const shape = () => {
|
||||
const b = box();
|
||||
return {
|
||||
legend: b?.querySelector("legend").textContent,
|
||||
keys: [...(b?.querySelectorAll("[data-option-keys]") ?? [])].map(
|
||||
(n) => `${n.dataset.optionKeys}|${n.querySelector("span").textContent}`,
|
||||
),
|
||||
values: ["culvert_height_m", "culvert_slope_pct"].map((k) => input(k)?.value),
|
||||
link: b?.querySelector(".b05-structure__summary-name").textContent,
|
||||
linked: b?.querySelector(".b05-structure__summary-name").classList.contains("is-assigned"),
|
||||
};
|
||||
};
|
||||
const item = () =>
|
||||
[...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
|
||||
/^3\+7.*맹암거/.test(l.textContent.trim()),
|
||||
);
|
||||
const openItem = async () => {
|
||||
await until(() => item());
|
||||
const li = item();
|
||||
(li.querySelector("button") ?? li).click();
|
||||
await w(600);
|
||||
// 이미 고른 채로 열렸으면 누르면 풀림 — 풀렸으면 한 번 더(재선택 = 해제)
|
||||
if (sels()[1].value !== KEY) (li.querySelector("button") ?? li).click();
|
||||
await until(() => vis(box()));
|
||||
await w(300);
|
||||
};
|
||||
const pickLink = async () => {
|
||||
box().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(500);
|
||||
};
|
||||
const pendingMine = () =>
|
||||
(api.readPendingStructures(projectId) ?? []).filter((s) => s.type_id === KEY);
|
||||
const out = { mode: MODE };
|
||||
|
||||
if (MODE === "edit") {
|
||||
sessionStorage.setItem("__526_session", JSON.stringify({ ...sessionStorage }));
|
||||
out.b05Types = await typesOf("A");
|
||||
out.b05HasLongUnderdrain = (await typesOf("B")).some((t) => t.startsWith("underdrain:"));
|
||||
await typesOf("A");
|
||||
await choose(sels()[1], KEY);
|
||||
out.boxBeforeStation = { shown: vis(box()), locked: !!box()?.querySelector(".is-locked") };
|
||||
const pos = [...sec().querySelectorAll(".b05-structure__position-row input")].filter(vis);
|
||||
for (const [el, v] of [
|
||||
[pos[0], "3"],
|
||||
[pos[1], "7"],
|
||||
]) {
|
||||
el.value = v;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
await w(300);
|
||||
for (const [k, v] of [
|
||||
["culvert_height_m", "0.6"],
|
||||
["culvert_slope_pct", "2.5"],
|
||||
]) {
|
||||
input(k).value = v;
|
||||
input(k).dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input(k).dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
await w(200);
|
||||
await pickLink();
|
||||
out.form = shape();
|
||||
sec().querySelector(".is-primary").click(); // [추가]
|
||||
await w(800);
|
||||
const mine = pendingMine();
|
||||
out.pending = mine.map((s) => ({
|
||||
chainage_m: s.chainage_m,
|
||||
options: s.options,
|
||||
groups: Object.keys(s.group_items ?? {}),
|
||||
}));
|
||||
// 종단 알약 — 약자 · 색이 종단배수 맹암거(맹암거 · #1f7fb8)와 따로
|
||||
const types = await api.fetchStructureTypes(true);
|
||||
const ids = new Set(mine.map((s) => s.structure_id));
|
||||
if (!document.querySelector(".b05-structure__mark")) {
|
||||
[...document.querySelectorAll("button")]
|
||||
.find((b) => b.textContent.trim() === "▲종단")
|
||||
?.click();
|
||||
await until(() => document.querySelector(".b05-structure__mark"), 8000);
|
||||
}
|
||||
out.marks = [...document.querySelectorAll(".b05-structure__mark")]
|
||||
.filter((m) => ids.has(m.dataset.structureId))
|
||||
.map((m) => `${m.textContent}|${m.style.color}`);
|
||||
out.longStyle = types.find((t) => t.type_id === "underdrain")?.style;
|
||||
// 다시 열기 — 폼을 비우고 목록에서 다시 고름
|
||||
const li = item();
|
||||
(li.querySelector("button") ?? li).click(); // 재선택 = 해제
|
||||
await w(400);
|
||||
await openItem();
|
||||
out.reopened = shape();
|
||||
out.reopenSame = JSON.stringify(out.reopened) === JSON.stringify(out.form);
|
||||
}
|
||||
|
||||
if (MODE === "save") {
|
||||
// [저장] — 요청을 가로채 몸통만 봄(서버 · storage 안 감) · 뒤에 화면이 서버 목록을 다시 읽어 항목이 사라짐
|
||||
const real = window.fetch;
|
||||
const sent = [];
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const method = (init.method || "GET").toUpperCase();
|
||||
if (method !== "GET" && String(url).includes(`/projects/${projectId}/`)) {
|
||||
sent.push({ url: String(url), method, body: init.body ? String(init.body) : "" });
|
||||
return new Response(JSON.stringify({ status: "success", revision: 0, count: 0 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return real(url, init);
|
||||
};
|
||||
try {
|
||||
document.querySelector('[data-dock-action="save"]').click();
|
||||
await w(3000);
|
||||
await until(() => !document.querySelector(".ui-overlay.is-active"), 20000);
|
||||
} finally {
|
||||
window.fetch = real;
|
||||
}
|
||||
const put = sent.find((r) => r.url.includes("/route/structures") && r.method === "PUT");
|
||||
const body = put ? JSON.parse(put.body) : null;
|
||||
const saved = body?.structures.filter((s) => s.type_id === KEY) ?? [];
|
||||
out.saved = saved.map((s) => ({
|
||||
chainage_m: s.chainage_m,
|
||||
options: s.options,
|
||||
groups: Object.keys(s.group_items ?? {}),
|
||||
}));
|
||||
out.sent = sent.map((r) => `${r.method} ${r.url.split("/api/")[1] ?? r.url}`);
|
||||
}
|
||||
|
||||
if (MODE === "b06") {
|
||||
await openItem();
|
||||
const b05 = shape();
|
||||
location.hash = "#/b06-section";
|
||||
await until(() => location.hash.includes("b06"), 5000);
|
||||
await w(6000);
|
||||
await openItem();
|
||||
const b06 = shape();
|
||||
out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06);
|
||||
out.b06 = b06;
|
||||
sels()[1].value = "";
|
||||
const li = item();
|
||||
(li.querySelector("button") ?? li).click(); // 고름 풂
|
||||
await w(300);
|
||||
out.b06TypesA = await typesOf("A");
|
||||
out.b06TypesB = await typesOf("B");
|
||||
}
|
||||
|
||||
if (MODE === "restore") {
|
||||
const raw = sessionStorage.getItem("__526_session");
|
||||
if (!raw) return JSON.stringify({ error: "처음 sessionStorage 없음" });
|
||||
const before = JSON.parse(raw);
|
||||
sessionStorage.clear();
|
||||
for (const [k, v] of Object.entries(before)) sessionStorage.setItem(k, v);
|
||||
out.restored = JSON.stringify({ ...sessionStorage }) === JSON.stringify(before);
|
||||
out.mine = pendingMine().length;
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
Reference in New Issue
Block a user