From 5b351e8c684ff582bb3f3d29ade9268d24f43ab8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 09:16:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(structure):=20B05=20=C2=B7=20B06=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=90=EA=B2=80=20=ED=9D=A0=20=EA=B3=A0=EC=B9=A8?= =?UTF-8?q?(53-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/design/design_profile.ts | 10 +- A00_Common/design/design_view_profile.ts | 17 +- .../B05_Profile_UI_Profile_Structures.ts | 24 ++ B05_Profile/B05_Profile_UI_Selection.ts | 6 +- B05_Profile/B05_Profile_UI_Structures_List.ts | 4 +- .../B05_Profile_UI_Structures_Marks.ts | 8 +- .../B05_Profile_UI_Structures_Panel.ts | 9 +- B05_Profile/B05_Profile_UI_Style.css | 4 + .../B06_Section_UI_Page_Structures_Panel.ts | 12 +- .../B06_Section_UI_Section_View_Profile.ts | 9 +- M02_MasterTemplete/M02_Table_Fill.py | 6 +- resources/tester/test_53_2_etc_table_name.py | 26 ++ resources/tester/test_design_page_single.py | 6 +- resources/tester/ui_checks/53-2_목록_강조.js | 334 ++++++++++++++++++ 14 files changed, 447 insertions(+), 28 deletions(-) create mode 100644 resources/tester/test_53_2_etc_table_name.py create mode 100644 resources/tester/ui_checks/53-2_목록_강조.js diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 04ef5de0f..def0e97d1 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -53,7 +53,7 @@ import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile import { structureGroupMenuItems, stationIdAtStructure as stationIdAtStructureOf, - structureIdAtStation as structureIdAtStationOf, + structureIdKeeping, } from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { @@ -261,9 +261,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로 * 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */ const structureIdAtStation = (stationId: string | null): string | null => - stationId !== null && stationIdAtStructure(selectedStructureId) === stationId - ? selectedStructureId - : structureIdAtStationOf(stationId, irregularStations, structures); + structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail); const stationIdAtStructure = (structureId: string | null): string | null => stationIdAtStructureOf(structureId, irregularStations, structures); @@ -626,8 +624,10 @@ export function createDesignProfile(ctx: DesignProfileContext) { }, /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ setSelectedStructure(structureId: string | null) { + // 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2) selectedStructureId = structureId; - selectedStationId = stationIdAtStructure(structureId); + const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId; + selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId); draw(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 8ca8ae223..6cbbbb4d6 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,7 +31,7 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection"; +import { NO_SELECTION, PIPE_MATCH_M, nearestWithin, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, @@ -123,6 +123,15 @@ export async function mountProfileView( profilePanel.setMinCoverPipes(pipes); restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; + /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2). */ + const selectGraphAt = (id: string | null, chainageM: number | null): void => { + const bare = + id === null && chainageM !== null + ? lastPipes?.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M) + : undefined; + if (bare) profilePanel.setSelectedStructure(`pipe-${bare.chainage_m.toFixed(2)}`); + else profilePanel.setSelectedStation(id); + }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( shared, @@ -205,7 +214,7 @@ export async function mountProfileView( selectionSyncing = value; }, selectMarker: (id) => viewer.markers.selectStation(id), - selectGraph: (id) => profilePanel.setSelectedStation(id), + selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null), selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), @@ -299,10 +308,11 @@ export async function mountProfileView( : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); const id = station ? irregularStationId(station.id) : null; viewer.markers.selectStation(id); - profilePanel.setSelectedStation(id); + selectGraphAt(id, chainage); syncBasinHighlight(id); // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). profilePanel.drainage.selectPipeAtChainage(chainage); + viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2) } finally { selectionSyncing = false; } @@ -315,6 +325,7 @@ export async function mountProfileView( selectionSyncing = true; try { profilePanel.setSelectedStructure(structure?.structure_id ?? null); + viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null); } finally { selectionSyncing = false; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 4844b20c7..eef902955 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -154,6 +154,30 @@ export function structureIdAtStation( return hit?.structure_id ?? null; } +/** 측점선이 골라질 때 켤 알약 — 지금 고른 알약이 그 측점 자리면 그대로 둔다(같은 측점 + * 여럿 · 24-6b). 규칙 측점(B06 카드)도 기준점 누가거리로 맞춰 본다 — 안 보면 카드가 켜지며 + * 고른 구조물 알약이 꺼졌다(53-2). */ +export function structureIdKeeping( + stationId: string | null, + selectedId: string | null, + stations: IrregularStation[], + structures: StructureInstance[], + detail: { + longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> }; + } | null, +): string | null { + const selected = structures.find((item) => item.structure_id === selectedId); + const at = detail?.longitudinal.stations.find( + (entry) => entry.station_id === stationId, + )?.chainage_m; + const keeps = + stationId !== null && + !!selected && + (stationIdAtStructure(selectedId, stations, structures) === stationId || + (at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M)); + return keeps ? selectedId : structureIdAtStation(stationId, stations, structures); +} + /** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ export function stationIdAtStructure( structureId: string | null, diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index c5fd228ec..d28add866 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -23,8 +23,8 @@ export interface SelectionSyncPorts { setSyncing: (value: boolean) => void; /** 3D 뷰어 마커 선택. */ selectMarker: (stationId: string | null) => void; - /** 종단 그래프 세로선 선택. */ - selectGraph: (stationId: string | null) => void; + /** 종단 그래프 세로선 선택 — 누가거리는 세로선 없는 시설(물넘이포장)의 알약을 켜는 데 씀. */ + selectGraph: (stationId: string | null, chainageM?: number | null) => void; /** 좌측 구조물 폼 선택(누가거리 기준). */ selectSidebar: (chainageM: number | null) => void; /** 배수유역도 세부유역 강조(누가거리 기준). */ @@ -98,7 +98,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { ports.setSyncing(true); try { ports.selectMarker(id); - ports.selectGraph(id); + ports.selectGraph(id, chainageM); // 투영 측점이 아직 없어도(방금 넣은 임시 관 — 재계산 전) 누가거리는 그대로 // 넘긴다. null을 넘기면 사이드가 "해제"로 받아 임시 배치를 스스로 취소한다 // (2026-08-18 임시 배치 자기 삭제 버그). diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index abb884a5d..ee699ec95 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -56,8 +56,8 @@ export function numberDuplicateNames(names: ReadonlyArray): string[] { }); } -/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름). */ -function etcName(structure: StructureInstance): string | null { +/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름) · 알약도 씀(53-2). */ +export function etcName(structure: StructureInstance): string | null { const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : ""; return name || null; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 6b96079a5..e0913e2e7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -19,7 +19,7 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; -import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; +import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 @@ -133,7 +133,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement (left, right) => structureAnchorM(left) - structureAnchorM(right), ); const numberedNames = numberDuplicateNames( - anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id), + anchorSorted.map( + (entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id, + ), ); const displayNames = new Map( anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]), @@ -143,7 +145,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); - const rawName = type?.name ?? structure.type_id; + const rawName = etcName(structure) ?? type?.name ?? structure.type_id; // 기타 = 손 이름(목록과 같게) const displayName = displayNames.get(id) ?? rawName; // "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지). const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : ""; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 5eec636e4..160806a93 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -556,11 +556,10 @@ export function createStructuresSection( // 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인). structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry }))); // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. - if (applyingLive) renderList(); - else { - loadForm(null); - renderList(); - } + // [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2). + const kept = structures.find((entry) => entry.structure_id === editingId) ?? null; + if (!applyingLive) loadForm(editingId === null ? null : kept); + renderList(); }, getStructures: () => [...structures], setPipeFacilities(pipes) { diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index d0b76f534..4ae803d58 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -395,6 +395,10 @@ cursor: pointer; } +.b05-route__irregular-item:hover { + border-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + .b05-route__irregular-item.is-selected { border-color: var(--color-royal-amethyst, rgb(109 40 217)); background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 12%, transparent); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 64f2f4592..d45b00c53 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -172,12 +172,16 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc deps.onStructuresChanged?.(); }, onSelect: (structure) => { - if (!structure) return; - deps.markStructure?.(structure.structure_id ?? null); + if (!structure) { + deps.markStructure?.(null); // 풂 = 알약도 끔(53-2) + return; + } // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. preferredId = structure.structure_id ?? null; deps.focusChainage(structureAnchorM(structure)); preferredId = null; + // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) + deps.markStructure?.(structure.structure_id ?? null); }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. @@ -254,7 +258,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { - deps.markStructure?.(null); // 알약 강조는 관 측점선 짝으로(같은 측점 구조물 강조를 푼다). // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. selectDesign( deps.projectId, @@ -262,6 +265,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc { from: "b06" }, ); if (chainageM !== null) deps.focusChainage(chainageM); + // 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2) + deps.markStructure?.(chainageM === null ? null : `pipe-${chainageM.toFixed(2)}`); }, }, }); @@ -491,6 +496,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (hit.pipeChainageM !== null) { currentChainageM = hit.pipeChainageM; refreshSpecDefaults(hit.pipeChainageM); + deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2) } section.selectAt(selection); return { chainageM, structureId: hit.structureId }; diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 4a177276d..ce2ace55f 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -196,9 +196,14 @@ export function createSectionProfile( const hit = marks.find((entry) => entry.structure_id === structureId); if (!hit) return; const edit = host.edit(); - if (!isPipeMark(hit) && hit.structure_id && edit) + if (!isPipeMark(hit) && hit.structure_id && edit) { edit.selectStructure(hit.structure_id); - else host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + return; + } + // 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2) + host.selectCard(null); + host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + profile?.setSelectedStructure(structureId); // 측점선 없는 물넘이포장도 그 알약 }, onStructureMarkMove: (structureId, toChainageM) => { const edit = host.edit(); diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 964b1c188..862138d91 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -103,6 +103,10 @@ def collect_items( definition = types.get(item.type_id) if definition is not None and definition.managed_by: continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈 + options = _with_defaults(definition, dict(item.options or {})) + label = etc_label(item.type_id, item.options) + if label: # 표 머리(이름마다 펼침)도 손 이름 · 비면 「기타」(53-2) + options["name"] = label["그룹"] items.append( { "type_id": item.type_id, @@ -110,7 +114,7 @@ def collect_items( "chainage_m": float(item.chainage_m), "start_m": item.start_m, "end_m": item.end_m, - "options": _with_defaults(definition, dict(item.options or {})), + "options": options, "summary_item": item.summary_item, "복사본": item.structure_id or f"#{index}", "group_items": item.group_items, diff --git a/resources/tester/test_53_2_etc_table_name.py b/resources/tester/test_53_2_etc_table_name.py new file mode 100644 index 000000000..166f82c04 --- /dev/null +++ b/resources/tester/test_53_2_etc_table_name.py @@ -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")] == ["배수판", "기타", "기타"] diff --git a/resources/tester/test_design_page_single.py b/resources/tester/test_design_page_single.py index e78ba74a2..6cdbdd9a3 100644 --- a/resources/tester/test_design_page_single.py +++ b/resources/tester/test_design_page_single.py @@ -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_진행_단계_두_칸이_보기를_따라감(): diff --git a/resources/tester/ui_checks/53-2_목록_강조.js b/resources/tester/ui_checks/53-2_목록_강조.js new file mode 100644 index 000000000..76c58b674 --- /dev/null +++ b/resources/tester/ui_checks/53-2_목록_강조.js @@ -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); +})();