From 673ba60e4c5c18846727218018b29fd6d4ecf306 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 21 Oct 2026 13:15:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(505-3=20B05=20=EA=B7=B8=EB=A6=BC=20=C2=B7?= =?UTF-8?q?=20=EA=B8=80):=20=EC=9E=84=EC=8B=9C=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=B0=BD=20=3D=20B06=20=EC=B9=B4=EB=93=9C(=ED=98=95=EC=83=81?= =?UTF-8?q?=20=EC=96=B9=EC=9D=80=20=EB=8B=A8=EB=A9=B4)=20=C2=B7=20?= =?UTF-8?q?=EB=B9=88=EC=B9=B8=20=EC=B9=A9=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EC=9C=84=20=C2=B7=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=ED=8C=90=20=EA=B0=80=EB=91=A0=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EB=B0=98=EC=84=A0=20=EB=88=84=EB=A6=84=20=ED=86=B5=EA=B3=BC=20?= =?UTF-8?q?=C2=B7=20STALE=20=EA=B8=80=20=C2=B7=20=EC=B0=BD=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EA=B3=B5=EC=9A=A9=20=EA=BC=B4=20=E2=80=94=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20309-3=20=C2=B7=20310?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측점 뜨기(design_pipeline_probe)가 형상 · 이름 얹은 단면도 돌려줌 · 창은 「횡단」 판과 같은 routeCrossCard(서버 미리 뜬 것만 선 그림) - Cross_Card 판 재료 crossSourceOf 로 나눔(창 · 판 한 벌) · 카드 머리 걷기 전 경고 칩을 그래프 오른쪽 위로 - 곡선 판 가둠 = 편집 도구줄 아래 · 열린 횡단 판 왼쪽 · 뜬 종단 판 위 · 판 크기 바뀌면 다시 가둠 - 종단 지반선 · 계획선 pointer-events none · WF_State_Stale = 「다시 확인 필요 (앞 단계 변경)」 · ui-button → ui-btn - 시험 test_505_3_cross_card_text · test_505_3_cross_card_screen(5179) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_pipeline_probe.ts | 15 +- .../B05_Profile_UI_Profile_CrossModal.css | 8 + .../B05_Profile_UI_Profile_CrossModal.ts | 60 +++-- .../B05_Profile_UI_RouteEdit_Cross_Card.ts | 22 +- .../B05_Profile_UI_RouteEdit_CurveBar.ts | 27 ++- .../B05_Profile_UI_Style_RouteEdit.css | 26 +++ .../logic/B05_Profile_Pipeline_Detail.ts | 2 +- B06_Section/B06_Section_UI_Style_Cross.css | 6 + .../tester/test_505_3_cross_card_screen.py | 217 ++++++++++++++++++ .../tester/test_505_3_cross_card_text.py | 84 +++++++ ui_template/ui_template_locale_common.ts | 2 +- 11 files changed, 438 insertions(+), 31 deletions(-) create mode 100644 resources/tester/test_505_3_cross_card_screen.py create mode 100644 resources/tester/test_505_3_cross_card_text.py diff --git a/A00_Common/design/design_pipeline_probe.ts b/A00_Common/design/design_pipeline_probe.ts index daa0da8cf..83e22c5dc 100644 --- a/A00_Common/design/design_pipeline_probe.ts +++ b/A00_Common/design/design_pipeline_probe.ts @@ -6,6 +6,7 @@ * `crossWithUphill` · `stationSection`)를 그 자리 측점 하나로 다시 부름 — [측점 추가] 뒤 묶음이 낼 측점과 * 같은 틀 · 반경 · 확폭(기존 측점 + 그 자리로 셈). 파생 저장 · 캐시 · 묶음 상태는 안 건드림. * 지형 격자가 없거나 분석 지형 ≠ 설계 지형이면 null(부르는 쪽이 서버 미리 뜨기로). + * 단면(`section`) = 그 측점에 지금 현황 형상 · 이름을 얹은 것 — 창이 「횡단」 판과 같은 B06 카드로 그림(505-3). * ========================================================================== */ import { pagePipeline } from "./design_pipeline_page"; @@ -19,27 +20,31 @@ import { probeStation } from "./logic/design_pipeline_probe_station"; import { groundStageDeps } from "../../B05_Profile/B05_Profile_Api_TerrainGrid"; import { createGroundStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; import { + attachStatus, crossWithShapes, crossAtSeat, crossWithUphill, shapeStatus, + stationSection, type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage"; import { pipelineCrossAt } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Cross"; import type { CrossPreviewResponse } from "../../B05_Profile/B05_Profile_Api_Replan"; +import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; const SAME_M = 1e-6; /** - * 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단 — 묶음이 아직 안 셌거나 격자 없음 · 분석 지형 ≠ 설계 지형이면 null. + * 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단(선 그림 재료 · 형상 얹은 단면) — 묶음이 아직 안 셌거나 + * 격자 없음 · 분석 지형 ≠ 설계 지형이면 null. */ export async function pipelineCrossAtChainage( projectId: string, chainageM: number, intervalM: number, -): Promise { +): Promise<{ preview: CrossPreviewResponse; section: CrossSection } | null> { const pipe = pagePipeline(projectId); const route = pipe?.output("route"); const profile = pipe?.output("profile"); @@ -105,5 +110,9 @@ export async function pipelineCrossAtChainage( output: (name: StageName) => name === "ground" ? { stations: [station] } : name === "cross" ? cross : null, } as unknown as Parameters[0]; - return pipelineCrossAt(one, chainageM, intervalM, undefined); + const preview = pipelineCrossAt(one, chainageM, intervalM, undefined); + if (!preview) return null; + const section = stationSection(station, cross, {}, intervalM); + attachStatus([section], status); + return { preview, section }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css index 471b4f913..4f7cff4dc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css @@ -43,6 +43,14 @@ background: var(--color-surface); } +/* B06 카드 읽기 전용(505-3) — 칸 테두리 안 · 고름 강조 · 손가락 없음 */ +.b05-crossmodal__box > .b06-cross-card { + border: 0; + border-radius: 0; + box-shadow: none; + cursor: default; +} + .b05-crossmodal__foot { color: var(--color-text-secondary); font-size: var(--text-caption); diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index aff64491e..3f76da8ac 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -5,7 +5,8 @@ * 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침 * (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산 * `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` · - * 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시. + * 저장 계획선 기준 · 글 한 줄) · 그림 = 평면 「횡단」 판과 같은 B06 카드(구조물 형상 · 포장 · 면적 · 505-3 · + * 서버 미리 뜬 것만 선 그림 `buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시. * [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가 * 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1). * ========================================================================== */ @@ -20,7 +21,7 @@ import { } from "../A00_Common/design/logic/design_profile_free"; import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../B06_Section/B06_Section_UI_Longitudinal"; import { previewStructureSections } from "../B06_Section/B06_Section_Api_Fetch"; -import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { pagePipeline, pipelineDetail } from "../A00_Common/design/design_pipeline_page"; import { pipelineCrossAtChainage } from "../A00_Common/design/design_pipeline_probe"; import { stationLabel } from "../common_util/common_util_station"; import { plusStationsOf, writePlusDraft } from "./B05_Profile_Api_PlusStations"; @@ -30,6 +31,7 @@ import { CROSS_PLOT_PAD, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; +import { crossSourceOf, routeCrossCard } from "./B05_Profile_UI_RouteEdit_Cross_Card"; import { crossPickAt, groundElevationAt, @@ -196,9 +198,9 @@ function createCrossModal(host: HTMLElement): CrossModal { 임시 횡단
- - - + + +
@@ -241,7 +243,7 @@ function createCrossModal(host: HTMLElement): CrossModal { const now = await pipelineCrossAtChainage(projectId(), m, current?.intervalM ?? 20).catch( () => null, ); - const [cross] = now ? [now] : await previewStructureSections(projectId(), [m]); + const [cross] = now ? [now.preview] : await previewStructureSections(projectId(), [m]); if (mine !== turn) return; if (!cross) { foot.textContent = "이 자리의 횡단을 뜨지 못했습니다."; @@ -249,21 +251,37 @@ function createCrossModal(host: HTMLElement): CrossModal { } const preview = cross as unknown as CrossPreviewResponse; const rect = box.getBoundingClientRect(); - const plot = buildCrossSvg( - preview, - Math.round(rect.width) || 320, - Math.round(rect.height) || 180, - ); - box.replaceChildren(plot.svg); - attachSvgZoomPan({ - svg: plot.svg, - layer: plot.layer, - widthPx: plot.width, - heightPx: plot.height, - pad: CROSS_PLOT_PAD, - content: plot.content, - wheelZoom: true, - }); + const [width, height] = [Math.round(rect.width) || 320, Math.round(rect.height) || 180]; + if (now) { + // 「횡단」 판과 같은 B06 카드(505-3) — 그림 밖 높이만큼 줄여 한 번 더 지어 칸에 맞춤 + const detail = pipelineDetail(projectId()); + const source = crossSourceOf( + projectId(), + now.section, + detail?.cross_sections ?? [now.section], + detail?.longitudinal.design_profiles, + "modal", + ); + const build = (plotHeight: number): HTMLElement => { + const card = routeCrossCard(source, current?.intervalM ?? 20, width, plotHeight); + box.replaceChildren(card); + return card; + }; + const extra = build(height).offsetHeight - height; + if (extra > 0) build(Math.max(120, height - extra)); + } else { + const plot = buildCrossSvg(preview, width, height); + box.replaceChildren(plot.svg); + attachSvgZoomPan({ + svg: plot.svg, + layer: plot.layer, + widthPx: plot.width, + heightPx: plot.height, + pad: CROSS_PLOT_PAD, + content: plot.content, + wheelZoom: true, + }); + } const planZ = Number( (preview.design as { design_elevation_m?: unknown } | null)?.design_elevation_m, ); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts index f68879dce..f99e4050e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts @@ -15,7 +15,7 @@ import "../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; import { pipelineDetail } from "../A00_Common/design/design_pipeline_page"; import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; import { nearestWithin } from "../A00_Common/design/design_selection"; -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import type { CrossSection, DesignProfile } from "../B06_Section/B06_Section_Api_Fetch"; import { peekSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { displayHalfWidth } from "../B06_Section/B06_Section_Display_Width"; import { pageRouteId } from "../A00_Common/design/design_page_route"; @@ -74,7 +74,17 @@ export function routeCrossSource( const sections = detail?.cross_sections ?? []; const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m); if (!detail || !found) return null; - const profiles = detail.longitudinal.design_profiles; + return crossSourceOf(projectId, found, sections, detail.longitudinal.design_profiles, pane); +} + +/** 단면 하나 → 판 재료(사본) — 이웃 단면 · 계획선은 연동 관 · 반폭 · 계획고 재료(종단 임시 횡단 창도 씀 · 505-3). */ +export function crossSourceOf( + projectId: string, + found: CrossSection, + sections: readonly CrossSection[], + profiles: DesignProfile[] | undefined, + pane: string, +): RouteCrossSource { const planZ = designElevationAt(profiles, found.chainage_m); return { section: { @@ -146,6 +156,14 @@ export function routeCrossCard( box, ); // 그래프만(393-3) — 카드 머리 · 줌 단추 · 발(암 경계 · 요약) 걷음 · 벽 · 선 · 면적 고름 막음 + // 경고 칩(빈칸 · 기본값 · 사면 열림)만 그래프 오른쪽 위에 겹쳐 남김(505-3) + const chips = card.querySelectorAll(":scope > header .b06-cross-card__warning"); + if (chips.length) { + const box = document.createElement("div"); + box.className = "b05-cross-chips"; + box.append(...chips); + card.append(box); + } card .querySelectorAll(":scope > header, :scope > footer, .b06-cross-card__zoom") .forEach((el) => el.remove()); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 42ab19b74..a2ed117c6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -180,6 +180,13 @@ export function createCurveBar(params: CurveBarParams): CurveBar { ); } + // 가둠 판(도구줄 · 횡단 판 · 종단 판)이 열리고 닫히고 크기가 바뀌면 다시 가둠(505-3) · 편집 창이 닫히면 끊음 + const watched = new Set(); + const refit = new ResizeObserver(() => { + if (!params.canvas.isConnected) return refit.disconnect(); + sync(); + }); + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */ function sync(): void { const { @@ -199,11 +206,25 @@ export function createCurveBar(params: CurveBarParams): CurveBar { return; } const rect = params.canvas.getBoundingClientRect(); + // 가둠 = 도구줄 아래 · 열린 횡단 판 왼쪽 · 뜬 종단 판 위(505-3 · 판 밑에 깔려 가리지 않게) + const main = params.canvas.closest(".design-page__main") ?? params.canvas.parentElement; + const box = (selector: string): DOMRect | null => { + const element = main?.querySelector(selector); + if (element && !watched.has(element)) { + watched.add(element); + refit.observe(element); + } + const found = element?.getBoundingClientRect(); + return found && found.width > 0 && found.height > 0 ? found : null; + }; + const tools = box(".b05-routeedit__tools"); + const side = box(".b05-routeedit__side-wrap"); + const profile = box(".b05-route-profile"); const bounds = { left: rect.left + 8, - top: rect.top + 8, - right: rect.right - 8, - bottom: rect.bottom - 8, + top: Math.max(rect.top, tools?.bottom ?? rect.top) + 8, + right: Math.min(rect.right, side?.left ?? rect.right) - 8, + bottom: Math.min(rect.bottom, profile?.top ?? rect.bottom) - 8, }; const last = planned.length - 1; if (picked === 0 || picked === last) { diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 4d4584636..4326777db 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -288,6 +288,32 @@ cursor: default; } +/* 카드 경고 칩(빈칸 · 기본값 · 사면 열림) — 머리를 걷어도 그래프 오른쪽 위에 겹쳐 남김(505-3) · 판 · 임시 횡단 창 같이 씀 */ +.b06-cross-card:has(> .b05-cross-chips) { + position: relative; +} + +.b05-cross-chips { + position: absolute; + top: 4px; + right: 4px; + z-index: 3; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + max-width: 70%; + pointer-events: none; +} + +.b05-cross-chips > .b06-cross-card__warning { + padding: 0 4px; + border-radius: var(--radius-4, 4px); + background: color-mix(in srgb, var(--color-surface-raised) 85%, transparent); + font-size: var(--text-caption); + pointer-events: auto; +} + .b05-routeedit__cross-foot:empty { display: none; } diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index b7ed1724d..68b790170 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -231,7 +231,7 @@ export function stationSection( /** 측점마다 그림 자료 · 카드 이름 = 지금 현황을 그 자리에 다시 얹음(캐시 `withDraftShapes` · 이름 = 측점 계획 * 제목 · 457) — 옛 측점 그림을 옮기지 않음. */ -function attachStatus(sections: CrossSection[], status: ShapeStatus): void { +export function attachStatus(sections: CrossSection[], status: ShapeStatus): void { const { pipes, structures, types } = status; attachShapes(sections as unknown as Array>, pipes, structures, types); const planned = planStations(pipes, structures, types, registryDefaults(types)); diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 7b5c83036..bf1a2099b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -436,6 +436,12 @@ html.is-bm-route .b06-section__notice-bar { stroke-width: 2.5; } +/* 지반선 · 계획선은 누름을 통과 — 겹친 측점선 누름이 측점 고름 · 임시 횡단으로 감(505-3) */ +.b06-chart__profile, +.b06-chart__design-profile { + pointer-events: none; +} + .b06-chart__cross-profile { fill: none; stroke: var(--color-chart-1); diff --git a/resources/tester/test_505_3_cross_card_screen.py b/resources/tester/test_505_3_cross_card_screen.py new file mode 100644 index 000000000..61dd405b5 --- /dev/null +++ b/resources/tester/test_505_3_cross_card_screen.py @@ -0,0 +1,217 @@ +"""PLAN 505-3 화면 — 자기 포트(5179) Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. + +- 종단 측점선 · 원지반선 겹친 자리 누름 = 측점선이 받음(지반선 누름 통과) → 임시 횡단 창 = B06 카드 + 「⚠ 배관 관경 빈칸」 칩 +- 관경 800(세션만 · [저장] 안 함) → 창 카드에 관 형상 · Ctrl+휠 확대가 창 칸 밖으로 안 넘침 · 밝음 · 어두움 한 장씩 +- 곡선 판 = 도구줄 아래 · 열린 횡단 판 왼쪽(판을 나중에 열어도 비킴) +- 계정 화면 취향(종단 · 횡단 판 열림 · 높이)만 시험 앞 값으로 되돌림 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_505_3_cross_card_screen.py -q +""" + +from __future__ import annotations + +import json +import urllib.request +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BASE = "http://localhost:5179" +PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md) +SHOTS = ROOT / "tmp" / "shots_505_3" +PIPE_M = 388 # 검증_sub4 배관 하나(관경 빈칸 · 19+8) +PREFS = "/api/dashboard/me/ui-prefs" +# 이 시험이 건드리는 취향만 되돌림 — 계정을 다른 창도 같이 써서 통째로 덮으면 남의 값이 사라짐 +TOUCHED = ("profile-collapsed", "profile-height", "b05-routeedit-cross-v2") + + +def _up() -> bool: + try: + urllib.request.urlopen(BASE, timeout=3) + return True + except Exception: + return False + + +pytestmark = pytest.mark.skipif(not _up(), reason="5179 서버 없음") + +# 측점 m 을 가로 가운데로 · 그 측점선과 원지반선 교점(화면 좌표) · 그 자리 맨 위 요소 +JS_SCROLL = r"""(m) => { + const marker = [...document.querySelectorAll('.b06-chart__station')] + .find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6); + const body = marker.closest('.b05-route-profile__body'); + body.scrollLeft = Math.max(0, +marker.querySelector('line').getAttribute('x1') - body.clientWidth / 2); +}""" +JS_POINT = r"""(m) => { + const marker = [...document.querySelectorAll('.b06-chart__station')] + .find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6); + const line = marker.querySelector('.b06-chart__station-line'); + const svg = line.ownerSVGElement; + const x = +line.getAttribute('x1'); + const poly = svg.querySelector('.b06-chart__profile'); + const pts = poly.getAttribute('points').trim().split(/\s+/).map((s) => s.split(',').map(Number)); + const k = Math.max(1, pts.findIndex((p) => p[0] >= x)); + const [a, b] = [pts[k - 1], pts[k]]; + const pt = svg.createSVGPoint(); + pt.x = x; + pt.y = a[1] + ((b[1] - a[1]) * (x - a[0])) / (b[0] - a[0] || 1); + const s = pt.matrixTransform(poly.getScreenCTM()); + return { x: s.x, y: s.y, hit: document.elementFromPoint(s.x, s.y)?.getAttribute('class') }; +}""" +JS_MODAL = r"""() => { + const r = document.querySelector('.b05-crossmodal'); + const card = r.querySelector('.b06-cross-card'); + const box = r.querySelector('.b05-crossmodal__box').getBoundingClientRect(); + const svg = card?.querySelector('svg').getBoundingClientRect(); + return { + station: r.querySelector('.b05-crossmodal__station').textContent, + card: !!card, + chips: [...r.querySelectorAll('.b05-cross-chips > *')].map((e) => e.textContent), + pipes: card ? card.querySelectorAll('.b06-chart__culvert-pipe').length : 0, + inside: !!svg && svg.top >= box.top && svg.bottom <= box.bottom + 1 && svg.right <= box.right + 1, + width: Math.round(r.getBoundingClientRect().width), + buttons: [...r.querySelectorAll('.b05-crossmodal__actions button')].map((e) => e.className), + scale: [...(card?.querySelectorAll('svg g[transform]') ?? [])] + .map((g) => g.getAttribute('transform')).find((t) => /^translate\([^)]*\) scale\(/.test(t)) ?? '', + }; +}""" +JS_RECTS = r"""() => { + const q = (s) => [...document.querySelectorAll(s)].find((x) => x.getClientRects().length); + const r = (s) => { const o = q(s)?.getBoundingClientRect(); return o ? [o.left, o.top, o.right, o.bottom] : null; }; + return { label: r('.b05-routeedit__label'), tools: r('.b05-routeedit__tools'), side: r('.b05-routeedit__side-wrap') }; +}""" + + +@pytest.fixture(scope="module") +def page(): + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + ctx = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + assert ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok + prefs = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {}) + tab = ctx.new_page() + tab.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')") + tab.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + tab.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + tab.wait_for_timeout(8000) + SHOTS.mkdir(parents=True, exist_ok=True) + try: + yield tab + finally: + tab.wait_for_timeout(2000) # 취향 올리기(1.5 s 모음) 끝난 뒤 되돌림 + now = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {}) + for key in (f"aislo:pref:{name}" for name in TOUCHED): + if key in prefs: + now[key] = prefs[key] + else: + now.pop(key, None) + assert ctx.request.put(f"{BASE}{PREFS}", data={"prefs": now}).ok + chrome.close() + + +def _profile(page, open_: bool) -> None: + height = page.evaluate( + "() => document.querySelector('.b05-route-profile')?.getBoundingClientRect().height ?? 0" + ) + if (height > 100) != open_: # 접힘 = 높이 1 px(측점 요소는 DOM 에 남음) + page.get_by_text("종단", exact=True).last.click() + page.wait_for_timeout(3000) + + +def _open_modal(page) -> dict: + _profile(page, True) + page.evaluate(JS_SCROLL, PIPE_M) + page.wait_for_timeout(800) + at = page.evaluate(JS_POINT, PIPE_M) + assert "b06-chart__station-line" in (at["hit"] or ""), at # 지반선이 아니라 측점선이 받음 + page.mouse.move(at["x"], at["y"]) + page.wait_for_timeout(300) + page.mouse.click(at["x"], at["y"]) + page.wait_for_function( + "() => document.querySelector('.b05-crossmodal .b06-cross-card')", timeout=30000 + ) + page.wait_for_timeout(1500) + return page.evaluate(JS_MODAL) + + +def test_0_곡선_판_도구줄_횡단판_비킴(page): + _profile(page, False) + rects = page.evaluate(JS_RECTS) + if rects["side"][2] - rects["side"][0] > 100: # 횡단 판 접고 시작 + page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click() + page.wait_for_timeout(2000) + shown = "() => [...document.querySelectorAll('.b05-routeedit__label')].some((x) => !x.hidden)" + for _ in range(3): # 첫 누름이 앞 고름(구조물) 풂으로 갈 수 있음 + page.mouse.click(1199, 296) # 검증_sub4 첫 화면의 14번째 꺾임점 + page.wait_for_timeout(1500) + if page.evaluate(shown): + break + page.screenshot(path=str(SHOTS / "curve_pick.png")) + assert page.evaluate(shown) + # 지도를 위 · 오른쪽으로 밀어 꺾임점을 도구줄 · 오른쪽 끝 밑으로 + page.mouse.move(800, 650) + page.mouse.down(button="middle") + page.mouse.move(1050, 380, steps=16) + page.mouse.up(button="middle") + page.wait_for_timeout(1200) + rects = page.evaluate(JS_RECTS) + assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5 + page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click() + page.wait_for_timeout(2500) + rects = page.evaluate(JS_RECTS) + assert rects["label"][2] <= rects["side"][0] - 8 + 0.5 # 판을 나중에 열어도 비킴 + assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5 + page.screenshot(path=str(SHOTS / "curve_light.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + page.screenshot(path=str(SHOTS / "curve_dark.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + page.keyboard.press("Escape") # 꺾임점 고름 풂 + + +def test_1_빈칸_칩_카드(page): + modal = _open_modal(page) + assert modal["station"] == "19+8" and modal["card"] + assert modal["chips"] == ["⚠ 배관 관경 빈칸"] + assert modal["pipes"] == 0 and modal["width"] == 380 + assert all("ui-btn ui-btn--ghost ui-btn--sm" in name for name in modal["buttons"]) + page.locator(".b05-crossmodal").get_by_text("닫기").click() + + +def test_2_횡단_판_칩_관경_넣으면_형상_확대_밝음_어두움(page): + _profile(page, False) + page.locator(".b05-route__irregular-item", has_text="19+8").first.click() + page.wait_for_timeout(3000) + chips = page.eval_on_selector_all( + ".b05-routeedit__cross .b05-cross-chips > *", "(els) => els.map((e) => e.textContent)" + ) + assert "⚠ 배관 관경 빈칸" in chips # 「횡단」 판 = 카드 머리를 걷어도 칩은 그래프 오른쪽 위 + page.locator(".b05-routeedit__cross").first.screenshot(path=str(SHOTS / "panel_chip.png")) + if not page.locator("select:visible").filter(has=page.locator("option[value='800']")).count(): + page.get_by_text("구조물 배치", exact=True).click() + page.wait_for_timeout(1500) + page.locator("select:visible").filter( + has=page.locator("option[value='800']") + ).first.select_option("800") # 세션 초안만 — [저장] 안 누름 + page.wait_for_timeout(5000) + modal = _open_modal(page) + assert modal["chips"] == [] and modal["pipes"] > 0 + shot = page.locator(".b05-crossmodal") + shot.screenshot(path=str(SHOTS / "modal_light.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + shot.screenshot(path=str(SHOTS / "modal_dark.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + box = page.locator(".b05-crossmodal__box").bounding_box() + cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 + page.evaluate( + "([x, y]) => document.elementFromPoint(x, y).dispatchEvent(new WheelEvent('wheel'," + " {deltaY: 300, ctrlKey: true, clientX: x, clientY: y, bubbles: true, cancelable: true}))", + [cx, cy], + ) + page.wait_for_timeout(800) + zoomed = page.evaluate(JS_MODAL) + assert "scale(1.15)" in zoomed["scale"] and zoomed["inside"] + page.locator(".b05-crossmodal").get_by_text("닫기").click() diff --git a/resources/tester/test_505_3_cross_card_text.py b/resources/tester/test_505_3_cross_card_text.py new file mode 100644 index 000000000..05ecc02e1 --- /dev/null +++ b/resources/tester/test_505_3_cross_card_text.py @@ -0,0 +1,84 @@ +"""505-3 B05 그림 · 글 — 임시 횡단 창 = B06 카드(형상 얹은 단면) · 빈칸 칩 그래프 오른쪽 위 · 곡선 판 가둠 · +지반선 · 계획선 누름 통과 · STALE 글 · 창 단추 공용 꼴. + +./venv/Scripts/python.exe -m pytest resources/tester/test_505_3_cross_card_text.py -q +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +def test_측점_뜨기는_형상_얹은_단면도_돌려줌() -> None: + probe = _read("A00_Common", "design", "design_pipeline_probe.ts") + assert "Promise<{ preview: CrossPreviewResponse; section: CrossSection } | null>" in probe + assert "const section = stationSection(station, cross, {}, intervalM);" in probe + assert "attachStatus([section], status);" in probe + detail = _read("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts") + assert "export function attachStatus(sections: CrossSection[], status: ShapeStatus)" in detail + + +def test_임시_횡단_창은_B06_카드() -> None: + modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts") + assert "crossSourceOf(" in modal and "routeCrossCard(source," in modal + assert "now ? [now.preview] : await previewStructureSections(projectId(), [m])" in modal + # 서버 미리 뜬 것(분석 지형 다름)만 선 그림 + assert "buildCrossSvg(preview, width, height)" in modal + assert 'class="ui-button"' not in modal + assert modal.count('class="ui-btn ui-btn--ghost ui-btn--sm" data-act=') == 3 + css = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.css") + assert ".b05-crossmodal__box > .b06-cross-card {" in css + + +def test_판_재료_한_벌() -> None: + card = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts") + assert ( + "return crossSourceOf(projectId, found, sections, detail.longitudinal.design_profiles, pane);" + in card + ) + assert card.count("export function crossSourceOf(") == 1 + + +def test_빈칸_칩은_머리_걷기_전에_꺼냄() -> None: + card = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts") + chips = card.index('":scope > header .b06-cross-card__warning"') + remove = card.index(':scope > header, :scope > footer, .b06-cross-card__zoom"') + assert chips < remove + assert 'box.className = "b05-cross-chips";' in card + css = _read("B05_Profile", "B05_Profile_UI_Style_RouteEdit.css") + rule = css[css.index(".b05-cross-chips {") :] + assert "top: 4px;" in rule[:200] and "right: 4px;" in rule[:200] + assert ".b06-cross-card:has(> .b05-cross-chips) {\n position: relative;" in css + + +def test_곡선_판_가둠_도구줄_횡단판_종단판() -> None: + bar = _read("B05_Profile", "B05_Profile_UI_RouteEdit_CurveBar.ts") + for selector in ( + ".b05-routeedit__tools", + ".b05-routeedit__side-wrap", + ".b05-route-profile", + ): + assert f'box("{selector}")' in bar + assert "top: Math.max(rect.top, tools?.bottom ?? rect.top) + 8," in bar + assert "right: Math.min(rect.right, side?.left ?? rect.right) - 8," in bar + assert "bottom: Math.min(rect.bottom, profile?.top ?? rect.bottom) - 8," in bar + + +def test_지반선_계획선_누름_통과() -> None: + css = _read("B06_Section", "B06_Section_UI_Style_Cross.css") + assert ".b06-chart__profile,\n.b06-chart__design-profile {\n pointer-events: none;" in css + + +def test_STALE_글_앞_단계() -> None: + locale = _read("ui_template", "ui_template_locale_common.ts") + assert ( + 'WF_State_Stale: ["다시 확인 필요 (앞 단계 변경)", "Stale (an earlier step changed)"],' + in locale + ) + assert "하위 단계" not in locale diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index 41d1c8620..3aa65d7c6 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -85,7 +85,7 @@ export const ui_locales_common = { WF_Step_DesignDetail: ["상세설계", "Detail Design"], WF_Step_Quantity: ["수량산출", "Quantity"], WF_Step_Estimation: ["원가계산", "Cost Estimate"], - WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"], + WF_State_Stale: ["다시 확인 필요 (앞 단계 변경)", "Stale (an earlier step changed)"], WF_State_Failed: ["실패", "Failed"], WF_State_Complete: ["완료", "Complete"], WF_State_InProgress: ["진행 중", "In Progress"],