diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 062d250ed..6dd9a018e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -52,6 +52,7 @@ import { import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; +import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel"; @@ -335,6 +336,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.redraw(); }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), + // 라벨 흙막이 상자 수 = 폼과 같은 유출 단 수(54-1 · 54-14) + containerCount: (structure) => + structure.type_id === "pipe" + ? (id) => (id === "soil_add" ? pipeExtraCount(structure.chainage_m ?? null) : undefined) + : undefined, }); // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts new file mode 100644 index 000000000..a627c2640 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Balloon.ts + * 종단 구조물 알약 정보 라벨 줄(54-14) — 화면 요소 없는 순수 규칙. B05 · B06 알약 레인이 같이 씀. + * + * · 줄 = 종류 이름(기타 = 손 이름) · 기준 측점(구간 = 시작 ~ 끝) · 컨테이너마다 첫 옵션 값 한 줄 + * (「관: 관경 1000」) · 컨테이너가 없으면 앞 두 줄만 · 메모 · 그 밖 옵션은 안 씀 + * · 컨테이너 = 좌측 패널 폼과 같은 상자 — 명세 종류(`STRUCTURE_SPECS`)는 명세 컨테이너(선택 컨테이너는 + * 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`) + * · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀) + * · 빈 값 = 등록부 기본값 「(기본)」 · 값 `auto` = 「(자동)」(54-13 표시와 같음) · 그것도 없으면 「—」 + * ========================================================================== */ + +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; +import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; +import { + branchOf, + choiceLabel, + choicesOf, + containerTitle, + itemLabel, + readState, + type Instance, + type SpecContainer, + type SpecItem, + type StructureSpec, +} from "../A00_Common/design/design_structure_spec"; + +const DEFAULT_MARK = "(기본)"; +const AUTO_MARK = "(자동)"; +const NONE = "—"; + +/** 선택 컨테이너 개수를 밖에서 정함(배관 흙막이 = 유출 단 수 · 폼 `hooks.count` 와 같은 값). */ +export type ContainerCount = (containerId: string) => number | null | undefined; + +/** 라벨 줄 — [이름, 기준 측점, ...컨테이너 줄]. */ +export function balloonLines( + structure: StructureInstance, + type: StructureType | undefined, + head: { name: string; position: string }, + count?: ContainerCount, +): string[] { + const spec = STRUCTURE_SPECS[structure.type_id]; + const rest = spec + ? specLines(spec, structure, type, count) + : groupLines(structure.options ?? {}, type); + return [head.name, head.position, ...rest]; +} + +/** 명세 컨테이너 줄 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름. */ +function specLines( + spec: StructureSpec, + structure: StructureInstance, + type: StructureType | undefined, + count?: ContainerCount, +): string[] { + let names = type?.spec_names ?? {}; + if (structure.type_id === "pipe") { + // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) + const { soil_add: _old, ...pipeNames } = names; + names = pipeNames; + } + const state = formState(spec, structure.type_id, structure.options ?? {}); + // 빈 칸이 그림에서 쓰는 값 — 등록부 기본값을 같은 길로 읽은 필수 컨테이너 값 + const fallback = formState(spec, structure.type_id, registryDefaults(type)); + const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + return spec.containers.flatMap((c) => { + const title = containerTitle(c, names, groups[c.link]); + return instancesOf(c, state[c.id] ?? [], count).map((values, index) => { + const item = c.items.find( + (entry) => entry.kind !== "auto" && (!entry.by || branchOf(entry, values)), + ); + const head = c.required ? title : `${title} ${index + 1}`; + const base = c.required ? (fallback[c.id]?.[0]?.[item?.id ?? ""] ?? "") : ""; + return item + ? `${head}: ${itemLabel(c, item, names)} ${specValue(c, item, values, names, base)}` + : `${head}: ${NONE}`; + }); + }); +} + +/** 저장 값 → 폼 칸 값 — 폼(`Drainage_Facility.setFacility`)과 같은 손질(BOX 옛 설치 · 본체 규격 · + * 세월교 옛 날개벽). */ +function formState( + spec: StructureSpec, + typeId: string, + options: Record, +): Record { + const input = + typeId === "box_culvert" + ? boxOptionsIn(options) + : typeId === "ford_bridge" + ? withLegacyGuards(options) + : options; + const state = readState(spec, input); + // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 + if (typeId === "box_culvert") state.body = [{ ...state.body?.[0], ...bodyOf(options) }]; + return state; +} + +/** 보이는 인스턴스 — 필수 = 한 벌 · 선택 = 개수가 정해졌으면 그 수(모자라면 빈 인스턴스) · 아니면 저장된 것. */ +function instancesOf(c: SpecContainer, list: Instance[], count?: ContainerCount): Instance[] { + if (c.required) return [list[0] ?? {}]; + const n = count?.(c.id); + return n === undefined || n === null + ? list + : Array.from({ length: n }, (_, index) => list[index] ?? {}); +} + +function specValue( + c: SpecContainer, + item: SpecItem, + values: Instance, + names: Record, + fallback: string, +): string { + const shown = (raw: string): string => { + const choice = choicesOf(item, values).find((ch) => ch.value === raw); + return choice ? choiceLabel(c, item, choice, names) : raw; + }; + const raw = (values[item.id] ?? "").trim(); + if (raw === "auto") return AUTO_MARK; + if (raw) return shown(raw); + return fallback.trim() ? `${shown(fallback.trim())} ${DEFAULT_MARK}` : NONE; +} + +/** 정의 옵션 그룹 줄 — 그룹마다 첫 옵션(정의에 있고 켜진 칸). */ +function groupLines( + options: Record, + type: StructureType | undefined, +): string[] { + const byKey = new Map((type?.options ?? []).map((option) => [option.key, option])); + return (type?.groups ?? []).map((group) => { + const option = group.options + .map((key) => byKey.get(key)) + .find((entry) => entry && entry.visible !== false && entry.enabled !== false); + if (!option) return `${group.name}: ${NONE}`; + const unit = option.unit ?? ""; + const raw = options[option.key]; + const value = + raw === "auto" + ? AUTO_MARK + : raw !== undefined && raw !== "" + ? `${raw}${unit}` + : option.default !== null && option.default !== undefined && option.default !== "" + ? `${option.default}${unit} ${DEFAULT_MARK}` + : NONE; + return `${group.name}: ${option.label} ${value}`; + }); +} + +/** 등록부 기본값(키 → 값) — 빈 칸이 그림에서 쓰는 값. */ +function registryDefaults(type: StructureType | undefined): Record { + return Object.fromEntries( + (type?.options ?? []) + .filter((option) => option.default !== null && option.default !== undefined) + .map((option) => [option.key, option.default as string | number]), + ); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 895276fd4..e05b782d7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -20,6 +20,7 @@ import { type StructureType, } from "./B05_Profile_Api_Structures"; import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; +import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon"; import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 @@ -40,6 +41,8 @@ const LANE_PADDING_PX = 2; const OVERLAP_PX = 52; /** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */ const ETC_PILL_CHARS = 3; +/** 라벨을 위로 비키는 높이(px) — 그래프 바닥 ▼ 단추(바닥 2 + 높이 17) + 틈 2(54-14). */ +const EDIT_BUTTON_CLEARANCE_PX = 21; /** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다). * 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을 @@ -64,18 +67,8 @@ export interface StructureMarksOptions { selectedId: string | null; onSelect: (structureId: string | null) => void; onMove: (structureId: string, toChainageM: number) => void; -} - -function optionSummary(structure: StructureInstance, type: StructureType | undefined): string { - if (!type) return ""; - return type.options - .map((option) => { - const value = structure.options?.[option.key]; - if (value === undefined || value === "") return null; - return `${option.label} ${value}${option.unit ?? ""}`; - }) - .filter(Boolean) - .join(" · "); + /** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */ + containerCount?: (structure: StructureInstance) => ContainerCount | undefined; } /** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */ @@ -236,7 +229,17 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement if (selected) lane.append( - buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName), + buildBalloon( + balloonLines( + structure, + type, + { name: displayName, position: positionText(structure, options.stationIntervalM) }, + options.containerCount?.(structure), + ), + type, + anchorPx, + row, + ), ); }); @@ -259,44 +262,57 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement /** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. * 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18 * 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로 - * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */ + * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. + * 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값(`balloonLines` · 54-14) · 그래프 바닥 ▼ 단추를 가리지 + * 않게 단추 높이만큼 더 위 · 위가 잘리면 알약 아래로. */ function buildBalloon( - structure: StructureInstance, + lines: string[], type: StructureType | undefined, anchorPx: number, row: number | null, - stationIntervalM: number, - /** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */ - displayName?: string, ): HTMLElement { const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center"); balloon.style.left = `${anchorPx}px`; - balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; + balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`; balloon.style.borderColor = type?.style?.color ?? "#888"; + const [name, ...rest] = lines; const title = document.createElement("strong"); - title.textContent = displayName ?? type?.name ?? structure.type_id; - const position = document.createElement("span"); - position.textContent = positionText(structure, stationIntervalM); - balloon.append(title, position); - - const summary = optionSummary(structure, type); - if (summary) { - const detail = document.createElement("span"); - detail.textContent = summary; - balloon.append(detail); - } - if (structure.memo) { - const memo = document.createElement("span"); - memo.className = "b05-structure__balloon-memo"; - memo.textContent = structure.memo; - balloon.append(memo); - } + title.textContent = name; + balloon.append( + title, + ...rest.map((line) => { + const span = document.createElement("span"); + span.textContent = line; + return span; + }), + ); + requestAnimationFrame(() => flipIfClipped(balloon)); return balloon; } +/** 라벨 위가 스크롤 상자 · 창 밖으로 잘리면 알약 아래로 편다(아래가 덜 잘릴 때만). */ +function flipIfClipped(balloon: HTMLElement): void { + const lane = balloon.parentElement; + if (!balloon.isConnected || !lane) return; + let top = 0; + let bottom = window.innerHeight; + for (let at: HTMLElement | null = lane; at; at = at.parentElement) + if (getComputedStyle(at).overflowY !== "visible") { + const clip = at.getBoundingClientRect(); + top = Math.max(top, clip.top); + bottom = Math.min(bottom, clip.bottom); + } + const box = balloon.getBoundingClientRect(); + const hiddenAbove = top - box.top; + const hiddenBelow = lane.getBoundingClientRect().bottom + ROW_GAP_PX + box.height - bottom; + if (hiddenAbove <= 0 || hiddenBelow >= hiddenAbove) return; + balloon.style.bottom = "auto"; + balloon.style.top = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; +} + /** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */ function attachMarkDrag( mark: HTMLElement, diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 65ba8eb49..126a19b9f 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -6,11 +6,11 @@ * ========================================================================== */ /* 구조물 알약 레인 — 그래프 바로 아래 한 줄(2026-08-17 사용자 지시 1·3·6). - 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. */ + 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. + z-index 를 두지 않음 — 쌓임 층을 만들면 라벨이 그 안에 갇혀 ▲▼ 단추층(4) 밑으로 감(54-14). */ .b05-structure__lane { position: relative; overflow: visible; - z-index: 3; } /* 레인 왼쪽 Y축 띠 — 그래프 sticky Y축과 같은 폭·같은 배경으로 이어 붙여, 가로 @@ -105,7 +105,8 @@ /* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */ /* 벌룬은 알약 위로 뜬다(2026-08-18) — 아래는 도면 테이블에 가린다. 전개 방향: - 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). */ + 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). + 층 = 종단 패널 맨 위 — ▲▼ 단추층(4) · 고정 Y축 · 테이블 · 유토곡선(5) 위 · 우클릭 메뉴(7) 아래(54-14). */ .b05-structure__balloon { position: absolute; z-index: 6; @@ -122,7 +123,6 @@ line-height: 1.35; white-space: nowrap; pointer-events: none; - z-index: 4; } .b05-structure__balloon.is-left { @@ -138,11 +138,6 @@ font-size: 11.5px; } -.b05-structure__balloon-memo { - opacity: 0.7; - font-style: italic; -} - /* ── 계곡 통과 시설 부속 옵션 서브폼 (구조물 배치 폼 안, 2026-08-17 UI 개편) ── * 배관·BOX암거·세월교·독립 기슭막이의 부속 옵션을 2열 격자로 담는다. */ .b05-drainage__facility { diff --git a/resources/tester/helper_54_14_balloon_lines.cjs b/resources/tester/helper_54_14_balloon_lines.cjs new file mode 100644 index 000000000..5816c7935 --- /dev/null +++ b/resources/tester/helper_54_14_balloon_lines.cjs @@ -0,0 +1,121 @@ +/* 54-14 종단 알약 정보 라벨 줄 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { balloonLines } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Balloon.ts"), +); + +const opt = (key, label, extra = {}) => ({ key, label, default: null, unit: null, ...extra }); +const TYPES = { + pipe: { + type_id: "pipe", + name: "배관", + options: [ + opt("pipe_diameter_mm", "관경", { default: "1000" }), + opt("inlet_type", "유입", { default: "기슭막이" }), + ], + groups: [ + { id: "grv6lny", name: "관", options: [] }, + { id: "gnbf9m9", name: "관 보호공(유입)", options: [] }, + { id: "g9lq9uk", name: "관 보호공(유출)", options: [] }, + ], + spec_names: { soil_add: "흙막이 추가" }, + }, + box_culvert: { + type_id: "box_culvert", + name: "BOX암거", + options: [ + opt("body_width_m", "폭", { default: 2 }), + opt("body_height_m", "높이", { default: 2 }), + ], + groups: [], + }, + cross_underdrain: { + type_id: "cross_underdrain", + name: "맹암거", + options: [ + opt("culvert_height_m", "높이", { unit: "m" }), + opt("culvert_slope_pct", "기울기", { unit: "%" }), + ], + groups: [ + { id: "gcrossud", name: "맹암거", options: ["culvert_height_m", "culvert_slope_pct"] }, + ], + }, + retaining_wall: { + type_id: "retaining_wall", + name: "옹벽", + options: [ + opt("height_m", "높이", { default: 2, unit: "m", visible: false }), + opt("length_m", "길이", { default: 10, unit: "m" }), + ], + groups: [{ id: "gretaindetail", name: "상세 내용", options: ["height_m", "length_m"] }], + }, + mirror: { type_id: "mirror", name: "반사경", options: [], groups: [] }, + etc: { type_id: "etc", name: "기타", options: [opt("name", "이름")], groups: [] }, +}; +const point = (type_id, options = {}) => ({ + structure_id: type_id, + type_id, + placement: "point", + chainage_m: 100, + options, + memo: "메모는 안 씀", +}); +const head = (name) => ({ name, position: "5+0" }); +const run = (structure, count) => + balloonLines(structure, TYPES[structure.type_id], head("이름"), count); + +const out = { + pipeFull: run( + point("pipe", { + pipe_diameter_mm: 1200, + inlet_type: "집수정", + inlet_structure: "L", + outlet_type: "기슭막이", + soil_add_1_structure: "기슭막이", + soil_add_1_form: "돌망태", + chute_add_1_structure: "도수로", + }), + ), + pipeBlank: run(point("pipe")), + // 흙막이 개수 = 밖에서 정한 단 수(2) — 저장 1 벌 · 모자란 둘째는 빈 상자 + pipeCount: run(point("pipe", { soil_add_1_structure: "기슭막이" }), (id) => + id === "soil_add" ? 2 : undefined, + ), + boxPreset: run(point("box_culvert", { body_width_m: 3, body_height_m: 3, wing_in: "있음" })), + boxBlank: run(point("box_culvert")), + underdrain: run(point("cross_underdrain", { culvert_height_m: 0.6 })), + underdrainBlank: run(point("cross_underdrain")), + wall: run(point("retaining_wall")), + wallAuto: run(point("retaining_wall", { length_m: "auto" })), + mirror: run(point("mirror", { anything: 1 })), + etcEmpty: run(point("etc", { name: "안내판" })), + etcNote: run(point("etc", { name: "안내판", etc_detail_1_note: "시험 비고" })), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_54_14_balloon_lines.py b/resources/tester/test_54_14_balloon_lines.py new file mode 100644 index 000000000..6374f7903 --- /dev/null +++ b/resources/tester/test_54_14_balloon_lines.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""54-14 종단 구조물 알약 정보 라벨 — 중요한 것만. + +• 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값 한 줄(컨테이너가 없으면 앞 두 줄만) +• 컨테이너 = 좌측 패널 폼과 같은 상자(명세 컨테이너 · 선택 컨테이너는 인스턴스마다 · 정의 옵션 그룹) +• 빈 값 = 등록부 기본값 「(기본)」 · 값 auto = 「(자동)」 · 그것도 없으면 「—」 · 메모 · 그 밖 옵션은 안 씀 +• 라벨은 ▲▼ 단추층 위 · 단추 높이만큼 위로 비킴 · B05 · B06 은 같은 레인 — 실제 화면은 ORCA 로 따로 본다. +화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_14_balloon_lines.cjs`). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +MARKS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts") +STYLE = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_14_balloon_lines.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배관_컨테이너마다_첫_값_한_줄(out): + assert out["pipeFull"] == [ + "이름", + "5+0", + "관: 관경 1200", + "관 보호공(유입): 구조 집수정", + "관 보호공(유출): 구조 흙막이", + "흙막이 1: 구조 흙막이", + "도수로/돌붙임 1: 구조 도수로", + ] + + +def test_빈_값은_등록부_기본값에_기본_표시(out): + assert out["pipeBlank"] == [ + "이름", + "5+0", + "관: 관경 1000 (기본)", + "관 보호공(유입): 구조 흙막이 (기본)", + "관 보호공(유출): 구조 —", + ] + + +def test_흙막이_상자_수는_밖에서_정한_단_수(out): + assert out["pipeCount"][-2:] == ["흙막이 1: 구조 흙막이", "흙막이 2: 구조 —"] + assert len(out["pipeCount"]) == 2 + 3 + 2 + + +def test_박스_본체_규격은_폭_높이로_되찾음(out): + assert out["boxPreset"] == [ + "이름", + "5+0", + "본체: 본체 규격 3.0 × 3.0", + "유입 구조: 구조 날개벽", + "유출 구조: 구조 —", + ] + assert out["boxBlank"][2] == "본체: 본체 규격 2.0 × 2.0 (기본)" + + +def test_정의_옵션_그룹은_그룹마다_첫_켜진_옵션(out): + assert out["underdrain"] == ["이름", "5+0", "맹암거: 높이 0.6m"] + assert out["underdrainBlank"] == ["이름", "5+0", "맹암거: 높이 —"] + # 끈 칸(높이)은 건너뜀 · 빈 값은 기본값 + assert out["wall"] == ["이름", "5+0", "상세 내용: 길이 10m (기본)"] + assert out["wallAuto"] == ["이름", "5+0", "상세 내용: 길이 (자동)"] + + +def test_컨테이너가_없으면_이름과_기준_측점만(out): + assert out["mirror"] == ["이름", "5+0"] + assert out["etcEmpty"] == ["이름", "5+0"] + assert out["etcNote"] == ["이름", "5+0", "상세 내용 1: 비고 시험 비고"] + + +def test_라벨은_단추층_위로_비켜_뜬다(): + with open(MARKS, encoding="utf-8") as handle: + marks = handle.read() + with open(STYLE, encoding="utf-8") as handle: + style = handle.read() + assert "EDIT_BUTTON_CLEARANCE_PX = 21" in marks + assert "ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX" in marks + assert "balloonLines(" in marks and "structure.memo" not in marks + lane = style.split(".b05-structure__lane {", 1)[1].split("}", 1)[0] + assert "z-index" not in lane # 레인이 쌓임 층을 만들면 라벨이 그 안에 갇힘 + balloon = style.split(".b05-structure__balloon {", 1)[1].split("}", 1)[0] + assert balloon.count("z-index") == 1 and "z-index: 6;" in balloon diff --git a/resources/tester/ui_checks/54-14_알약_라벨.js b/resources/tester/ui_checks/54-14_알약_라벨.js new file mode 100644 index 000000000..1e04a6b09 --- /dev/null +++ b/resources/tester/ui_checks/54-14_알약_라벨.js @@ -0,0 +1,210 @@ +/* 54-14 — 종단도 구조물 알약 정보 라벨: 맨 위 층 · ▲▼ 단추를 안 가림 · 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 값 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(데스크탑_메인) · 로그인 · 종단 도크에 배관 1(4+5) · 배관 3(22+0) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5414 = "단계";` + * seed → 처음 초안(세션 원문)을 `__5414_orig` 에 남김 · 구조물 초안에 맹암거 · 반사경 · 기타(안내판 · 비고) 심음 + * → 그 뒤 orca reload + * box → 배관 3(22+0) 종류를 BOX암거로(관 초안만 · 저장 안 함) + * check → 배관 · 박스 · 맹암거 · 반사경 · 기타 알약을 눌러 라벨 판정 — B05 에서 한 번(값을 세션에 둠) · + * B06(#/b06-section) 에서 한 번(B05 = B06) + * restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload + * 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__5414_out_{단계}")` 로 읽음 + * 판정: 라벨 위 점 15 곳 모두 맨 위 = 라벨 · 라벨 상자와 ▲▼ 단추 상자 겹침 0 · 줄 수 = 1 + 1 + 컨테이너 수 + * (컨테이너 수 = 좌측 패널 폼에 보이는 상자 · 줄 머리 = 상자 제목) · B05 = B06 + * 쓰는 것: sessionStorage(구조물 · 관 초안) — 정본(서버)은 안 씀 + */ +(async () => { + const MODE = window.__m5414 || "check"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const pid = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts")); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const visible = (n) => n.offsetParent !== null; + const DRAFTS = /^aislo:draft:(structures|pipes):/; + const b06 = location.hash.includes("b06"); + const out = { mode: MODE, page: location.hash, fails: [] }; + const fail = (what) => out.fails.push(what); + const marks = () => [...document.querySelectorAll(".b05-structure__mark")]; + const TARGETS = { + pipe: (m) => m.dataset.structureId === "pipe-85.05", + box: (m) => m.dataset.structureId === "pipe-440.24", + underdrain: (m) => m.title.startsWith("맹암거"), + mirror: (m) => m.title.startsWith("반사경"), + etc: (m) => m.title.startsWith("안내판"), + }; + const openDock = async () => { + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + if (handle?.getAttribute("aria-expanded") !== "true") handle?.click(); + await until(() => marks().length > 0, 30000); + }; + + if (MODE === "seed") { + const orig = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + sessionStorage.setItem("__5414_orig", JSON.stringify(orig)); + const types = Object.fromEntries((await api.fetchStructureTypes()).map((t) => [t.type_id, t])); + const base = api.readPendingStructures(pid) ?? (await api.fetchStructures(pid)).structures; + const add = (id, type_id, at, options) => { + const interval = types[type_id].placement === "interval"; + return { + structure_id: id, + type_id, + placement: types[type_id].placement, + chainage_m: at, + start_m: interval ? at - 5 : null, + end_m: interval ? at + 5 : null, + options, + memo: "54-14 메모(라벨에 안 나옴)", + placement_source: "manual", + status: "draft", + revision: 0, + geometry: null, + }; + }; + api.writePendingStructures(pid, [ + ...base.filter((s) => !String(s.structure_id).startsWith("t5414")), + add("t5414ud", "cross_underdrain", 600, { culvert_height_m: 0.6 }), + add("t5414mr", "mirror", 660, {}), + add("t5414etc", "etc", 720, { name: "안내판", etc_detail_1_note: "시험 비고" }), + ]); + out.seeded = api.readPendingStructures(pid).length - base.length; + } + + if (MODE === "box") { + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const row = () => rows().find((li) => /^22\+0 (배관|BOX암거)/.test(txt(li))); + await until(() => row(), 30000); + if (!row().classList.contains("is-selected")) row().click(); + await w(1500); + const sel = document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + sel.value = "box_culvert"; + sel.dispatchEvent(new Event("change", { bubbles: true })); + await openDock(); + await until(() => marks().some((m) => TARGETS.box(m) && m.title.startsWith("BOX암거"))); + out.box = marks().find(TARGETS.box)?.title ?? null; + if (!out.box?.startsWith("BOX암거")) fail(`박스 알약 ${out.box}`); + row()?.click(); // 고른 것 풂 + await w(800); + } + + if (MODE === "check") { + await openDock(); + const body = document.querySelector(".b05-structure__lane").closest("[class*='__body']"); + const r = {}; + for (const [key, find] of Object.entries(TARGETS)) { + const mark = marks().find(find); + if (!mark) { + fail(`${key}: 알약 없음`); + continue; + } + mark.scrollIntoView({ inline: "center", block: "nearest" }); + await w(400); + const now = marks().find(find); + if (!now.classList.contains("is-selected")) now.click(); + await until(() => document.querySelector(".b05-structure__balloon")); + await w(1200); // 좌측 폼 · 레인 다시 그림 + const balloon = document.querySelector(".b05-structure__balloon"); + const box = balloon.getBoundingClientRect(); + const lines = [...balloon.children].map((c) => c.textContent); + // ① 맨 위 층 — 라벨 위 15 점에서 가장 위 요소가 라벨(재는 동안만 누름 받기 켬) + balloon.style.pointerEvents = "auto"; + let top = 0; + for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9]) + for (const fy of [0.2, 0.5, 0.8]) { + const hit = document.elementFromPoint( + box.left + box.width * fx, + box.top + box.height * fy, + ); + if (hit && balloon.contains(hit)) top += 1; + } + balloon.style.pointerEvents = ""; + // ② ▲▼ 단추와 안 겹침(실좌표) · 바로 아래 ▼ 단추와 틈 + const buttons = [...document.querySelectorAll(".b05-profile-edit__btn")].map((b) => ({ + down: b.classList.contains("is-down"), + rect: b.getBoundingClientRect(), + })); + const overlap = buttons.filter( + ({ rect }) => + rect.left < box.right && + rect.right > box.left && + rect.top < box.bottom && + rect.bottom > box.top, + ).length; + const under = buttons.filter( + ({ down, rect }) => down && rect.left < box.right && rect.right > box.left, + ); + const gap = under.length ? Math.min(...under.map(({ rect }) => rect.top)) - box.bottom : null; + // ③ 줄 수 = 1 + 1 + 좌측 폼 상자 수 · 줄 머리 = 상자 제목 + const legends = [ + ...document.querySelectorAll("fieldset[data-spec-container], [data-b05-group]"), + ] + .filter(visible) + .map((f) => { + const legend = f.querySelector("legend").cloneNode(true); + legend.querySelectorAll("button").forEach((b) => b.remove()); + return legend.textContent.trim(); + }); + const heads = lines.slice(2).map((l) => l.slice(0, l.indexOf(": "))); + r[key] = { + lines, + top, + overlap, + gap, + boxes: legends.length, + z: getComputedStyle(balloon).zIndex, + clipTop: body ? box.top - body.getBoundingClientRect().top : null, + }; + if (top !== 15) fail(`${key}: 맨 위 아님 ${top}/15`); + if (overlap) fail(`${key}: 단추 겹침 ${overlap}`); + if (lines.length !== 2 + legends.length) + fail(`${key}: 줄 ${lines.length} ≠ 2 + 상자 ${legends.length}`); + if (JSON.stringify(heads) !== JSON.stringify(legends)) + fail(`${key}: 줄 머리 ${heads} ≠ 상자 ${legends}`); + if (lines.some((l) => l.includes("메모"))) fail(`${key}: 메모 줄`); + marks().find(find).click(); // 고른 것 풂 + await w(800); + } + out.read = r; + const lineMap = JSON.stringify( + Object.fromEntries(Object.entries(r).map(([k, v]) => [k, v.lines])), + ); + if (!b06) sessionStorage.setItem("__5414_b05", lineMap); + else { + out.sameAsB05 = sessionStorage.getItem("__5414_b05") === lineMap; + if (!out.sameAsB05) fail("B05 ≠ B06"); + } + } + + if (MODE === "restore") { + const orig = JSON.parse(sessionStorage.getItem("__5414_orig") || "null"); + if (!orig) return JSON.stringify({ error: "원래 값 없음" }); + for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k); + for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v); + const now = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + out.restored = JSON.stringify(now) === JSON.stringify(orig); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("__5414_")) sessionStorage.removeItem(k); + } + const result = JSON.stringify(out); + if (MODE !== "restore") sessionStorage.setItem(`__5414_out_${MODE}`, result); + return result; +})();