fix(505-3 B05 그림 · 글): 임시 횡단 창 = B06 카드(형상 얹은 단면) · 빈칸 칩 그래프 오른쪽 위 · 곡선 판 가둠 · 지반선 누름 통과 · STALE 글 · 창 단추 공용 꼴 — 요구 309-3 · 310

- 측점 뜨기(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 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:15:54 +09:00
co-authored by Claude Opus 5.5
parent aaadbd295e
commit 673ba60e4c
11 changed files with 438 additions and 31 deletions
+12 -3
View File
@@ -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<CrossPreviewResponse | null> {
): 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<typeof pipelineCrossAt>[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 };
}
@@ -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);
@@ -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 {
<strong>임시 횡단</strong>
<span class="b05-crossmodal__station"></span>
<div class="b05-crossmodal__actions">
<button type="button" class="ui-button" data-act="add">측점 추가</button>
<button type="button" class="ui-button" data-act="remove">삭제</button>
<button type="button" class="ui-button" data-act="close">닫기</button>
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="add">측점 추가</button>
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="remove">삭제</button>
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="close">닫기</button>
</div>
</div>
<div class="b05-crossmodal__box"></div>
@@ -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,
);
@@ -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());
@@ -180,6 +180,13 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
);
}
// 가둠 판(도구줄 · 횡단 판 · 종단 판)이 열리고 닫히고 크기가 바뀌면 다시 가둠(505-3) · 편집 창이 닫히면 끊음
const watched = new Set<Element>();
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) {
@@ -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;
}
@@ -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<Record<string, unknown>>, pipes, structures, types);
const planned = planStations(pipes, structures, types, registryDefaults(types));
@@ -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);
@@ -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()
@@ -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
+1 -1
View File
@@ -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"],