diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index bb293e404..951564e82 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -148,6 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { if (event.button === 1) event.preventDefault(); }); svg.addEventListener("pointerdown", (event) => { + // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1) + moved = false; // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; event.preventDefault(); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7c09a92ed..7bda5f9f9 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -638,7 +638,7 @@ export type CrossCellSource = /** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */ export interface CrossPickPiece { - line: "design" | "ground" | "spoil" | "structure"; + line: "design" | "ground" | "spoil" | "structure" | "rock"; start: [number, number]; end: [number, number]; } diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index fae03161c..05740c7ef 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel): class CrossPickPiece(BaseModel): """[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고).""" - line: Literal["design", "ground", "spoil", "structure"] + line: Literal["design", "ground", "spoil", "structure", "rock"] start: list[float] = Field(..., min_length=2, max_length=2) end: list[float] = Field(..., min_length=2, max_length=2) diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts index f137535dc..3f15d056d 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -44,6 +44,16 @@ export function groundRuns(samples: ReadonlyArray | undefined): P const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) => (raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite)); +/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */ +export function rockRuns( + design: CrossDesign | undefined, + samples: ReadonlyArray | undefined, +): Pt[][] { + const offset = design?.rock_boundary_offset_m; + if (design?.geometry_preset !== "rock" || typeof offset !== "number") return []; + return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset])); +} + /** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */ export function pickLines( design: CrossDesign | undefined, @@ -53,6 +63,7 @@ export function pickLines( design: [polyline(design?.design_line)], ground: groundRuns(samples), spoil: [polyline(design?.spoil_fill_line)], + rock: rockRuns(design, samples), }; } diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 2a5aca824..b3d8442c4 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Cross_Detail_Select.ts - * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로 + * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로 * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). @@ -79,6 +79,8 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli else if (el.classList.contains("b06-chart__cross-profile")) others.push({ line: "ground", points }); else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); + else if (el.classList.contains("b06-chart__rock-boundary")) + others.push({ line: "rock", points }); } const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({ line: piece.structure ? "structure" : "design", @@ -102,6 +104,7 @@ const LINE_NAME: Record = { structure: "구조물 선", ground: "원지반선", spoil: "사토장선", + rock: "암 경계선", }; /** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ @@ -111,7 +114,11 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie const [piece] = pieces; const from = piece.points[0]; const to = piece.points[piece.points.length - 1]; - if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil") + if ( + design && + piece.points.length === 2 && + (piece.line === "design" || piece.line === "structure") + ) return pieceLabel(design, { from: { offset: from[0], elevation: from[1] }, to: { offset: to[0], elevation: to[1] }, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py index d522ab3f9..3cc85c50f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -181,12 +181,21 @@ def ring_area(paths: list[list[Point]]) -> float | None: return area if area > 1e-9 else None +def rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]: + """암 경계선 — 원지반선 + `rock_boundary_offset_m`(암 프리셋만 · 카드 그리기와 같은 규칙).""" + offset = _num(design.get("rock_boundary_offset_m")) + if design.get("geometry_preset") != "rock" or offset is None: + return [] + return [[(x, y + offset) for x, y in run] for run in ground_runs(samples)] + + def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: - """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선).""" + """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선).""" return { "design": [_design_line(design)], "ground": ground_runs(samples), "spoil": [_polyline(design.get("spoil_fill_line"))], + "rock": rock_runs(design, samples), } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 726387ccb..476ede05d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -11,7 +11,7 @@ * 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) · * 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 - * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 + * 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지) * 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ @@ -25,7 +25,6 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; import { roadOf, type Dest, @@ -33,7 +32,9 @@ import { type MilestoneRow, type Road, } from "./B08_Quantity_Milestone_Calc"; +import { setLeg } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { findSegmentKm, @@ -245,27 +246,26 @@ export interface SiteAddress { mid_address: string | null; } -/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ +/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ title: "공사지 — 시점 · 중점", closeLabel: "닫기", + dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { - const text = (label: string, value: string): HTMLElement => - el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); - // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) + // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) · 보이고 받는 것만 km(255-1) const data = liveMilestone(projectId); const site = siteInfo(); - const km = (m: number | null | undefined): string => - m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); const mid = createInputField({ - label: "중점 측점 (m)", + label: "중점 측점 (km)", type: "number", min: 0, compact: true, }); - mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; - mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); + mid.input.step = "0.001"; + mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m / 1000) : ""; + mid.input.value = + data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", @@ -277,40 +277,39 @@ export function openSiteInfo(projectId: string): void { label: "수정", variant: "filled", onClick: () => { + const text = mid.input.value; + const value = Number(text); + const km = text.trim() && Number.isFinite(value) && value >= 0 ? value : null; const fresh = liveMilestone(projectId); - const value = Number(mid.input.value); - fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; - fresh.site.mid_road = road.select.value as Road; - putMilestone(projectId, fresh); + putMilestone( + projectId, + setLeg(fresh, "start", 0, { road: road.select.value as Road, km }), + ); close(); }, }); save.dataset.b08MilestoneSite = "save"; actions.append(save); - const map = buildAddressMap(); - const info = el("div", { children: [text("주소", "불러오는 중…")] }); - body.append( - line(mid.root, road.root), - text( - "시점 = 노선 첫 점(측점 0)", - `노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`, - ), - info, - map.root, - ); - request(`/projects/${projectId}/quantity/milestone/site-address`).then( - (s) => { - const ll = (p: [number, number] | null): string => - p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : ""; - info.replaceChildren( - text("프로젝트 등록 주소(사업지역)", s.region || "없음"), - text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)), - text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)), - ); - if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]); - else if (s.region) void map.show(s.region); - }, - (error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"), + const map = editMap(projectId, { hint: "", onMove: () => undefined }); + const pins = (s: { + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; + }): MapPin[] => + ( + [ + ["start", "시점", s.start_lonlat], + ["mid", "중점", s.mid_lonlat], + ] as const + ).flatMap(([key, name, p]) => + p ? [{ key, name, lon: p[0], lat: p[1], movable: false }] : [], + ); + if (site) map.set(pins(site), []); + body.append(line(mid.root, road.root), map.root); + // 지금 중점 측점(캐시)의 중점 좌표 — 오면 표지 다시 + const query = data.site.mid_station_m == null ? "" : `?mid_m=${data.site.mid_station_m}`; + request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( + (s) => map.set(pins(s), []), + (error: unknown) => showToast(`공사지 좌표를 못 읽었습니다 — ${String(error)}`, "error"), ); }, }); diff --git a/resources/tester/helper_259_detail_region.cjs b/resources/tester/helper_259_detail_region.cjs new file mode 100644 index 000000000..61139b6dd --- /dev/null +++ b/resources/tester/helper_259_detail_region.cjs @@ -0,0 +1,133 @@ +/* PLAN 259 단위검증 헬퍼 — [상세] 공용 팬 누름 풂 · 암 경계선 나눔 · 닫힌 영역 · 표 칸 → 그래프 고름. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes, rock} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_259_detail_region.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const Z = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +/* ① 공용 팬 — 가운데 단추 팬(click 없음) 뒤 왼쪽 누름 click 이 안 삼켜짐 */ +function panCase(panStart) { + const listeners = {}; + const svg = { + addEventListener: (name, fn) => (listeners[name] ??= []).push(fn), + setAttribute: () => {}, + classList: { add: () => {}, remove: () => {} }, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 600, height: 300 }), + }; + const layer = { setAttribute: () => {} }; + Z.attachSvgZoomPan({ + svg, + layer, + widthPx: 600, + heightPx: 300, + pad: { left: 10, right: 10, top: 10, bottom: 10 }, + content: { x: -600, y: -300, width: 1800, height: 900 }, + panStart, + }); + const fire = (name, init = {}) => { + let stopped = false; + const event = { + button: 0, + clientX: 0, + clientY: 0, + pointerId: 1, + preventDefault: () => {}, + stopPropagation: () => (stopped = true), + ...init, + }; + (listeners[name] ?? []).forEach((fn) => fn(event)); + return stopped; + }; + const drag = (button) => { + fire("pointerdown", { button, clientX: 100, clientY: 100 }); + fire("pointermove", { button, clientX: 140, clientY: 120 }); + fire("pointerup", { button, clientX: 140, clientY: 120 }); + }; + const click = (x = 50) => { + fire("pointerdown", { clientX: x, clientY: 50 }); + fire("pointerup", { clientX: x, clientY: 50 }); + return fire("click", { clientX: x, clientY: 50 }); + }; + return { drag, click, fire }; +} +{ + const middle = panCase(); + middle.drag(1); // 가운데 단추 팬 — 브라우저는 click 을 안 보냄(auxclick) + out.panMiddle = { firstClickStopped: middle.click(), secondClickStopped: middle.click() }; + // 거리이정표 꼴 — 빈 곳 왼쪽 끌기 팬(끌기로 끝난 click 은 막고 · 다음 누름은 통과) + const left = panCase((event) => event.button === 1 || event.button === 0); + left.drag(0); + // 끌기 끝 click(왼쪽은 브라우저가 보냄) — 막혀야 함 + out.panLeft = { afterDragStopped: left.fire("click"), nextClickStopped: left.click() }; +} + +/* ② 암 경계선 — 그림 요소 → 조각(설계선과 만나는 점에서 나눔) */ +const px = ([offset, elevation]) => + [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ].join(","); +const el = (classes, points, attrs = {}) => ({ + tagName: "polyline", + classList: { contains: (name) => classes.includes(name) }, + hasAttribute: (name) => name in attrs, + getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)), +}); +const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]); +const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]); +const rockPts = G.rockRuns(design, samples)[0]; +const elements = [ + el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }), + el(["b06-chart__cross-profile"], groundPts), + el(["b06-chart__rock-boundary"], rockPts), +]; +const pieces = S.collectPieces(elements, axes); +out.pieces = pieces; +const rockPieces = pieces.filter((p) => p.line === "rock"); +out.rockLabel = S.selectionLabel(design, [rockPieces[1]]); +out.rockSource = S.selectionSource("length", [rockPieces[1]]); +out.rockLocal = T.localCellValue(out.rockSource, design, samples); +out.rockLength = G.pathLength(rockPieces[1].points); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index c29c60cf8..b36718fc9 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -150,10 +150,8 @@ def test_화면_모달_팬줌_그림탭(): place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts") assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place assert "searchAddress(text)" in place - assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도 - # ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도 - assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal - assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal + # ② 시점 모달 — 지도 = 공용 카카오 지도(255-1 · 주소 줄 지움) + assert "/quantity/milestone/site-address" in modal and "editMap(projectId" in modal # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 for text in ('"수정"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): assert text in add diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index ce9f8815e..93228518e 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -116,8 +116,8 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): assert "milestoneCardBody" not in _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") site = modal[modal.index("export function openSiteInfo") :] - assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site - assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site + assert 'label: "중점 측점 (km)"' in site and 'label: "시점 → 중점 도로"' in site + assert 'setLeg(fresh, "start", 0, {' in site and "putMilestone(" in site # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 254 여럿 · 삭제 = 그 기준점만 base = modal[ modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") diff --git a/resources/tester/test_255_1_b08_milestone_site_modal.py b/resources/tester/test_255_1_b08_milestone_site_modal.py new file mode 100644 index 000000000..e944a6d05 --- /dev/null +++ b/resources/tester/test_255_1_b08_milestone_site_modal.py @@ -0,0 +1,51 @@ +"""255-1 거리이정표 시점 모달 — 중점 측점 km 로 보이고 받음(저장은 m) · 설명 줄 지움 · +지도 = 공용 카카오 지도(시점 · 중점 표지).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _site(km): + data = {"site": {"mid_station_m": 999, "mid_road": "비포장"}, "base": {}, "routes": []} + return _node(f'setLeg({json.dumps(data)}, "start", 0, {{ km: {json.dumps(km)} }}).site') + + +def test_중점_km_저장_m(): + assert _site(1.2345)["mid_station_m"] == 1234.5 + assert _site(0.0005)["mid_station_m"] == 0.5 + assert _site(None)["mid_station_m"] is None # 빈칸 = 노선 ÷ 2 + + +def test_시점_모달_km_카카오_지도(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + site = modal[modal.index("export function openSiteInfo") :] + assert 'label: "중점 측점 (km)"' in site + assert "String(data.site.mid_station_m / 1000)" in site # 보임 = km + assert 'setLeg(fresh, "start", 0, {' in site # 받음 km → 저장 m + # 설명 줄 · 등록 주소 · 브이월드 주소 줄 · B01 타일 지도 없음 + for gone in ("시점 = 노선 첫 점", "프로젝트 등록 주소", "브이월드 주소", "buildAddressMap"): + assert gone not in modal + assert "editMap(projectId" in site + assert '["start", "시점", s.start_lonlat]' in site and '["mid", "중점", s.mid_lonlat]' in site + assert 'dialogClass: "b08-milestone__modal"' in site # 다른 모달과 같은 창 · 지도 높이 diff --git a/resources/tester/test_259_detail_region.py b/resources/tester/test_259_detail_region.py new file mode 100644 index 000000000..4a0c3b66e --- /dev/null +++ b/resources/tester/test_259_detail_region.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""PLAN 259 — B06 [상세] 영역 · 길이 · 표 강조. + +못박는 것 + - 259-1 공용 팬(`A00_Common/b_svg_zoom_pan.ts`) — 가운데 단추 팬 뒤 왼쪽 누름 click 이 안 삼켜짐 · + 거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음. + - 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 · + 암 프리셋 아니면 다시 고름. + +./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value, pick_lines + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)] +DESIGN = { + "design_elevation_m": 98.0, + "geometry_preset": "rock", + "rock_boundary_offset_m": -1.0, + "road_edges": { + "left": {"offset_m": 2.0, "elevation_m": 98.0}, + "right": {"offset_m": -2.0, "elevation_m": 98.0}, + }, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] +AXES = { + "widthPx": 600, + "heightPx": 300, + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 110, + "elevationMid": 100, + "exaggeration": 2, +} +ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_259_detail_region.cjs")], + input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 259-1 공용 팬 ──────────────────────────────────────────────────────── + + +def test_가운데_단추_팬_뒤_왼쪽_누름_통과(out): + assert out["panMiddle"] == {"firstClickStopped": False, "secondClickStopped": False} + + +def test_거리이정표_왼쪽_끌기_팬은_끌기_click_만_막음(out): + assert out["panLeft"] == {"afterDragStopped": True, "nextClickStopped": False} + + +# ── 259-1 암 경계선 나눔 · 고름 ─────────────────────────────────────────── + + +def test_암_경계선이_설계선과_만나는_점에서_나뉨(out): + rock = [p for p in out["pieces"] if p["line"] == "rock"] + assert len(rock) == 3 + ends = sorted(round(p["points"][0][0], 4) for p in rock) + assert ends == pytest.approx([-10.0, -ROCK_X, ROCK_X], abs=1e-3) + # 설계 경사도 암 경계선에서 잘림(경사 끝 · 암 교점 · 원지반 교점) + design_ends = { + round(q[0], 3) + for p in out["pieces"] + if p["line"] == "design" + for q in (p["points"][0], p["points"][-1]) + } + assert round(ROCK_X, 3) in design_ends + + +def test_암_경계선_고름_값_저장_꼴(out): + length = 2 * ROCK_X + assert out["rockLength"] == pytest.approx(length, abs=1e-3) + assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m" + source = out["rockSource"] + assert source["src"] == "length" and source["pieces"][0]["line"] == "rock" + patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}}) + assert patch.model_dump()["detail_table"]["cells"]["fence"]["pieces"][0]["line"] == "rock" + + +def test_암_경계선_서버_거울(out): + number, _text, again = cell_value(out["rockSource"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(out["rockLocal"], abs=1e-6) + assert pick_lines(DESIGN, SAMPLES)["rock"] == [[(x, 99.0) for x in (-10, -5, 0, 5, 10)]] + # 토사 프리셋이면 암 경계선 없음 → 다시 고름 + soil = {**DESIGN, "geometry_preset": "soil"} + assert pick_lines(soil, SAMPLES)["rock"] == [] + assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True)