diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 4e7884a1d..d4c7a3d17 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -136,8 +136,9 @@ export function subscribeSelection(projectId: string | null, fn: SelectionListen } /** - * 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로 - * 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다. + * 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 **같은 것**을 다시 적을 때만 부재키를 지킨다 — + * 3D 로 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다. + * 다른 구조물로 옮기면 버리고, 빈 값("")을 주면 지운다(53-1). * `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다. * 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지). */ @@ -157,7 +158,17 @@ export function selectDesign( before.structureId === selection.structureId && (options.partKey === undefined || options.partKey === before.partKey); if (same) return; - const key = options.partKey ?? before?.partKey; + // 부재키는 같은 것을 다시 적을 때만 지킴 — 다른 구조물로 옮기면 옛 부재(조정창)가 따라오지 않음(53-1) + const sameTarget = + before !== null && + before.chainageM !== null && + isNear(before.chainageM, selection.chainageM) && + before.structureId === selection.structureId; + // 빈 부재키("") = 부재를 풂 + const key = + options.partKey === "" + ? undefined + : (options.partKey ?? (sameTarget ? before?.partKey : undefined)); writeState( "structure-pick", { at: selection.chainageM, key, sid: selection.structureId ?? undefined }, diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 6e63dbbe9..0ae205ba6 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -176,7 +176,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return wrap; }); box.body.append(grid(...fields)); - if (c.required) box.body.append(slotOf(c.id)); + if (c.required) { + const slot = slotOf(c.id); + // B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1) + const prev = slot.closest("fieldset"); + if (prev?.classList.contains("is-active")) { + const legend = box.root.querySelector("legend")!; + legend.dataset.baseLabel = legend.textContent ?? ""; + legend.textContent = prev.querySelector("legend")?.textContent ?? legend.textContent; + box.root.classList.add("is-active"); + } + box.body.append(slot); + } if (links) { const key = linkOf(c, index + 1); const row = buildSummaryRow(); diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 3acaa9e8e..d20716f00 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -355,6 +355,11 @@ export async function mountCrossView( const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); // 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다. if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" }); + // 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면 + // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) + const pipeAt = structuresPanel.currentChainage(); + const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); + if (mark) sectionView.selectMark(mark); structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. @@ -433,6 +438,10 @@ export async function mountCrossView( structuresPanel.prefer(handoff?.structureId ?? null); sectionView.focusStation(stationId); structuresPanel.prefer(null); + // 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1) + const sid = handoff?.structureId; + if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid)) + structuresPanel.selectStructure(sid); }, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), @@ -492,6 +501,8 @@ export async function mountCrossView( // 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다. host.onShow(() => { if (currentRouteId === null) return; + // 떠난 사이 종단 보기가 폼을 다른 것으로 바꿨을 수 있음 — 같은 넘김값도 다시 적용(53-1) + appliedPick = null; void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures().then(() => reconcileStaleDesigns()); }); diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index d4de1c8c0..585175a53 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -88,7 +88,8 @@ export function restoreStructurePick( selectStation(handoff.at); // 구조물 id 가 적혀 있을 때만 구조물로 연다 — 관 목록이 아직 안 실렸을 때 측점만으로 구조물을 // 짐작하면 그 측점을 덮는 다른 구조물이 열린다(관은 목록이 실릴 때 위 `selectStation` 이 연다). - if (!structures.hasSelection() && handoff.structureId) { + // id 가 있으면 측점이 연 관보다 먼저다(resolveSelection ① · 53-1) + if (handoff.structureId) { structures.selectAt({ chainageM: handoff.at, structureId: handoff.structureId }); } return structures.hasSelection(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 5eec636e4..05d04dad0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -326,6 +326,8 @@ export function createStructuresSection( commitTempPipe(); syncOptionLock(); syncRangeDisplay(); + // 고르던 항목의 측점도 값 — 다른 칸처럼 바로 반영(53-1) + if (positionConfirmed) liveCommit(); } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */ diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 7395f61ff..9c32dfbbe 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -109,6 +109,13 @@ function clearSideSlots(): void { // 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다. if (target === sideSlots?.extra) group.hidden = true; } + // 칸이 다른 명세로 옮겨 간 뒤 옛 상자에 남은 강조까지 끔(53-1) + document + .querySelectorAll("fieldset.is-active > legend[data-base-label]") + .forEach((legend) => { + legend.parentElement!.classList.remove("is-active"); + setGroupLabel(legend.parentElement!, ""); + }); } /** 행 DOM을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */ @@ -156,8 +163,8 @@ function relocateSideRows(panelRoot: HTMLElement): void { // 이름표에 지금 만지는 구조물을 적는다 — "유입구 (기슭막이)", "유출구 (기슭막이(독립))". // 추가 기슭막이 칸은 이름 자체가 그 단이라 괄호 안에 단 번호가 온다(2026-08-30 지시 1). setGroupLabel(group, panelRoot.dataset.panelTitle ?? ""); - // 다단은 칸이 새로 서는 것이라 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2). - if (side === "extra") group.scrollIntoView({ block: "nearest", behavior: "smooth" }); + // 고른 칸이 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2 · 유입 · 유출도 53-1). + group.scrollIntoView({ block: "nearest" }); } function ensureDock(): void { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 64f2f4592..2e2287053 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -38,8 +38,10 @@ import { } from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { + coversChainage, NO_SELECTION, PIPE_MATCH_M, + readSelection, resolveSelection, selectDesign, type DesignSelection, @@ -170,6 +172,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); deps.onStructuresChanged?.(); + // 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1) + const picked = readSelection(deps.projectId); + const moved = picked?.structureId + ? structures.find((entry) => entry.structure_id === picked.structureId) + : undefined; + if (moved && picked?.chainageM != null && !coversChainage(moved, picked.chainageM)) { + const id = moved.structure_id ?? null; + preferredId = id; + deps.focusChainage(structureAnchorM(moved)); + preferredId = null; + deps.markStructure?.(id); // 카드 고름이 옛 자리로 알약을 풀었으면 다시 세움 + selectDesign( + deps.projectId, + { chainageM: structureAnchorM(moved), structureId: id }, + { from: "b06" }, + ); + } }, onSelect: (structure) => { if (!structure) return; @@ -552,11 +571,17 @@ export function wireStructureSelection( const origRevet = stationControls.revetOffset.select; stationControls.revetOffset.select = (chainageM, key) => { - markWallSelecting(); + // 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1) + const nested = wallSelecting; + // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) + if (key) markWallSelecting(); origRevet(chainageM, key); if (key) remember?.(ownerChainageOf(chainageM), key); if (key) panel.showAtChainage(ownerChainageOf(chainageM)); - else panel.releasePipe(); + else if (!nested) { + remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 + panel.releasePipe(); + } if (key) syncInletStructure(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. refreshAdjustSlots(); @@ -564,22 +589,30 @@ export function wireStructureSelection( const origFord = stationControls.ford.select; stationControls.ford.select = (chainageM, role) => { - markWallSelecting(); + const nested = wallSelecting; + if (role) markWallSelecting(); origFord(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); - else panel.releasePipe(); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). refreshAdjustSlots(); }; const origBox = stationControls.box.select; stationControls.box.select = (chainageM, role) => { - markWallSelecting(); + const nested = wallSelecting; + if (role) markWallSelecting(); origBox(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); - else panel.releasePipe(); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. refreshAdjustSlots(); }; diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 4a177276d..2378a447c 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,6 +15,7 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; +import { coversChainage } from "../A00_Common/design/design_selection"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -133,6 +134,8 @@ export function createSectionProfile( let profile: DesignProfile | null = null; let detail: SectionDetailResponse | null = null; let marks: ReadonlyArray = []; + /** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */ + let markedId: string | null = null; let types: ReadonlyArray = []; let stations: IrregularStation[] = []; let intervalM = 20; @@ -253,6 +256,7 @@ export function createSectionProfile( profile?.redraw(); }, selectMark(structureId) { + markedId = structureId; profile?.setSelectedStructure(structureId); }, showCard(section) { @@ -265,6 +269,10 @@ export function createSectionProfile( (entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M, ); profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id); + // 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1) + const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined; + if (!pipe && marked && coversChainage(marked, section.chainage_m)) + profile.setSelectedStructure(markedId); }, }; } diff --git a/resources/tester/helper_b05_b06_selection.cjs b/resources/tester/helper_b05_b06_selection.cjs index 8892c7e2f..e38ec452e 100644 --- a/resources/tester/helper_b05_b06_selection.cjs +++ b/resources/tester/helper_b05_b06_selection.cjs @@ -80,8 +80,10 @@ sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "own", f out.store1 = sel.readSelection(P); sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { from: "b06" }); out.sameQuiet = heard.length; // 같은 값 · 부재키 생략 → 알리지 않음 +sel.selectDesign(P, { chainageM: 205.2, structureId: "wall" }, { from: "b06" }); +out.keyKept = sel.readSelection(P)?.partKey; // 같은 것을 다시 적으면 부재키를 지킨다 sel.selectDesign(P, { chainageM: 173.09, structureId: null }, { from: "b05" }); -out.keyKept = sel.readSelection(P)?.partKey; // 부재키를 안 주면 지킨다(옛 규칙) +out.keyDropped = sel.readSelection(P)?.partKey ?? null; // 다른 구조물로 옮기면 버린다(53-1) sel.selectDesign(P, sel.NO_SELECTION, { from: "b05" }); out.cleared = sel.readSelection(P); out.heardFrom = heard.map((h) => [h.chainageM, h.structureId, h.from]); @@ -150,7 +152,7 @@ out.apply = { }; // ── B05 진입 때 되살리기 — 구조물 id 가 있을 때만 구조물로 연다 ───────────── -const restoreRun = (pickValue) => { +const restoreRun = (pickValue, stationOpens = false) => { sel.selectDesign(P, sel.NO_SELECTION); if (pickValue) sel.selectDesign(P, pickValue.selection, { partKey: pickValue.key }); const calls = []; @@ -163,15 +165,17 @@ const restoreRun = (pickValue) => { }, }; const controls = { select: (p) => calls.push(["3d", p.chainageM, p.key ?? null]) }; - const done = pickSession.restoreStructurePick(controls, P, section, (c) => - calls.push(["station", c]), - ); + const done = pickSession.restoreStructurePick(controls, P, section, (c) => { + calls.push(["station", c]); + opened = stationOpens; // 같은 측점 관이 먼저 열림 + }); return { calls, done }; }; out.restore = { pipeOnly: restoreRun({ selection: { chainageM: 85.05, structureId: null } }), structure: restoreRun({ selection: { chainageM: 205, structureId: "wall" }, key: "own" }), nothing: restoreRun(null), + structureOverPipe: restoreRun({ selection: { chainageM: 440.24, structureId: "mirror" } }, true), }; sel.selectDesign(P, sel.NO_SELECTION); diff --git a/resources/tester/test_b05_b06_selection_single.py b/resources/tester/test_b05_b06_selection_single.py index 13728118d..33b63cf3e 100644 --- a/resources/tester/test_b05_b06_selection_single.py +++ b/resources/tester/test_b05_b06_selection_single.py @@ -105,13 +105,19 @@ def test_세션에_적고_읽는다(out): def test_부재키를_안_주면_지키고_해제하면_비운다(out): assert out["keyKept"] == "own" + assert out["keyDropped"] is None # 다른 구조물로 옮기면 옛 부재가 안 따라옴(53-1) assert out["cleared"] is None def test_같은_값은_알리지_않고_출처를_함께_알린다(out): assert out["sameQuiet"] == 1 - assert out["heardFrom"] == [[205, "wall", "b06"], [173.09, None, "b05"], [None, None, "b05"]] - assert out["afterOff"] == 3 # 구독을 끊으면 더는 안 들림 + assert out["heardFrom"] == [ + [205, "wall", "b06"], + [205.2, "wall", "b06"], + [173.09, None, "b05"], + [None, None, "b05"], + ] + assert out["afterOff"] == 4 # 구독을 끊으면 더는 안 들림 def test_알림_도중_다시_낸_값은_적기만_한다(out): @@ -172,6 +178,13 @@ def test_B05_되살리기_적힌_것이_없으면_끝난_것(out): assert out["restore"]["nothing"] == {"calls": [], "done": True} +def test_B05_되살리기_같은_측점_관보다_구조물_id가_먼저(out): + # B06 에서 고른 반사경(22+0) — 측점이 관을 먼저 열어도 id 의 구조물로 연다(53-1) + over = out["restore"]["structureOverPipe"] + assert over["calls"][-1] == ["selectAt", 440.24, "mirror"] + assert over["done"] is True + + # ── 배선 ─────────────────────────────────────────────────────────────── def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나(): panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") diff --git a/resources/tester/ui_checks/53-1_횡단_고르기.js b/resources/tester/ui_checks/53-1_횡단_고르기.js new file mode 100644 index 000000000..c6025557e --- /dev/null +++ b/resources/tester/ui_checks/53-1_횡단_고르기.js @@ -0,0 +1,607 @@ +/* 53-1 — B06 횡단도에서 구조물을 누르면 좌측 패널이 그 구조물로 켜지는가(전수) + * 시작 화면: B06 횡단설계(#/b06-section) · 창마다 자기 검증 프로젝트 · 로그인 상태 + * 부르는 법: orca eval 은 30초 안팎에 끊김 — 단계는 시작만 하고 결과는 따로 읽음 + * orca eval --expression "window.__m531='setup'; $(cat 이 파일)" → 초안 사진 + 표 20종 중 없는 종류를 세션에만 넣음(저장 안 함) + * orca eval --expression "window.__m531='check'; $(cat 이 파일)" → 전수 목록(벽 · 구체 · 카드 · 같은 측점 · 값 · 옮기기) + * orca eval --expression "window.__m531='handoff'; $(cat 이 파일)" → 저장 안 하고 B06 → B05 → B06 + * orca eval --expression "window.__m531='restore'; $(cat 이 파일)" → 초안을 사진대로 · 뒤에 orca reload + * 결과: orca eval --expression "JSON.stringify(window.__r531)" (끝나기 전엔 null) + * 쓰는 것: sessionStorage 만(서버 저장 없음 · 공유 storage 안 씀) + * 기대 수치: check · handoff 의 bad = [] · rows 마다 ok = true + * ① 종류 · 기준 측점 = 누른 것 ② 컨테이너 몫(유입 · 유출 · N단 · 좌우)이면 그 상자 강조 + 보임 + * ③ 목록 강조 1 · 종단 알약 1 ④ 좌측 값 → 횡단 다시 그림 ⑤ 다시 누름 = 풂 · 빈 곳 · 같은 측점 · 옮기기 · B05 ↔ B06 + */ +(() => { + const MODE = window.__m531 || "check"; + window.__r531 = null; + const PROJECT = localStorage.getItem("frd_current_project_id"); + const SNAP = "ui531-snap"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 250) { + if (fn()) return true; + await w(250); + } + return false; + }; + const sec = () => document.querySelector(".b05-structure-section"); + const selects = () => [...sec().querySelectorAll(".b05-structure__grid select")]; + const fire = (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const flat = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const listRows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const cards = () => [...document.querySelectorAll("article.b06-cross-card")]; + const chainOf = (card) => { + const m = card.id.match(/(\d{9})(\d{3})$/); + return m ? Number(m[1]) + Number(m[2]) / 1000 : NaN; + }; + const selectedCard = () => document.querySelector(".b06-cross-card--selected"); + const click = (el) => el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + /** 지금 좌측 패널 · 목록 · 알약 · 선택 칸. */ + const state = () => { + const pickKey = Object.keys(sessionStorage).find( + (k) => k.includes("structure-pick") && k.includes(PROJECT), + ); + let pick = null; + try { + pick = JSON.parse(sessionStorage.getItem(pickKey)); + } catch { + /* 없음 */ + } + const box = [...document.querySelectorAll("fieldset.is-active")].map((f) => { + const r = f.getBoundingClientRect(); + let sc = f.parentElement; + while (sc && !/(auto|scroll)/.test(getComputedStyle(sc).overflowY)) sc = sc.parentElement; + const b = sc ? sc.getBoundingClientRect() : { top: 0, bottom: innerHeight }; + const seen = r.height > 0 && r.top < Math.min(b.bottom, innerHeight) && r.bottom > b.top; + return { legend: flat(f.querySelector("legend")), seen }; + }); + const anchor = sec().querySelectorAll(".b05-structure__position-row > label")[2]; + return { + type: selects()[1]?.value ?? "", + anchor: anchor ? [...anchor.querySelectorAll("input")].map((i) => i.value).join("+") : "", + pick, + box, + list: listRows() + .filter((li) => li.classList.contains("is-selected")) + .map(flat), + pill: [...document.querySelectorAll(".b05-structure__mark.is-selected")].map((m) => m.title), + card: selectedCard()?.id ?? null, + }; + }; + const bad = []; + const known = []; // 고치지 않은 흠(횡단 그리기 몫) — 보고에 올림 + const rows = []; + const judge = (name, ok, s, note = "") => { + rows.push({ + name, + ok, + note, + type: s.type, + key: s.pick?.key ?? null, + box: s.box, + list: s.list, + pill: s.pill.length, + card: s.card?.slice(-9) ?? null, + }); + if (!ok) bad.push(name); + }; + /** 부재키 → 강조돼야 할 상자 이름. own(사면 구조물)은 컨테이너가 없음. */ + const boxFor = (key) => + key === "inlet" + ? /유입|좌|우/ + : key === "outlet" + ? /유출|좌|우/ + : /^b?extra/.test(key ?? "") + ? /추가/ + : key === "left" || key === "right" + ? /유입|유출/ + : null; + const boxOk = (s) => { + const want = boxFor(s.pick?.key); + if (!want) return s.box.length === 0; + return s.box.length === 1 && want.test(s.box[0].legend) && s.box[0].seen; + }; + const commonOk = (s) => + !!s.type && s.list.length === 1 && s.pill.length === 1 && !!s.pick && boxOk(s); + const hitsOf = (card) => [ + ...card.querySelectorAll(".b06-chart__culvert-revet-hit, .b06-chart__box-hit"), + ]; + const uniqueHits = (card) => [...new Set(hitsOf(card))]; + /** 카드 고름을 풂(같은 카드 다시 누름 = 풂). */ + const clearCard = async () => { + const c = selectedCard(); + if (c) { + click(c.querySelector("svg")); + await w(900); + } + }; + const openProfile = async () => { + const prof = document.querySelector(".b05-route-profile"); + if (prof?.classList.contains("is-collapsed")) { + document.querySelector(".ui-workflow-overlay__handle--bottom")?.click(); + await w(2500); + } + // 뒤에 깔린 탭은 그리기가 밀림 — 알약이 설 때까지 크기 알림을 보냄 + for (let i = 0; i < 20 && !document.querySelector(".b05-structure__mark"); i++) { + dispatchEvent(new Event("resize")); + await w(500); + } + }; + /** 세션에만 넣는 종류(표 01~08) — 이미 있으면 건너뜀. [군, 종류, 측점, +m, 길이(구간형)] */ + const ADD = [ + ["G", "pavement_concrete", 22, 0, 10], + ["A", "cross_underdrain", 22, 0], + ["E", "mirror", 22, 0], + ["A", "ford_pavement", 3, 0], + ["A", "open_ditch", 5, 0], + ["A", "cross_drain_exposed", 6, 0], + ["E", "evac_road", 9, 0], + ["E", "guardrail", 11, 0, 10], + ["E", "curb", 14, 0], + ["E", "hazard_sign", 15, 0], + ["E", "position_sign", 18, 0], + ["E", "water_intake", 19, 0], + ["E", "completion_sign", 20, 0], + ["E", "barrier_gate", 21, 0], + ["E", "spoil_bank", 24, 0], + ["E", "borrow_pit", 25, 0], + ["호환", "etc", 27, 0], + ]; + const addOne = async ([g, t, no, plus, len]) => { + const btn = (label) => [...sec().querySelectorAll("button")].find((b) => flat(b) === label); + btn("리셋")?.click(); + await w(400); + const n = listRows().length; // A군은 측점을 넣는 순간 임시로 목록에 섬 + const [gs, ts] = selects(); + fire(gs, g); + await w(300); + if (![...ts.options].some((o) => o.value === t)) return `${t}: 종류 없음`; + fire(ts, t); + await w(1200); + const labels = [...sec().querySelectorAll(".b05-structure__position-row > label")]; + const anchor = labels[2].querySelectorAll("input"); + fire(anchor[0], String(no)); + await w(200); + fire(anchor[1], String(plus)); + await w(300); + const field = (re) => + [...sec().querySelectorAll("label")] + .filter((l) => l.offsetParent) + .find((l) => re.test(flat(l))) + ?.querySelector("input"); + if (len) { + // 구간형 — 길이 · 전(시작 = 기준 − 전), 사토장은 시작 · 종료 칸 + if (field(/^길이/)) { + fire(field(/^길이/), String(len)); + fire(field(/^기준측점 전/), String(len / 2)); + } + if (!labels[1].hidden) { + const [s0, s1] = labels[1].querySelectorAll("input"); + const [e0, e1] = labels[3].querySelectorAll("input"); + fire(s0, String(no - 1)); + fire(s1, "10"); + fire(e0, String(no)); + fire(e1, "10"); + } + await w(300); + } else if (!labels[1].hidden) { + const [s0, s1] = labels[1].querySelectorAll("input"); + const [e0, e1] = labels[3].querySelectorAll("input"); + fire(s0, String(no - 1)); + fire(s1, "10"); + fire(e0, String(no)); + fire(e1, "10"); + await w(300); + } + btn("추가")?.click(); + await w(1500); + return listRows().length > n ? null : `${t}: 안 들어감`; + }; + + const run = async () => { + // 뒤에 깔린 탭(ORCA)은 화면 프레임이 안 돎 — rAF 를 기다리는 넘김 · 스크롤이 멈춤. 시험 동안만 시간으로 대신 + let frame = false; + requestAnimationFrame(() => (frame = true)); + await w(300); + if (!frame && !window.__raf531) { + window.__raf531 = window.requestAnimationFrame; + window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); + } + await until(() => listRows().length > 0 && cards().length > 0); + await w(1000); + const out = { mode: MODE, project: PROJECT }; + + if (MODE === "setup") { + if (!sessionStorage.getItem(SNAP)) + sessionStorage.setItem( + SNAP, + JSON.stringify( + Object.entries(sessionStorage).filter(([k]) => k.startsWith("aislo:draft:")), + ), + ); + const have = new Set(listRows().map((li) => flat(li).replace(/^[\d+~ ]+/, ""))); + out.added = []; + for (const item of ADD) { + const [g, t] = item; + selects()[0] && fire(selects()[0], g); + await w(200); + const name = [...selects()[1].options].find((o) => o.value === t)?.text ?? t; + if ([...have].some((h) => h.startsWith(name))) continue; + const err = await addOne(item); + out.added.push(err ?? name); + if (err) bad.push(err); + } + sec() + .querySelectorAll("button") + .forEach((b) => flat(b) === "리셋" && b.click()); + // 다단 벽(추가 기슭막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로) + out.tier = null; + for (const card of cards().filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) { + if (/추가\(성토부\)/.test(card.innerHTML)) { + out.tier = card.id; + break; + } + const outlet = () => + [ + ...document.getElementById(card.id).querySelectorAll(".b06-chart__culvert-revet-hit"), + ].find((h) => /유출/.test(h.querySelector("title")?.textContent ?? "")); + click(outlet()); + await w(1400); + [...sec().querySelectorAll(".b06-structure-panel__struct--tier button")] + .find((b) => b.offsetParent && /추가/.test(b.title)) + ?.click(); + await w(2500); + const added = /추가\(성토부\)/.test(document.getElementById(card.id).innerHTML); + click(outlet()); // 풂 + await w(1000); + if (added) { + out.tier = card.id; + break; + } + } + if (!out.tier) bad.push("다단 벽 못 세움"); + out.rows = listRows().map(flat); + } + + if (MODE === "check") { + await openProfile(); + // ── 가. 횡단도에 그려진 고를 거리 전부 — 다른 카드에서 처음 누름 · 같은 카드 다른 부재 ── + await clearCard(); + const targets = cards().filter((c) => hitsOf(c).length); + for (const card of targets) { + const n = uniqueHits(card).length; + for (let i = 0; i < n; i++) { + const hit = uniqueHits(document.getElementById(card.id))[i]; + const before = state(); + click(hit); + await w(1400); + let s = state(); + // 이미 고른 부재였으면 풀렸을 것 — 한 번 더 눌러 고름 + if (!s.pick?.key && before.pick?.key) { + click(uniqueHits(document.getElementById(card.id))[i]); + await w(1400); + s = state(); + } + judge(`벽 ${card.id.slice(-9)}#${i}`, commonOk(s) && s.card === card.id, s); + } + } + // ── 나. 같은 부재 다시 누름 = 풂(카드 고름은 남음) ── + const t0 = targets[0]; + if (t0) { + click(uniqueHits(t0)[0]); + await w(1400); + if (!state().pick?.key) { + click(uniqueHits(document.getElementById(t0.id))[0]); + await w(1400); + } + click(uniqueHits(document.getElementById(t0.id))[0]); + await w(1400); + const s = state(); + judge("다시 누름 = 풂", !s.pick?.key && s.box.length === 0 && s.card === t0.id, s); + } + // ── 다. 빈 곳 — 구조물 카드 · 빈 카드 · 다시 누름 = 카드 풂 ── + await clearCard(); + const pipeCard = targets.find((c) => /유입 집수정|유출 기슭막이/.test(c.innerHTML)); + if (pipeCard) { + click(pipeCard.querySelector("svg")); + await w(1400); + let s = state(); + judge("빈 곳(구조물 카드)", !!s.type && !s.pick?.key && s.box.length === 0, s); + click(document.getElementById(pipeCard.id).querySelector("svg")); + await w(1400); + s = state(); + judge("빈 곳 다시 = 카드 풂", !s.card && !s.type && !s.pick, s); + } + const used = new Set(listRows().map((li) => flat(li).match(/^(\d+)\+/)?.[1])); + const empty = cards().find((c) => !used.has(String(Math.floor(chainOf(c) / 20)))); + if (empty) { + click(empty.querySelector("svg")); + await w(1400); + const s = state(); + judge("빈 카드", !s.type && s.box.length === 0 && s.card === empty.id, s); + await clearCard(); + } + // ── 라. 표 20종 — 목록으로 골라 종류 확인 → 그 카드 빈 곳 눌러 같은 것이 켜지나 ── + const rowsNow = listRows().map(flat); + for (const text of rowsNow) { + const li = listRows().find((l) => flat(l) === text); + if (!li) continue; + await clearCard(); + (li.querySelector("button") ?? li).click(); + await w(1400); + const viaList = state(); + const card = viaList.card && document.getElementById(viaList.card); + judge( + `목록 ${text}`, + !!viaList.type && viaList.list.length === 1 && !!card && viaList.pill.length === 1, + viaList, + ); + if (!card) continue; + await clearCard(); + click(card.querySelector("svg")); + await w(1400); + const viaCard = state(); + const same = viaCard.type === viaList.type; + if (same) + judge(`카드 ${text}`, viaCard.list.length === 1 && viaCard.pill.length === 1, viaCard); + // 같은 카드에 관(그려진 것)이 있으면 관이 먼저 — 그림 없는 구조물은 목록으로만(흠 · 그리기 몫) + else if (uniqueHits(card).length && viaCard.type) + known.push(`카드 ${text} → ${viaCard.list[0] ?? viaCard.type}`); + else judge(`카드 ${text}`, false, viaCard, `카드는 ${viaCard.type || "빈 것"}`); + } + await clearCard(); + // ── 마. 같은 측점 여럿 — 목록 구조물 → 그 카드 관 벽 → 다시 목록 ── + const shared = rowsNow.find( + (t) => + !/배관/.test(t) && + rowsNow.some((u) => /배관/.test(u) && u.split(" ")[0] === t.split(" ")[0]), + ); + if (shared) { + const pipeRow = rowsNow.find( + (u) => /배관/.test(u) && u.split(" ")[0] === shared.split(" ")[0], + ); + const pick = (t) => { + const li = listRows().find((l) => flat(l) === t); + (li.querySelector("button") ?? li).click(); + }; + pick(pipeRow); + await w(1400); + const pc = selectedCard(); + pick(shared); + await w(1400); + let s = state(); + judge(`같은 측점: 목록 ${shared}`, s.type !== "pipe" && !s.pick?.key && !!s.pick?.sid, s); + const wall = pc && uniqueHits(document.getElementById(pc.id))[0]; + if (wall) { + click(wall); + await w(1400); + s = state(); + judge("같은 측점: 그 카드 관 벽", s.type === "pipe" && commonOk(s), s); + } + pick(shared); + await w(1400); + s = state(); + judge("같은 측점: 다시 목록", s.type !== "pipe" && !s.pick?.key && !!s.pick?.sid, s); + } + // ── 바. 좌측 값 → 횡단 다시 그림 → 되돌림(관 유출 형태 · 세월교 련 수 · 박스 유입 구조) ── + const titles = (card) => + [...document.getElementById(card.id).querySelectorAll("title")].map((t) => t.textContent); + const ctlOf = (key) => + [...sec().querySelectorAll(`[data-spec-item="${key}"]`)] + .find((e) => e.offsetParent) + ?.querySelector("input, select"); + const bodyCase = async (card, key, value, re) => { + await clearCard(); + click(uniqueHits(document.getElementById(card.id))[0]); + await w(1400); + const ctl = ctlOf(key); + if (!ctl) return judge(`값 → 횡단: ${key}`, false, state(), "칸 없음"); + const was = ctl.value; + const before = titles(card).join("|"); + fire(ctl, value); + await w(3000); + const after = titles(card).join("|"); + judge( + `값 → 횡단: ${key}=${value}`, + re(after) && after !== before && boxOk(state()), + state(), + ); + fire(ctlOf(key), was); + await w(3000); + const back = titles(card).join("|"); + // 빈 칸 = 자동(24-4a) — 서버가 채운 기본 연장을 앞단이 지워 「연장」 표기만 빠짐(흠 · 저장하면 다시 섬) + const noSpan = (t) => t.replace(/ · 연장 [\d.]+m/g, ""); + if (back !== before && noSpan(back) === noSpan(before)) + known.push( + `값 되돌림 ${key}: 벽 설명의 「연장」 표기 빠짐(서버 기본값 · 저장 뒤 다시 섬)`, + ); + judge(`값 되돌림: ${key}`, noSpan(back) === noSpan(before), state()); + }; + // 형태 칸은 구조를 고른 관에서만 섬 — 그 칸이 서는 관을 찾음 + for (const c of targets.filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) { + await clearCard(); + click(uniqueHits(document.getElementById(c.id))[0]); + await w(1400); + if (!ctlOf("outlet.form")) continue; + await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 기슭막이 콘크리트/.test(t)); + break; + } + const fordCard = targets.find((c) => /련/.test(titles(c).join("|"))); + if (fordCard) await bodyCase(fordCard, "ford.count", "3", (t) => /3련/.test(t)); + const boxCard = targets.find((c) => c.querySelector(".b06-chart__box-hit")); + if (boxCard) + await bodyCase( + boxCard, + "inlet.structure", + "없음", + (t) => (t.match(/날개벽 구간/g) ?? []).length === 1, + ); + // ── 사. 옮기기 — 사면 구조물 기준 측점 +2 → 목록 · 카드 · 알약 · 선택 칸이 따라감 → 되돌림 ── + await clearCard(); + // 사면 구조물 차례로 — 필수 칸이 빈 옛 구조물은 측점을 바꿔도 반영이 막히고 그 칸으로 포커스만 감(흠) + const input = () => + sec() + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input")[0]; + const move = async (v) => { + const el = input(); + el.focus(); + fire(el, String(v)); + el.blur(); + el.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + await w(8000); + return state(); + }; + for (const c of targets.filter((c) => + /독립 기슭막이/.test(c.querySelector(".b06-chart__culvert-revet title")?.textContent ?? ""), + )) { + await clearCard(); + click(uniqueHits(document.getElementById(c.id))[0]); + await w(1400); + const s0 = state(); + const no = Number(input().value); + let s = await move(no + 2); + if (s.list[0] === s0.list[0]) { + const stuck = flat(document.activeElement?.closest("label") ?? document.body).slice( + 0, + 12, + ); + known.push(`옮기기 ${s0.list[0]}: 반영 안 됨 — 빈 필수 칸 「${stuck}」 으로 포커스만`); + await move(no); + continue; + } + judge( + "옮기기: 목록 · 카드 · 알약 · 선택 칸", + s.list[0]?.startsWith(`${no + 2}+`) && + Math.abs(chainOf(document.getElementById(s.card)) - (no + 2) * 20) < 1 && + s.pill.length === 1 && + s.pick?.sid === s0.pick?.sid && + Math.abs(s.pick.at - (no + 2) * 20) < 1, + s, + ); + s = await move(no); + judge( + "옮기기 되돌림", + s.list[0] === s0.list[0] && + Math.abs(chainOf(document.getElementById(s.card)) - no * 20) < 1 && + s.pill.length === 1, + s, + ); + break; + } + await clearCard(); + } + + if (MODE === "handoff") { + await openProfile(); + const go = async (hash) => { + location.hash = hash; + await until(() => listRows().length > 0 && selects()[1]?.value); + await w(5000); + return state(); + }; + if (!location.hash.includes("b06")) await go("#/b06-section"); + // 같은 측점에 다른 구조물이 있는 관을 고름(없으면 아무 관) + const texts = listRows().map(flat); + const pipeRow = + texts.find( + (t) => + /배관/.test(t) && + texts.some((u) => !/배관/.test(u) && u.split(" ")[0] === t.split(" ")[0]), + ) ?? texts.find((t) => /배관/.test(t)); + await clearCard(); + for (let i = 0; i < 2 && !selectedCard(); i++) { + // 목록 다시 누름 = 풂 — 이미 골라져 있었으면 한 번 더 + const li = listRows().find((l) => flat(l) === pipeRow); + (li.querySelector("button") ?? li).click(); + await w(1400); + } + const pipeCard = selectedCard(); + const outletHit = () => + uniqueHits(document.getElementById(pipeCard.id)).find((h) => + /유출/.test(h.querySelector("title")?.textContent ?? ""), + ); + await clearCard(); + click(outletHit()); + await w(1400); + const a = state(); + const b05 = await go("#/b05-profile"); + const b06 = await go("#/b06-section"); + judge( + "관 유출 → B05", + b05.type === a.type && b05.anchor === a.anchor && b05.list[0] === a.list[0], + b05, + ); + judge( + "B05 → B06 유출 그대로", + b06.pick?.key === "outlet" && boxOk(b06) && b06.list[0] === a.list[0], + b06, + ); + // 같은 측점 다른 구조물을 목록으로 — 부재키가 따라오지 않고 그 구조물이 열림 + const station = a.list[0]?.split(" ")[0]; + const other = listRows().find( + (li) => flat(li).startsWith(station + " ") && !/배관/.test(flat(li)), + ); + if (other) { + const otherText = flat(other); + (other.querySelector("button") ?? other).click(); + await w(1400); + const c = state(); + const b05b = await go("#/b05-profile"); + const b06b = await go("#/b06-section"); + judge("같은 측점 구조물 → B05", b05b.type === c.type && !b05b.pick?.key, b05b); + judge("B05 → B06 그 구조물", b06b.type === c.type && b06b.box.length === 0, b06b); + // B06 에서 관 카드를 고른 채 B05 에서 같은 측점 다른 구조물을 고르고 돌아옴 — 카드가 이미 골라져 있어도 그 구조물 + const pick = (t) => { + const row = listRows().find((l) => flat(l) === t); + (row.querySelector("button") ?? row).click(); + }; + pick(pipeRow); + await w(1400); + await go("#/b05-profile"); + pick(otherText); + await w(1400); + const d = state(); + const b06c = await go("#/b06-section"); + judge( + "B06 카드 골라진 채 B05 에서 다른 것 → B06", + b06c.type === d.type && b06c.type !== "pipe", + b06c, + ); + } + } + + if (MODE === "restore") { + const snap = sessionStorage.getItem(SNAP); + if (!snap) out.error = "사진 없음"; + else { + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:") || k.includes("section-detail")) + sessionStorage.removeItem(k); + for (const [k, v] of JSON.parse(snap)) sessionStorage.setItem(k, v); + sessionStorage.removeItem(SNAP); + const sorted = (list) => JSON.stringify([...list].sort(([a], [b]) => a.localeCompare(b))); + out.same = + sorted(Object.entries(sessionStorage).filter(([k]) => k.startsWith("aislo:draft:"))) === + sorted(JSON.parse(snap)); + } + } + + if (window.__raf531) { + window.requestAnimationFrame = window.__raf531; + delete window.__raf531; + } + out.count = rows.length; + out.bad = bad; + out.known = known; + out.rows = out.rows ?? rows; + return out; + }; + run() + .then((out) => (window.__r531 = out)) + .catch((e) => (window.__r531 = { error: String(e?.stack ?? e) })); + return "started"; +})();