Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-06 13:02:22 +09:00
7 changed files with 4128 additions and 79 deletions
@@ -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()
@@ -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 컨테이너 이름
@@ -270,7 +270,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():
@@ -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);
})();