fix(structure): 관 지점 시설 종류 바꾸기 뒤 B06 횡단도 · 알약 · 목록 바로 새 종류(54-4)

- 좌측 패널 종류 바꿈 = 고른 관 지점 실시간 반영
- B06 관 고침이 시설 종류를 받음 · 옛 종류 칸 안 이음 · 알약 · 횡단 다시 그림
- 횡단 상세 읽기에 초안 종류 얹기(짝 산식 common_util_culvert_sets) · 카드 제목 라벨 포함
- 상세 조회가 측점 라벨을 지금 관 지점 종류로(저장 뒤 다시 열기)
- 구조물 다시 그림 최신 요청만 채택
- 시험: test_b05_pipe_station_relabel · ui_checks 54-4_종류바꾸기_횡단

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-06 10:26:34 +09:00
co-authored by Claude Opus 5.5
parent 6233daab11
commit 1e02ecc196
8 changed files with 429 additions and 16 deletions
@@ -0,0 +1,74 @@
"""54-4 — 저장으로 관 지점 종류만 바꾸면 횡단 측점 라벨(카드 제목)이 새 종류를 따라감.
측점은 다시 안 만들어지므로 라벨은 파일에 옛 종류로 남음 — 상세 조회가 지금 관 지점으로 고침.
다른 구조물 라벨(옹벽 등)은 건드리지 않음.
"""
import json
from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations
from common_util.common_util_drainage_pipes import parse_pipe_points
def _write_points(root, points):
edits = root / "B04_PreProcess" / "drainage" / "edits"
edits.mkdir(parents=True, exist_ok=True)
(edits / "pipe_points.json").write_text(json.dumps({"points": points}), encoding="utf-8")
def _sections():
return [
{"chainage_m": 80.0},
{"chainage_m": 85.05, "structure": "파형강관 D900"},
{"chainage_m": 120.0, "structure": "옹벽"},
{"chainage_m": 173.09, "structure": "세월교"},
{"chainage_m": 258.12, "structure": "파형강관 D800"},
]
def test_relabel_follows_saved_kind(tmp_path):
_write_points(
tmp_path,
[
{"chainage_m": 85.05, "source": "spacing", "facility": "box_culvert"},
{"chainage_m": 120.0, "source": "user", "facility": "box_culvert"}, # 옹벽 라벨 자리
{
"chainage_m": 173.09,
"source": "stream",
"facility": "pipe",
"options": {"pipe_kind": "흄관", "pipe_diameter_mm": 1200},
},
{"chainage_m": 258.12, "source": "spacing", "facility": "ford_pavement"},
],
)
sections = _sections()
relabel_pipe_stations(tmp_path, sections)
labels = {s["chainage_m"]: s.get("structure") for s in sections}
assert labels == {
80.0: None,
85.05: "BOX암거",
120.0: "옹벽", # 관 지점 라벨이 아닌 측점은 그대로
173.09: "흄관 D1200",
258.12: None, # 물넘이포장 = 라벨 없음(측점 생성 규칙과 같음)
}
def test_relabel_matches_generation_rule(tmp_path):
points = [
{"chainage_m": 85.05, "source": "spacing", "options": {"pipe_diameter_mm": 1000}},
{"chainage_m": 173.09, "source": "stream", "facility": "ford_bridge"},
]
_write_points(tmp_path, points)
sections = [
{"chainage_m": 85.05, "structure": "세월교"},
{"chainage_m": 173.09, "structure": "BOX암거"},
]
relabel_pipe_stations(tmp_path, sections)
generated = dict(resolve_extra_stations(tmp_path, parse_pipe_points(points)))
assert [s["structure"] for s in sections] == [generated[85.05], generated[173.09]]
def test_relabel_without_file_keeps_labels(tmp_path):
sections = _sections()
relabel_pipe_stations(tmp_path, sections)
assert sections == _sections()
@@ -0,0 +1,211 @@
/* 54-4 — 좌측 패널에서 같은 자리 관 지점 시설 종류를 바꿀 때 B06 횡단도 · 종단 알약 · 목록이 바로 새 종류
* 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4_검증) · 로그인 · 목록에 「4+5 배관 1」(85.05 m)
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m544 = "단계";`
* setup → 처음 관 지점 정본(서버)을 세션 `__544_orig` 에 남김 · 4+5 관 고름 · 종단 도크 폄
* cycle → 저장 전 네 종류 서로 바꾸기 12길(배관 → BOX → 세월교 → 물넘이 → 배관 → 세월교 → BOX →
* 물넘이 → 세월교 → 배관 → 물넘이 → BOX → 배관) — 길마다 횡단 카드 모양 · 제목 · 목록 · 알약 · 초안 칸
* save → BOX암거로 바꾸고 [저장] → 저장 뒤 카드 · 목록 · 알약 = BOX
* reopen → (orca reload 뒤) 저장분이 BOX 로 다시 섬 · BOX → 배관 바꾸기도 바로 따라옴(저장 뒤 길)
* restore → 처음 정본을 관 초안에 되얹고 [저장] → 서버 관 지점 = 처음과 같음
* 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__544_out_{단계}")` 로 읽음
* 쓰는 것: 검증 프로젝트 관 지점 정본(save · restore) · sessionStorage
* 기대 수치: 모든 단계 fails 0 · restore same true
*/
(async () => {
const MODE = window.__m544 || "cycle";
const raf = window.requestAnimationFrame;
window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const AT = 85.05;
const pid = localStorage.getItem("frd_current_project_id");
const api = async (u) => (await fetch(u, { credentials: "include" })).json();
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const NAME = {
pipe: "배관",
box_culvert: "BOX암거",
ford_bridge: "세월교",
ford_pavement: "물넘이포장",
};
const CHIP = {
pipe: "배수관",
box_culvert: "BOX암거",
ford_bridge: "세월교",
ford_pavement: null,
};
/** 그 종류에만 있는 칸 — 다른 종류로 바꾼 뒤 초안에 남으면 옛 칸이 섞인 것. */
const OWN = {
pipe: ["inlet_revet_form", "outlet_revet_form", "inlet_basin_length_m"], // 세월교도 inlet_type 을 씀(날개벽)
box_culvert: ["body_width_m", "body_height_m"],
ford_bridge: ["pipe_count"],
ford_pavement: [],
};
const typeSel = () =>
document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1];
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const row = () => rows().find((li) => /^4\+5 (배관|BOX암거|세월교|물넘이포장)/.test(txt(li)));
const card = () =>
[...document.querySelectorAll(".b06-cross-card")]
.map((c) => ({ c, at: Number(txt(c).split(" ")[1]?.replace("m", "")) }))
.sort((a, b) => Math.abs(a.at - AT) - Math.abs(b.at - AT))[0]?.c;
const pill = () =>
[...document.querySelectorAll(".b05-structure__mark")].find(
(m) => m.dataset.structureId === `pipe-${AT.toFixed(2)}`,
);
const draft = () => {
const raw = sessionStorage.getItem(`aislo:draft:pipes:${pid}`);
const v = raw ? JSON.parse(raw) : null;
return (v?.value ?? v ?? []).find((p) => Math.abs(p.chainage_m - AT) < 0.51) ?? null;
};
const openDock = async () => {
if (pill()) return;
const handle = document.querySelector(".ui-workflow-overlay__handle--bottom");
if (handle?.getAttribute("aria-expanded") !== "true") handle?.click();
for (let k = 0; k < 20 && !pill(); k++) await w(300);
};
const pick = async () => {
for (let k = 0; k < 40 && !row(); k++) await w(500);
if (!row()?.classList.contains("is-selected")) {
row()?.click();
await w(2500);
}
};
const until = async (fn, ms = 8000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
/** 횡단 카드 그림이 그 종류 모양인가 — 클래스 · 툴팁(svg title) 첫 낱말로 가름. */
const shapeOf = (c) => {
const svg = c.querySelector("svg");
const cls = [...svg.querySelectorAll("[class]")].map((x) => x.getAttribute("class"));
const tips = [...svg.querySelectorAll("title")].map((x) => x.textContent);
if (tips.some((t) => t.startsWith("BOX암거"))) return "box_culvert";
if (cls.some((k) => k.includes("ford-slab"))) return "ford_bridge";
if (cls.some((k) => k.includes("culvert-revet") || k.includes("culvert-pipe"))) return "pipe";
return "none";
};
const check = (to, label) => {
const c = card();
const got = {
shape: c ? shapeOf(c) : "카드 없음",
chip: c ? [...c.querySelectorAll(".b06-cross-card__structure")].map(txt) : [],
notes: c ? [...c.querySelectorAll(".b06-cross-card__warning")].map(txt) : [],
row: row() ? txt(row()) : null,
pill: pill() ? `${txt(pill())}|${pill().title}` : null,
type: typeSel().value,
facility: draft()?.facility,
keys: Object.keys(draft()?.options ?? {}),
};
const want = to === "ford_pavement" ? "none" : to;
if (got.shape !== want) fail(`${label}: 모양 ${got.shape} ≠ ${want}`);
if (CHIP[to] ? got.chip.join() !== CHIP[to] : got.chip.length)
fail(`${label}: 제목 ${got.chip}`);
if (to === "ford_pavement" && !got.notes.some((n) => n.includes("물넘이포장")))
fail(`${label}: 물넘이 알림 없음 ${got.notes}`);
if (got.notes.some((n) => Object.values(NAME).some((v) => v !== NAME[to] && n.includes(v))))
fail(`${label}: 옛 종류 알림 ${got.notes}`);
if (!got.row?.startsWith(`4+5 ${NAME[to]}`)) fail(`${label}: 목록 ${got.row}`);
if (!got.pill || !got.pill.includes(NAME[to])) fail(`${label}: 알약 ${got.pill}`);
if (got.type !== to) fail(`${label}: 폼 종류 ${got.type}`);
if (!["setup", "save", "reopen"].includes(MODE) && got.facility !== to)
fail(`${label}: 초안 종류 ${got.facility}`);
for (const [kind, keys] of Object.entries(OWN))
if (kind !== to && keys.some((k) => got.keys.includes(k)))
fail(`${label}: 옛 ${kind} 칸 섞임 ${got.keys.filter((k) => keys.includes(k))}`);
return got;
};
const change = async (to) => {
const t = typeSel();
t.value = to;
t.dispatchEvent(new Event("change", { bubbles: true }));
await until(() => card() && shapeOf(card()) === (to === "ford_pavement" ? "none" : to));
await w(600);
};
const save = async () => {
[...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click();
await until(() => document.querySelector(".ui-toast, [class*='toast']"), 60000);
await w(4000);
return document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null;
};
const out = { mode: MODE, fails: [] };
const fail = (what) => out.fails.push(what);
if (MODE === "setup") {
const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
sessionStorage.setItem("__544_orig", JSON.stringify(pipes));
out.orig = pipes.find((p) => Math.abs(p.chainage_m - AT) < 0.51);
await pick();
await openDock();
out.start = check(typeSel().value, "처음");
} else if (MODE === "cycle") {
await pick();
await openDock();
const path = [
"pipe",
"box_culvert",
"ford_bridge",
"ford_pavement",
"pipe",
"ford_bridge",
"box_culvert",
"ford_pavement",
"ford_bridge",
"pipe",
"ford_pavement",
"box_culvert",
"pipe",
];
if (typeSel().value !== path[0]) fail(`처음 종류 ${typeSel().value}`);
out.steps = [];
for (let i = 1; i < path.length; i++) {
const label = `${i} ${path[i - 1]}→${path[i]}`;
await change(path[i]);
const got = check(path[i], label);
out.steps.push(
`${label} ${got.shape} ${got.chip.join("/") || "-"} | ${got.row} | ${got.pill}`,
);
}
out.ways = new Set(path.slice(1).map((to, i) => `${path[i]}→${to}`)).size;
} else if (MODE === "save") {
await pick();
await openDock();
await change("box_culvert");
out.toast = await save();
await openDock();
out.after = check("box_culvert", "저장 뒤");
const saved = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
out.server = saved.find((p) => Math.abs(p.chainage_m - AT) < 0.51)?.facility;
if (out.server !== "box_culvert") fail(`서버 종류 ${out.server}`);
} else if (MODE === "reopen") {
await pick();
await openDock();
out.reopened = check("box_culvert", "다시 연 BOX");
await change("pipe");
out.toPipe = check("pipe", "저장 뒤 BOX→배관");
} else if (MODE === "restore") {
const orig = JSON.parse(sessionStorage.getItem("__544_orig") ?? "null");
if (!orig) fail("처음 정본 없음 — setup 먼저");
else {
const points = orig.map(({ lonlat, ...rest }) => rest); // 좌표는 서버가 다시 계산(DetailPipeInput)
sessionStorage.setItem(`aislo:draft:pipes:${pid}`, JSON.stringify(points));
location.reload();
return "reload — 다시 부르면 저장"; // 새로 고친 뒤 `__m544 = "restore2"`
}
} else if (MODE === "restore2") {
await pick();
out.toast = await save();
const orig = JSON.parse(sessionStorage.getItem("__544_orig"));
const now = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
const pick2 = (list) =>
list.map((p) => [p.chainage_m, p.facility ?? "pipe", JSON.stringify(p.options ?? {})]);
out.same = JSON.stringify(pick2(now)) === JSON.stringify(pick2(orig));
if (!out.same) fail("되돌린 정본이 처음과 다름");
out.at = now.find((p) => Math.abs(p.chainage_m - AT) < 0.51);
}
window.requestAnimationFrame = raf;
out.failCount = out.fails.length;
sessionStorage.setItem(`__544_out_${MODE}`, JSON.stringify(out));
return JSON.stringify(out);
})();