fix(structure): B05 · B06 좌측 구조물 목록 · 하이라이트 점검 흠 고침(53-2)

- B05 목록 · 구조물 알약 누르기에 3D 강조가 안 따라옴 → 목록 고름 · 관 고름이 3D 도 그 측점으로
- 물넘이포장(측점선 없음) 목록 · 알약 · 카드 고름에 알약이 안 켜짐 → 관 알약 id 로 바로 켬
- B06 정규 측점 · 측점 사이 구조물을 고르면 카드가 켜지며 알약이 꺼짐 → 알약은 카드 뒤에 · 측점선 짝 판정이 규칙 측점 기준점도 봄
- B06 목록에서 다시 눌러 풀어도 알약이 남음 → 풂 = 알약도 끔
- B06 목록에서 푼 뒤 같은 카드 관 알약을 누르면 아무것도 안 골라짐 → 카드 다시 고름
- 알약 번호 · 툴팁 · 벌룬이 기타 손 이름 대신 「기타 N」 → 목록과 같은 이름
- 구조물집계표 기타 열 머리: 빈 이름이 「기타 구조물」 · 빈칸 글 → 「기타」(집계 건 이름과 같게)
- [저장] 뒤 고른 구조물 강조가 풀림(다시 열면 되살아나 어긋남) → 같은 id 면 그대로 고름
- 목록 줄 마우스 올림 테두리
- 시험: ui_checks/53-2_목록_강조.js · test_53_2_etc_table_name.py · test_design_page_single 판정 자리 옮김

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 09:16:55 +09:00
co-authored by Claude Opus 5.5
parent 9c20c08327
commit 5b351e8c68
14 changed files with 447 additions and 28 deletions
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""기타 집계표 머리(이름마다 펼침) = 목록 이름 — 손 이름 · 비면 「기타」 — PLAN 53-2.
화면은 ORCA `ui_checks/53-2_목록_강조.js`.
"""
from M02_MasterTemplete import M02_Table_Fill as fill
from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물
_save,
world,
)
SOURCE = {"종류": "etc", "펼침": ["name"]}
def test_기타_표_머리는_손_이름_비면_기타(world): # noqa: F811 — 고정물
base = {"type_id": "etc", "placement": "point"}
named = {**base, "structure_id": "e1", "chainage_m": 20.0, "options": {"name": " 배수판 "}}
same = {**base, "structure_id": "e2", "chainage_m": 40.0, "options": {"name": "배수판"}}
space = {**base, "structure_id": "e3", "chainage_m": 60.0, "options": {"name": " "}}
none = {**base, "structure_id": "e4", "chainage_m": 80.0, "options": {}}
_save(world["root"], [named, same, space, none], [])
items = {i["복사본"]: i for i in fill.collect_items(world["root"])}
heads = {key: fill._expansion(SOURCE, items[key]) for key in ("e1", "e2", "e3", "e4")}
assert heads == {"e1": ("배수판",), "e2": ("배수판",), "e3": ("기타",), "e4": ("기타",)}
assert [items[key]["그룹"] for key in ("e1", "e3", "e4")] == ["배수판", "기타", "기타"]
+5 -1
View File
@@ -77,7 +77,11 @@ def test_창_키_듣기를_뗌():
def test_겹친_알약은_누른_것을_그대로():
profile = _read("A00_Common/design/design_profile.ts")
assert "stationIdAtStructure(selectedStructureId) === stationId" in profile
assert "structureIdKeeping(stationId, selectedStructureId" in profile
# 판정은 `_Profile_Structures` 로 옮김 — 비정규 측점 짝 · 규칙 측점(B06 카드) 기준점(53-2)
rule = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts")
assert "stationIdAtStructure(selectedId, stations, structures) === stationId" in rule
assert "Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M" in rule
def test_진행_단계_두_칸이_보기를_따라감():
@@ -0,0 +1,334 @@
/* 53-2 — B05 · B06 좌측 하단 구조물 목록 · 하이라이트 전수 점검
* 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계 · 검증 프로젝트(sub4_검증) · 로그인 · 종단 도크 펼침
* B05 3D 는 [3D 업데이트] 뒤(코리도 부재 신원표가 있어야 함)
* 자료: 구조물 정본에 표 20종 + 숨긴 종단배수(측구) · 옛 계류사방(골막이 · 기슭막이) · 옛 대피소 · 기타 손 이름 ·
* 같은 측점 여럿(4+5 = 배관 · 메쌓기 기준점 · 경계석 / 9+0 = 반사경 · 번호판)
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m532 = "단계";`
* names → 목록 이름 = 정본 · 레지스트리로 지은 이름(기준점 차례 · 같은 이름 번호) · 측점 글
* list → 줄마다 누르기: 그 줄만 강조 · 폼 종류 · 알약 하나(그 구조물) · B05 3D(그 측점 부재) · B06 카드(그 측점) · 다시 누르면 다 풀림
* pill → 알약마다 누르기: 그 줄 강조 · 목록 칸 안에 보임 · 알약 그대로 켜짐
* card → (B06) 구조물 기준점 측점 카드 누르기: 그 측점 줄 강조 · 알약 켜짐
* three → (B05) 3D 부재 누르기(화면 좌표 · 실제 포인터 이벤트): 그 측점 줄 강조 · 목록 칸 안
* misc → 마우스 올림 규칙(목록 줄 · 알약) · 목록 스크롤(칸보다 김 · 끝 줄 알약 → 보임)
* edit → (B05) 반사경 2+10 [추가] → 차례 · 번호 · 강조 풀림 → 그 줄 [삭제] → 처음 목록 → 옹벽 고르고 [저장]
* → 차례 그대로 · 강조 그대로 (서버 저장 · 검증 프로젝트)
* reopen → (orca reload 뒤 · orca screenshot 한 번 — 안 그려진 탭은 rAF 이 멈춰 「보이게 스크롤」이 안 돎)
* 옹벽 강조 · 알약 되살아남 · 목록 칸 안
* (B05 · B06 모두 돌려 names · list 결과가 같은지 대조)
* 긴 단계(list · pill · card · three)는 연결이 끊길 수 있음 — `void` 로 띄우고
* `sessionStorage.getItem("__532_out_{단계}_{b05|b06}")` 로 읽음
* 쓰는 것: 화면 조작만(sessionStorage 선택 키) — 끝나면 고른 것 풀어 둠
* ORCA 탭이 안 그려지는 동안 requestAnimationFrame 이 멈춤(목록 「보이게 스크롤」이 그 다음 프레임에 돎) —
* 단계 동안만 setTimeout 으로 대신 돌리고 끝에 되돌림
* 기대 수치: 모든 단계 fails 0
*/
(async () => {
const MODE = window.__m532 || "names";
const raf = window.requestAnimationFrame;
window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const pid = localStorage.getItem("frd_current_project_id");
const api = async (u) => (await fetch(u, { credentials: "include" })).json();
const vis = (el) => !!el && el.getClientRects().length > 0;
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const isB06 = location.hash.includes("b06");
const list = () => document.querySelector(".b05-route__irregular-list");
const items = () => [...list().querySelectorAll("li.b05-route__irregular-item")];
const selRows = () => items().filter((li) => li.classList.contains("is-selected"));
const typeSel = () =>
document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1];
const marks = () => [...document.querySelectorAll(".b05-structure__mark")].filter(vis);
const litPills = () =>
marks()
.filter((m) => m.classList.contains("is-selected"))
.map((m) => m.dataset.structureId);
const cardSel = () =>
[...document.querySelectorAll(".b06-cross-card--selected")]
.filter(vis)
.map((c) => Number(txt(c).split(" ")[1]?.replace("m", "")));
const tags = () => {
const out = [];
window.__corridorScene?.scene.traverse((o) => {
if (!o.isMesh || typeof o.userData?.chainageM !== "number") return;
const ms = Array.isArray(o.material) ? o.material : [o.material];
out.push({ o, c: o.userData.chainageM, lit: ms.some((m) => m.emissive?.getHex()) });
});
return out;
};
const lit3D = () => [
...new Set(
tags()
.filter((t) => t.lit)
.map((t) => t.c.toFixed(2)),
),
];
const inView = (li) => {
const a = li.getBoundingClientRect();
const b = list().getBoundingClientRect();
return a.top >= b.top - 1 && a.bottom <= b.bottom + 1;
};
// 기대 목록 — 정본 둘(구조물 · 관 지점)을 기준점 오름차순으로(목록과 같은 규칙)
const types = (await api("/api/projects/structure-types?include_disabled=true")).types;
const typeName = (id) => types.find((t) => t.type_id === id)?.name ?? id;
const interval = 20;
const sta = (m) => {
let n = Math.floor((m + 1e-6) / interval);
let r = m - n * interval;
if (Math.abs(r) < 0.05) r = 0;
if (r >= interval - 0.05) ((n += 1), (r = 0));
return `${n}+${Math.round(r)}`;
};
const structures = (await api(`/api/projects/${pid}/route/structures`)).structures;
const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
const rows = [
...structures.map((s) => ({
anchor: s.chainage_m ?? s.start_m,
type: s.type_id === "refuge" ? "evac_road" : s.type_id, // 옛 대피소는 대피로 폼으로 엶(52-7)
pill: s.structure_id,
name: (s.type_id === "etc" && String(s.options?.name ?? "").trim()) || typeName(s.type_id),
station: s.placement === "interval" ? `${sta(s.start_m)}~${sta(s.end_m)}` : sta(s.chainage_m),
})),
...pipes.map((p) => ({
anchor: p.chainage_m,
type: p.facility ?? "pipe",
pill: `pipe-${p.chainage_m.toFixed(2)}`,
name: typeName(p.facility ?? "pipe"),
station: sta(p.chainage_m),
})),
].sort((a, b) => a.anchor - b.anchor);
const total = {};
rows.forEach((r) => (total[r.name] = (total[r.name] ?? 0) + 1));
const seen = {};
rows.forEach((r) => {
if (total[r.name] > 1) r.name = `${r.name} ${(seen[r.name] = (seen[r.name] ?? 0) + 1)}`;
r.text = `${r.station} ${r.name}`;
});
const clearPick = async () => {
for (let k = 0; k < 2 && selRows().length; k++) {
selRows()[0].click();
await w(900);
}
};
const out = { mode: MODE, view: isB06 ? "b06" : "b05", fails: [] };
const fail = (what) => out.fails.push(what);
if (MODE === "reopen") {
// 다시 연 화면 — 저장 전에 고른 것이 되살아났는가(세션 고름)
const sel = selRows();
out.restored = sel.map(txt);
if (sel.length !== 1 || !/옹벽$/.test(txt(sel[0]))) fail(`되살린 강조 ${out.restored}`);
else if (!inView(sel[0])) fail("되살린 줄이 목록 칸 밖");
const pills = litPills();
if (pills.length !== 1 || pills[0] !== rows.find((r) => /옹벽$/.test(r.text))?.pill)
fail(`되살린 알약 ${pills}`);
}
await clearPick();
if (MODE === "names") {
const got = items().map(txt);
out.rows = got.length;
out.expected = rows.length;
rows.forEach((r, i) => got[i] !== r.text && fail(`${i}: ${got[i]} ≠ ${r.text}`));
out.names = got;
} else if (MODE === "list") {
const tagCs = new Set(tags().map((t) => t.c.toFixed(2)));
for (const [i, r] of rows.entries()) {
items()[i].click();
await w(isB06 ? 1500 : 900);
const sel = selRows();
if (sel.length !== 1 || sel[0] !== items()[i]) fail(`${r.text}: 줄 강조 ${sel.map(txt)}`);
if (typeSel()?.value !== r.type) fail(`${r.text}: 폼 종류 ${typeSel()?.value}`);
const pills = litPills();
if (pills.length !== 1 || pills[0] !== r.pill) fail(`${r.text}: 알약 ${pills}`);
if (!isB06) {
const want = [...tagCs].filter((c) => Math.abs(c - r.anchor) < 0.01);
const got = lit3D();
if (want.join() !== got.join()) fail(`${r.text}: 3D ${got} ≠ ${want}`);
} else {
// 카드 = 기준점에 가장 가까운 측점 하나(카드 글 「측점 누가거리m …」)
const card = cardSel();
if (card.length !== 1 || Math.abs(card[0] - r.anchor) > interval / 2)
fail(`${r.text}: 카드 ${card}`);
}
items()[i].click(); // 다시 누름 = 풂
await w(900);
if (selRows().length || litPills().length || (!isB06 && lit3D().length))
fail(`${r.text}: 풂 뒤 남음 ${selRows().length}/${litPills()}/${lit3D()}`);
}
out.checked = rows.length;
} else if (MODE === "pill") {
for (const r of rows) {
const pill = marks().find((m) => m.dataset.structureId === r.pill);
if (!pill) {
fail(`${r.text}: 알약 없음`);
continue;
}
pill.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(isB06 ? 1500 : 900);
const sel = selRows();
if (sel.length !== 1 || txt(sel[0]) !== r.text) fail(`${r.text}: 줄 ${sel.map(txt)}`);
else if (!inView(sel[0])) fail(`${r.text}: 목록 칸 밖`);
if (litPills().join() !== r.pill) fail(`${r.text}: 알약 ${litPills()}`);
await clearPick();
}
out.checked = rows.length;
} else if (MODE === "card") {
// 측점마다 카드 하나 — 기준점이 그 측점인 줄 중 하나가 켜져야 함(같은 측점 여럿은 52-15 규칙)
const cards = [...document.querySelectorAll(".b06-cross-card")];
let n = 0;
for (const card of cards) {
const at = Number(txt(card).split(" ")[1]?.replace("m", ""));
const here = rows.filter((r) => Math.abs(r.anchor - at) < 0.51);
if (!here.length) continue;
// 관 시설 범위 안 카드는 관이 먼저(계곡 통과 시설 우선 · resolveSelection)
here.push(...rows.filter((r) => r.pill.startsWith("pipe-") && Math.abs(r.anchor - at) < 5));
n += 1;
card.scrollIntoView({ block: "nearest" });
await w(300);
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(1500);
const sel = selRows();
const hit = here.find((r) => sel.length === 1 && txt(sel[0]) === r.text);
if (!hit) fail(`${at}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`);
else if (litPills().join() !== hit.pill) fail(`${at}: 알약 ${litPills()} ≠ ${hit.pill}`);
if (sel.length && !inView(sel[0])) fail(`${at}: 목록 칸 밖`);
await clearPick();
}
out.checked = n;
} else if (MODE === "three") {
const S = window.__corridorScene;
const canvas = [...document.querySelectorAll("canvas")]
.filter(vis)
.sort((a, b) => b.width * b.height - a.width * a.height)[0];
const done = new Set();
let n = 0;
// 종단 도크가 캔버스 아래를 덮음 — 단계 동안 접고 끝에 다시 폄
const handle = document.querySelector(".ui-workflow-overlay__handle--bottom");
handle?.click();
await w(1500);
const o = S.toScene(0, 0, 0); // 장면 → 모델: x = x − o.x · y = −(z − o.z) · z = y − o.y
for (const t of tags()) {
const key = t.c.toFixed(2);
if (done.has(key)) continue;
const box = new S.THREE.Box3().setFromObject(t.o);
const c = box.getCenter(new S.THREE.Vector3());
S.lookAt(c.x - o.x, -(c.z - o.z), c.y - o.y, 25, "top"); // 그 부재 위에서 내려다봄
S.camera.updateMatrixWorld();
const p = c.clone().project(S.camera);
const rect = canvas.getBoundingClientRect();
const x = rect.left + ((p.x + 1) / 2) * rect.width;
const y = rect.top + ((1 - p.y) / 2) * rect.height;
const top = document.elementFromPoint(x, y);
if (top !== canvas) continue; // 패널에 가린 부재는 건너뜀
const ray = new S.THREE.Raycaster();
ray.setFromCamera(new S.THREE.Vector2(p.x, p.y), S.camera);
const first = ray.intersectObject(S.scene, true).find((h) => h.object.isMesh);
if (first?.object.userData?.chainageM !== t.c) continue; // 앞에 다른 물체
done.add(key);
n += 1;
const ev = (type) =>
new PointerEvent(type, {
bubbles: true,
clientX: x,
clientY: y,
button: 0,
pointerId: 7,
isPrimary: true,
});
window.dispatchEvent(ev("pointerdown"));
canvas.dispatchEvent(ev("pointerdown"));
canvas.dispatchEvent(ev("pointerup"));
await w(1200);
const sel = selRows();
const here = rows.filter((r) => Math.abs(r.anchor - t.c) < 0.51);
if (!here.some((r) => sel.length === 1 && txt(sel[0]) === r.text))
fail(`3D ${key}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`);
else if (!inView(sel[0])) fail(`3D ${key}: 목록 칸 밖`);
if (!lit3D().includes(key)) fail(`3D ${key}: 부재 강조 ${lit3D()}`);
await clearPick();
}
handle?.click();
await w(1500);
out.tagged = new Set(tags().map((t) => t.c.toFixed(2))).size;
out.checked = n;
} else if (MODE === "misc") {
const rules = [...document.styleSheets].flatMap((s) => {
try {
return [...s.cssRules].map((r) => r.selectorText ?? "");
} catch {
return [];
}
});
if (!rules.includes(".b05-route__irregular-item:hover")) fail("목록 줄 마우스 올림 규칙 없음");
if (!rules.includes(".b05-structure__mark:hover")) fail("알약 마우스 올림 규칙 없음");
const l = list();
out.scroll = { scrollH: l.scrollHeight, clientH: l.clientHeight };
if (l.scrollHeight <= l.clientHeight) fail("목록이 칸보다 짧음 — 스크롤 시험 안 됨");
for (const r of [rows[rows.length - 1], rows[0], rows[Math.floor(rows.length / 2)]]) {
marks()
.find((m) => m.dataset.structureId === r.pill)
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(isB06 ? 1500 : 900);
const sel = selRows()[0];
if (!sel || !inView(sel)) fail(`${r.text}: 스크롤 뒤 안 보임`);
await clearPick();
}
out.checked = 5;
} else if (MODE === "edit") {
// 추가(반사경 2+10 · 폼) → 차례 · 강조 풀림 → 그 줄 고르고 삭제 → 처음과 같음 → 옹벽 고르고 [저장]
const before = items().map(txt);
const sec = document.querySelector(".b05-structure-section");
const fire = (el, v) => {
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
};
const [g, t] = sec.querySelectorAll(".b05-structure__grid select");
fire(g, "E");
await w(300);
fire(t, "mirror");
await w(800);
const anchor = sec
.querySelectorAll(".b05-structure__position-row > label")[2]
.querySelectorAll("input");
fire(anchor[0], "2");
fire(anchor[1], "10");
await w(300);
sec.querySelector(".is-primary").click(); // 추가
await w(1500);
const added = items().map(txt);
out.added = added.slice(0, 3);
if (added.length !== before.length + 1) fail(`추가 뒤 줄 수 ${added.length}`);
if (added[1] !== "2+10 반사경 1" || !added.includes("9+0 반사경 2"))
fail(`추가 차례 · 번호 ${added.slice(0, 3)} · 9+0 ${added.find((x) => x.startsWith("9+0"))}`);
if (selRows().length || litPills().length) fail("추가 뒤 강조 남음");
items()[1].click();
await w(900);
if (txt(selRows()[0] ?? list()) !== "2+10 반사경 1") fail("새 줄 고르기");
if (litPills().length !== 1) fail(`새 줄 알약 ${litPills()}`);
sec.querySelector(".is-danger").click(); // 삭제
await w(1500);
const removed = items().map(txt);
if (removed.join("|") !== before.join("|")) fail("삭제 뒤 목록이 처음과 다름");
if (selRows().length || litPills().length) fail("삭제 뒤 강조 남음");
// 옹벽 고른 채 [저장] — 저장 뒤 차례 그대로 · 강조 그대로
items()
.find((li) => /옹벽$/.test(txt(li)))
.click();
await w(900);
[...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click();
for (let k = 0; k < 120 && !document.querySelector(".ui-toast, [class*='toast']"); k++)
await w(500);
await w(3000);
out.toast = document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null;
const saved = items().map(txt);
if (saved.join("|") !== before.join("|")) fail("저장 뒤 목록 차례가 다름");
out.afterSave = selRows().map(txt);
if (out.afterSave.length !== 1 || !/옹벽$/.test(out.afterSave[0])) fail("저장 뒤 강조 풀림");
out.checked = 9;
}
window.requestAnimationFrame = raf;
out.failCount = out.fails.length;
sessionStorage.setItem(`__532_out_${MODE}_${out.view}`, JSON.stringify(out)); // 긴 단계는 여기서 읽음
return JSON.stringify(out);
})();