diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index 6b340ab0e..b09d2e0e0 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -33,19 +33,29 @@ export function createViewControls(callbacks: ViewControlCallbacks): { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); - // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 - // 가리킨다(누르면 그쪽으로 간다). - let projection: "perspective" | "ortho" = "perspective"; - const projectionButton = button( - "직교로", - () => { - projection = projection === "perspective" ? "ortho" : "perspective"; - projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; - callbacks.onProjection(projection); - }, - "glass", - ); - viewButtons.append(projectionButton); + // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). + const projectionButtons = ( + [ + ["perspective", "원근"], + ["ortho", "직교"], + ] as const + ).map(([kind, label]) => { + const element = button( + label, + () => { + projectionButtons.forEach(({ kind: other, element: el }) => { + el.classList.toggle("is-active", other === kind); + el.setAttribute("aria-pressed", String(other === kind)); + }); + callbacks.onProjection(kind); + }, + "glass", + ); + element.classList.toggle("is-active", kind === "ortho"); + element.setAttribute("aria-pressed", String(kind === "ortho")); + return { kind, element }; + }); + viewButtons.append(...projectionButtons.map(({ element }) => element)); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( 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_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 150f47e9f..4f3e2e137 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -25,6 +25,7 @@ import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { hidesTypeSummary, + markRequired, paintStale, renderOptionFields as renderOptionFieldsInto, } from "./B05_Profile_UI_Structures_Panel_Options"; @@ -99,12 +100,14 @@ export function createStructuresSection( groupBoxes, } = form; /** 옵션 그룹 상자를 지금 종류 · 칸에 맞춰 다시 세움(PLAN 46-3) — 칸을 그린 뒤마다 부름. */ - const regroup = (): void => + const regroup = (): void => { groupBoxes.render( currentType(), [optionRow, facilityOptions.root], facilityFormKind(currentType()), ); + markRequired(optionInputs, optionRow); // 빈 필수 칸 안내 = 칸이 옮겨 간 상자 안(54-12) + }; /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 06ab97d63..222588462 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -329,8 +329,9 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). + // 실시간 반영은 막지 않음 — 빈 칸은 표시 · 안내만(`markRequired` · 54-12), 막기는 [추가] · 서버 [저장] const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty()); - if (missing) { + if (missing && !live) { missing.input.focus(); return; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 703d97378..bed251a8b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -112,8 +112,10 @@ export function renderOptionFields( // 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4) input.addEventListener("change", () => { applyRules(option.key); + markRequired(optionInputs, optionRow); ctx.liveCommit(); }); + input.addEventListener("input", () => markRequired(optionInputs, optionRow)); all.push({ wrap, entry: { @@ -203,6 +205,29 @@ export function renderOptionFields( ctx.syncRangeDisplay(); } +/** 빈 필수 칸 표시(54-12) — 칸 테두리 + 그 칸이 든 상자(격자) 안 안내 한 줄. 칸이 그룹 상자로 옮겨 간 + * 뒤에도 맞게 패널이 칸 그리기 · 그룹 다시 세우기 · 값 바꿈마다 부름. 막지 않음(막기는 [추가] · [저장]). */ +export function markRequired( + optionInputs: OptionRenderContext["optionInputs"], + optionRow: HTMLElement, +): void { + const scope = optionRow.parentElement ?? optionRow; + scope.querySelectorAll(".b05-structure__required-note").forEach((note) => note.remove()); + scope + .querySelectorAll(".is-required-empty") + .forEach((input) => input.classList.remove("is-required-empty")); + for (const entry of optionInputs) { + if (!entry.required || !entry.isEmpty()) continue; + entry.input.classList.add("is-required-empty"); + const box = entry.input.closest("[data-option-keys]")?.parentElement; + if (!box || box.querySelector(":scope > .b05-structure__required-note")) continue; + const note = document.createElement("p"); + note.className = "b05-structure__required-note"; + note.textContent = "필수 칸 비어 있음 — [저장] 전 채움"; + box.append(note); + } +} + /** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */ function syncChoices( element: HTMLSelectElement, diff --git a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts index 296417088..e0a1027ca 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts @@ -23,13 +23,18 @@ export interface SummaryRow { } export function buildSummaryRow(): SummaryRow { + // 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][선택][해제] const root = document.createElement("div"); - root.className = "b05-structure__summary"; + root.className = "b05-route__field b05-structure__summary"; root.dataset.b05Summary = "row"; const label = document.createElement("span"); label.className = "b05-structure__summary-label"; - label.textContent = "집계 항목"; - const name = document.createElement("span"); + label.textContent = "구조물 조합"; + // 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음 + const name = document.createElement("input"); + name.type = "text"; + name.readOnly = true; + name.tabIndex = -1; name.className = "b05-structure__summary-name"; const pick = document.createElement("button"); pick.type = "button"; @@ -41,12 +46,15 @@ export function buildSummaryRow(): SummaryRow { clear.className = "b05-route__irregular-btn"; clear.textContent = "해제"; clear.dataset.b05Summary = "clear"; - root.append(label, name, pick, clear); + const line = document.createElement("div"); + line.className = "b05-route__irregular-actions"; + line.append(name, pick, clear); + root.append(label, line); let current: SummaryItem | null = null; const set = (item: SummaryItem | null): void => { current = item; - name.textContent = summaryLabel(item); + name.value = summaryLabel(item); name.title = summaryTooltip(item); name.classList.toggle("is-assigned", item !== null); clear.disabled = item === null; diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 65ba8eb49..a32685e02 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 { @@ -244,6 +239,18 @@ background: rgba(224, 91, 91, 0.08); } +/* 빈 필수 칸 — 칸 테두리 + 그 칸이 든 상자 안 안내 한 줄(54-12) */ +.b05-route__field .is-required-empty { + border-color: #e05b5b; +} + +.b05-structure__required-note { + grid-column: 1 / -1; + margin: 0; + color: #e05b5b; + font-size: var(--text-caption, 12px); +} + /* 측점 범위(계산 표시) — C군 등 길이 옵션 기반 구간 타입. 입력 칸이 아니라 기준 * 측점 + 길이로 계산된 "시작 ~ 종료"를 보여만 준다(2026-08-19 사용자 지시). * 글자 크기는 기준 측점 입력 칸과 동일(크롬 input 기본 13.333px — 패널 입력에 @@ -427,33 +434,16 @@ border-color: var(--color-warning, #c98a00); } -/* 집계 항목 줄(PLAN 21-4) — 옵션 · 시설 서브폼 아래 [이름][고르기][해제] 한 줄. 이름이 남는 폭을 - * 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색(`.is-assigned`) — 목록 줄과 같은 색. */ -.b05-structure__summary { - display: flex; - align-items: center; - gap: var(--spacing-8); - min-width: 0; -} - +/* 구조물 조합 잇기 줄(PLAN 21-4 · 54-17) — 옵션 칸 꼴(`b05-route__field`) 2행 · 아래 줄은 + * [이름 칸][선택][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색. */ .b05-structure__summary[hidden] { display: none; } -.b05-structure__summary-label { - flex: none; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - .b05-structure__summary-name { flex: 1 1 auto; - min-width: 0; - overflow: hidden; - color: var(--color-text-secondary); - font-size: var(--text-caption); + align-self: center; text-overflow: ellipsis; - white-space: nowrap; } /* 글자는 성공색에 본문색을 섞어 대비 4.5 를 넘김(성공색 그대로는 밝은 테마에서 4.4). */ diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 165456dad..1d0d2e415 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -289,8 +289,9 @@ export function createRouteViewer(): RouteViewer { iso: [distance, distance, distance], // 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다. top: [0, distance, distance * TOP_VIEW_TILT], - front: [0, distance * 0.25, distance], - side: [distance, distance * 0.25, 0], + // 정면 = 남에서 북을, 측면 = 동에서 서를 수평으로 본다(장면 +z 가 남, +x 가 동). + front: [0, 0, distance], + side: [distance, 0, 0], } as const; const [x, y, z] = positions[view]; const camera = cameraRig.camera(); @@ -309,7 +310,9 @@ export function createRouteViewer(): RouteViewer { if (!bounds) return; const width = bounds.x[1] - bounds.x[0]; const depth = bounds.y[1] - bounds.y[0]; - placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35, view); + // 수평 보기는 지형 앞 가장자리가 카메라에 가까워 원근에서 잘린다 — 그 반폭만큼 물린다. + const nearHalf = view === "front" ? depth / 2 : view === "side" ? width / 2 : 0; + placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35 + nearHalf, view); } async function reloadContours(interval: number): Promise { diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts index 32f94f3fd..7dbe1cdd7 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -2,8 +2,7 @@ * B05_Profile_UI_Viewer_Camera.ts * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. * - * 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정). - * 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면· + * 기본은 직교 — 원근(시야각 45°)은 단추로 고른다. 갈아 끼울 때 위치·시선·근평면· * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. * * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. @@ -22,7 +21,7 @@ export function createCameraRig() { const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); perspective.position.set(100, 120, 100); const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - let kind: ProjectionKind = "perspective"; + let kind: ProjectionKind = "ortho"; let aspect = 1; let halfHeight = 100; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index f6f44ca31..0baf219ef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -32,6 +32,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs logger = logging.getLogger(__name__) @@ -595,7 +596,10 @@ async def filled_table( raise _bad(error) from error if found is None: raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.") - lengths = fill.pipe_lengths_from_designs(await _cross_designs(project_id)) - document = await asyncio.to_thread(fill.fill_table, project["root"], found["문서"], lengths) + designs = await _cross_designs(project_id) + lengths = fill.pipe_lengths_from_designs(designs) + document = await asyncio.to_thread( + fill.fill_table, project["root"], found["문서"], lengths, soil_tiers_from_designs(designs) + ) result = await asyncio.to_thread(fill.recalc, document) return {"이름": name, "판": found["판"], "문서": document, "결과": result} diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 862138d91..4d41ea46b 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -85,6 +85,7 @@ def collect_items( project_root: str | Path, pipe_lengths: dict[float, float] | None = None, types: dict[str, StructureType] | None = None, + soil_tiers: dict[float, int] | None = None, ) -> list[dict[str, Any]]: """설계 정본 → 구조물 목록. @@ -133,6 +134,7 @@ def collect_items( "end_m": None, "options": _with_defaults(types.get(type_id), dict(point.options or {})), "관연장": _nearest(lengths, chainage) if type_id == "pipe" else None, + "흙막이단": _nearest(soil_tiers or {}, chainage), # 54-11 "summary_item": point.summary_item, "복사본": f"pipe@{round(chainage, 3)}", "group_items": point.group_items, @@ -672,10 +674,11 @@ def fill_table( project_root: str | Path, document: dict[str, Any], pipe_lengths: dict[float, float] | None = None, + soil_tiers: dict[float, int] | None = None, ) -> dict[str, Any]: """프로젝트 설계로 표 문서를 채움 — 저장 안 함 · 지정 항목 모드(미지정은 빈칸).""" types = structure_type_map() - items = collect_items(project_root, pipe_lengths, types) + items = collect_items(project_root, pipe_lengths, types, soil_tiers) doc, chosen, notes = assign_items(project_root, document, items) filled = fill_document(doc, chosen, types, assigned=True) filled["알림"] = [*notes, *filled.get("알림", [])] diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index 6523e0926..bda74e9a9 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -7,6 +7,7 @@ 짝: `A00_Common/design/design_structure_pipe.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) — 시험이 대조. 기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」. +배관 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은 파일에만(54-11). """ from __future__ import annotations @@ -35,6 +36,18 @@ def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]: return {"그룹": str((options or {}).get("name") or "").strip() or "기타"} +def soil_tiers_from_designs(designs: Any) -> dict[float, int]: + """B06 횡단 설계 → `{주인 측점: 선 유출 다단 수}` — 서버가 세운 `extra_walls` 목록이 있는 측점만 + (B06 `pipeExtraCount` 와 같은 수 · 54-11).""" + found: dict[float, int] = {} + for row in designs or []: + walls = (row.get("design") or {}).get("extra_walls") if isinstance(row, dict) else None + if isinstance(walls, list): + outlet = [w for w in walls if isinstance(w, dict) and w.get("side") == "outlet"] + found[round(float(row.get("chainage_m") or 0.0), 3)] = len(outlet) + return found + + def _as_options(values: dict[str, Any]) -> dict[str, Any]: """컨테이너 값 → 열이 읽는 칸 — 구조 = `structure` · `{쪽}_type` · 나머지 = 맨 칸 + `{쪽}_revet_*`.""" @@ -53,6 +66,7 @@ def _as_options(values: dict[str, Any]) -> dict[str, Any]: def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str, Any]]: """한 구조물의 선택 컨테이너 인스턴스마다 한 건(n 차례) — 컨테이너 없는 종류는 빈 목록.""" rows: list[dict[str, Any]] = [] + tiers = item.get("흙막이단") # None = 모름(저장된 것 그대로) for prefix, link, title in SPEC_CONTAINERS.get(item["type_id"], ()): head = re.compile(rf"^{prefix}_(\d+)_(.+)$") found: dict[int, dict[str, Any]] = {} @@ -61,6 +75,8 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str if hit: found.setdefault(int(hit.group(1)), {})[hit.group(2)] = value for n in sorted(found): + if prefix == "soil_add" and tiers is not None and n > tiers: + continue gid = f"{link}{n}" rows.append( { diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 740f9584f..32518256a 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -160,3 +160,24 @@ (10-6 사용자 · 추가 요청) - 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성 + +(10-6 사용자 · 다음 수정) + - B05/B06의 종단도 내널에서 구조물 알약을 선택하면 정보 라벨이 나오는데 위치가 상하 제어 버튼에 가려져서 안보여 최상위로 올려주고 그럼 버튼이 가려지니까 Y축 방향으로 조금 쉬프트 해줘. 그리고 안에 내용은 중요한 정보만 보여주면 됨. 지금은 너무 많이 나와. 중요한 정보라면 아마 기준 측점 정보와 컨테이너가 있는 경우 제일 앞에 있는 옵션값만 있어도 될것같아. + +(10-6 사용자 · 신규 기능) +다음은 신규 기능이야. 구조물 조합을 지정해야하는 경우에가 있는데 이때 좌측 패널의 구조물 리스트의 항목들에 측점정보 좌측에 연결되었거나 연결되지 않은 정보를 알려주는 기능이 있었으면 함. 이때 구조물에 따라 선택이나 필수 정보들에 의해 램프와 램프의 색상으로 표현되게 하면 될것같아. 램프영역에 의해 글자들의 배열이 깨지는건 싫으니 별도의 영역을 먼저지정하고 램프는 3행 다열 구조로 구성해줘. 램프의 사이즈는 작아도 되니 현재의 리스트 높이를 깨지 않는 범위에서 진행해줘. + +(10-6 사용자 · 중요) +이건 중요한 부분인데 B05와 B06은 같은 프론트엔드를 공유하고 같은 결과와 묶여있어야하는데 지금 분리되어있는것같아. 하나를 변경하면 다른 페이지로 이동되었을때 이전자료를 가지고 있는것같아. 저장하는 데이터 셋의 위치가 일원화 되어있지 않는것같은데. 확인해봐. + +(10-6 사용자 · 추가 요청) + - 구조물 배치에 구조물 조합 선택의 프론트엔드를 2행 구조로 변경해줘. 다른 옵션들을 보면 제목과 입력을 2행 구조로 반영되어 있잖아. 글자 폰트와 크기도 같은걸 사용해줘. + +(10-6 사용자 · 추가항목) +B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 제목은 원근, 직교 로 변경하고 기본값을 직교로 변경해줘. + +(10-6 사용자 · 추가항목) + - B05의 3D 영역에서 Front, Side버튼의 방향이 대각선 방향인것같아. 방향 재설정 해줘. + +(10-6 사용자 · 추가 요청) + - 페이지의 좌측 패널의 공통항목으로 들어온 버튼 세트의 버튼 크기가 조금 이상함. 점검 diff --git a/docs/raw/조사/2026-10-06_54-16_자료_한곳.md b/docs/raw/조사/2026-10-06_54-16_자료_한곳.md new file mode 100644 index 000000000..8a1e5415e --- /dev/null +++ b/docs/raw/조사/2026-10-06_54-16_자료_한곳.md @@ -0,0 +1,91 @@ +# 54-16 B05 · B06 자료 한 곳 확인 (조사 · 보고만) + +- 기준 커밋 `2104cc359` · 검증 프로젝트 `sub8_검증`(`c81b21f8…`) · 노선 224 +- 측정: 같은 창 안 넘김은 ORCA 탭 + `orca eval`. 도중 ORCA 탭이 통째로 새로 만들어져 세션 초안이 사라짐(앱 흠 아님) → 저장 · 새로고침 시나리오는 측정 전용 헤드리스 Chrome(같은 프로브 · 화면 안 띄움). ORCA 탭은 닫음 +- 값 되돌림: 높이 2 · 관경 1000 · 60m 계획고 865.48 · 3+0 측구 일반 으로 저장해 되돌림 확인. 단 횡단 저장 때 서버가 면적을 다시 계산해 누가토량은 −13,417.5 → −12,843.4 로 바뀜(서버 정본 재계산 결과 · 되돌릴 값 아님) + +## ① 자료마다 쥔 곳 + +세션 키 꼴 `aislo:{bucket}:{name}:{project}[:{route}]` (`A00_Common/b_page_state.ts:229-238`). 설계 가게(`design_store`)는 따로 값을 들지 않고 이 키를 바로 읽고 씀(`A00_Common/design/design_store.ts:133-153`) · 구독자 0. + +| 자료 | 기준 자리 | 같이 쥔 곳(두 벌) | 쓰는 곳 | 읽는 곳 | +|---|---|---|---|---| +| 구조물 목록 | ss `draft:structures` | 종단 다리 `ownStructures` · 횡단 패널 `structures` · 한 벌 폼 `structures` · 한 벌 종단도 `structures` · 서버 `structures.json`(보일 때마다 다시 받음) · 가게 칸 `structures`(아무도 안 씀) | `B05_Profile/B05_Profile_Api_Structures.ts:305` ← `B05_Profile_UI_Page_Structures.ts:245` · `B06_Section/B06_Section_UI_Page_Structures_Panel.ts:177` | `B05_Profile_UI_Page_Structures.ts:331` · `B06_..._Structures_Panel.ts:345` · `B06_Section_Section_Store.ts:61` | +| 구조물 옵션 | 구조물 초안 안 `options` | 명세 폼 `state`(`A00_Common/design/design_structure_form.ts:278,286`) | 폼 commit | `B05_Profile_UI_Structures_Panel_Commit.ts:94` | +| 관 옵션 | 관 초안 안 `options` | 배수유역도 `facilityStore` · 횡단 캐시 culvert/ford/box 스펙 · ss `culvertopt` · ss `revetx`/`fordadjust`/`boxadjust` | 종단 `B05_Profile_UI_Drainage_Panel.ts:612` · 횡단 `B06_..._Structures_Panel.ts:277-284` · `B06_Section_UI_Page_Span_Control.ts:112` · `B06_Section_UI_Page_Ford_Controls.ts:204,398` | `design_view_cross.ts:623`(마운트 한 번) · 저장 `B06_Section_Api_Culvert_Options.ts:64` | +| 컨테이너 값 | 관 `options` 의 `soil_add_{n}_*` | ss `extrawall`(단 수) · ss `extraspan` + 캐시 `extra_spans` · ss `revetx` · 모듈 전역 `sectionsSource`/`extraCountSource`(나중에 선 보기가 이김) | `B06_Section_UI_Page_Station_Controls.ts:324` · `B06_Section_UI_Page_Span_Control.ts:78-80` · `B06_Section_UI_Page_Pipe_Options.ts:229` | `design_structure_spec.ts:112-140` · `design_view_cross.ts:298` · `design_structure_pipe_calc.ts:32,60` | +| 잇기 | 관 · 구조물 초안 `summary_item`/`group_items` | 배수유역도 `facilityStore` · ss `revetlink`(마운트 때 한 번) | `B05_Profile_UI_Drainage_Panel.ts:599-600` · `B06_..._Structures_Panel.ts:245-259` | `B05_Profile_UI_Structures_Panel_Commit.ts:114,198` | +| 관 지점 | ss `draft:pipes` | 배수유역도 `pipeEditor` + `facilityStore` · `shared.lastPipes` · 종단 보기 `lastPipes` · 횡단 패널 `pipeFacilities` · 한 벌 폼 `pipeFacilities` · 종단 다리 `pipeStations`/`pipeMarks` · 서버 `pipe_points.json` | `B05_Profile_Api_Pipes_Draft.ts:27` ← `Drainage_Panel.ts:397` · `B06_..._Structures_Panel.ts:430` | `Drainage_Panel.ts:404` · `B06_..._Structures_Panel.ts:350` · `B06_Section_Section_Store.ts:105` | +| 종단 계획선 | ss `draft:alignment-draft` | 모듈 전역 EditState Map(`B05_Profile_UI_Profile_Edit.ts:143,188`) · 한 벌 종단도 `store`/`base` | `B05_Profile_UI_Profile_Edit.ts:198` | `:97` · `B06_Section_UI_Page_Design_Sync.ts:132` | +| 횡단 설계 patch | 메모리 캐시 Map(`B06_Section/B06_Section_Section_Store.ts:178-201` · 두 보기 공유) | ss `result:section-detail` 사본(받을 때만 씀 · 고침 안 받음) · ss `crossdesign` · ss `rockb`/`cutslope`/`berm`/`crossw`/`inletstruct`/`basinadjust`/`std-cross` · 종단 보기 `currentSectionDetail` · 횡단 보기 `sectionDetail` | `B06_Section_Cross_Design_Session.ts:55` · `design_view_cross.ts:270-296,430` | `B06_Section_Cross_Refresh.ts:253`(재계산 때만) · `B06_Section_UI_Page_Persist.ts:377` · 마운트 `design_view_cross.ts:565-567,612` | +| 사토 | 구조물 목록 안 사토장 | 캐시 `design.spoil_fill_*` | 서버 저장 때만 `B06_Section_Server_Calc_Prebuild.py:197` | `B06_Section_Cross_Refresh.ts:316-327`(저장분 폭 그대로) | +| 선택 상태 | ss `draft:structure-pick` | 가게 `selection` + ss `draft:selection`(아무도 안 씀) · 횡단 `appliedPick` | `design_selection.ts:152,172` | `design_selection.ts:112` · `B05_Profile_UI_Structure_Pick_Session.ts:34` | + +## ② 재현 (측정) + +같은 창 안 넘김 · 저장 전 + +| 자료 | B05 바꿈 → B06 | B06 바꿈 → B05 | +|---|---|---| +| 구조물 옵션(돌쌓기 높이) | 맞음 — 폼 1.5 · 카드 H=1.5m | 맞음 — 1.8 | +| 관 옵션(관경) | 맞음 — 1200 | 맞음 — 800 | +| 종단 계획선(60m ▲) | 맞음 — 865.68 | 맞음 — 865.78 | +| 선택 | 맞음 — 같은 구조물 고름 | 맞음 | +| **누가토량(같은 초안)** | **어긋남 — B05 −13,478.3 · B06 −12,888.7** | 넘긴 뒤 B05 가 B06 값(−12,931.8)으로 바뀜 | + +저장 · 새로고침 (B05: 높이 1.2 · 관경 1200 · ▲2 → B06: 측구 L형 · 관경 800 · 높이 1.0) + +| 단계 | 높이 | 관경 | 60m 계획고 | 3+0 측구 | 누가토량 | 판정 | +|---|---|---|---|---|---|---| +| 새로고침 뒤 B05 (저장 전) | 1 | 800 | 865.68 | (B05 안 보임) | **−12,915.6** (직전 −12,931.8) | **옛 값** — L형 초안이 계산에 안 얹힘 | +| 새로고침 뒤 B05 에서 [저장] → 새 창 | 1 | 800 | 865.68 | **일반** | −13,494.6 | **옛 값** — 「편집 내용을 저장했습니다」인데 L형이 안 나감 | +| 같은 창 → B06 | 1 | 800 | 865.68 | **일반**(ss 엔 L형 초안 남음) | −13,494.6 | **옛 값** — 화면은 일반 · [저장]하면 L형이 나감(화면 ≠ 저장) | +| B06 에서 [저장] → 새 창 | 1 | 800 | 865.68 | L형 | −12,931.8 | 맞음 | +| B06 관경 1000 바꾸고 바로 [저장] → 새 창 | 1 | **800** | 865.68 | L형 | −12,931.8 | **옛 값** — `pipe-points` PUT 두 번 · 둘째(배수유역도 메모리 800)가 덮음 | + +재지 않은 것(코드 읽기만): 컨테이너 값 · 잇기 · 사토 + +## ③ 원인 + +두 벌 (6) + +| # | 자리 | 왜 | +|---|---|---| +| D1 | 관 목록 세 벌 — ss 초안 · 배수유역도 메모리 · 횡단 `pipeFacilities` | [저장]이 초안을 보낸 **뒤** 배수유역도 메모리를 한 번 더 PUT(`A00_Common/design/design_shared.ts:275-281` → `B05_Profile/B05_Profile_UI_Drainage_Panel.ts:636-642`) → 늦은 메모리가 새 값을 덮음 | +| D2 | 관 옵션 네 곳 — 관 초안 · 횡단 캐시 스펙 · `culvertopt` · `revetx`/`fordadjust`/`boxadjust` | 저장 차례상 `culvertopt` 가 관 초안 뒤에 나가 이김(`design_save.ts:217-222,282-285`) | +| D3 | 흙막이 단 수 두 벌 — ss `extrawall` · 관 `soil_add_{n}_*` | 컨테이너 수는 `extrawall` 을 따름 · 모듈 전역 `sectionsSource` 는 나중에 선 보기가 이김 | +| D4 | 구조물 목록 — ss 하나 + 메모리 셋 + 보일 때마다 서버 | 숨은 쪽은 보일 때 서버 + ss 로 다시 세움(`B05_Profile_UI_Page_Structures.ts:319-322` · `B06_..._Structures_Panel.ts:334-341`) | +| D5 | 종횡단 상세 — 메모리 캐시 · ss 사본 · 종단 `currentSectionDetail` · 횡단 `sectionDetail` | ss 사본은 제자리 고침을 안 받아 늘 옛 값 · 종단 쪽은 진입 때만 받음(`design_view_profile.ts:410,442`) | +| D6 | 가게 빈 칸 셋(`structures` · `sectionDetail` · `selection`) + `draft:selection` 키 | 실제 자리는 다른 키 — 「한 곳」이 겉모양뿐 · `subscribe` 구독자 0 | + +다른 보기로 갈 때 덮음 · 안 얹음 + +- **카드 선택 초안이 재계산 길에서만 얹힘** — `crossDesignChoices` 를 읽는 곳은 `B06_Section/B06_Section_Cross_Refresh.ts:253`(계획고 재계산)과 저장(`B06_Section_UI_Page_Persist.ts:377`)뿐 → 새로고침 · 저장 뒤 카드는 서버 설계(일반)를 보이고 ss 엔 L형이 남음 (재현 2 · 3) +- **일반 [저장]은 횡단 보기가 섰을 때만 횡단 초안을 보냄** — 안 섰으면 `saveCachedCrossPatches` 가 캐시 메모리만(`B06_Section_Section_Store.ts:259`) · ss 초안(`crossdesign` · `rockb` 등) 빠짐 · 그래도 「저장했습니다」 (재현 3) +- 종단 보기 onShow 가 횡단 자료를 다시 안 읽음(`B05_Profile_UI_Page_Life.ts:30-35` · `design_view_profile.ts:548-551`) · `applyPipes(lastPipes)`(`:550`)가 끝나지 않은 메모리 목록을 폼에 실음 +- 횡단 onShow 에 관 옵션 초안 다시 얹기 없음 — 마운트 때만(`design_view_cross.ts:623`) +- 종단 [저장] 뒤 캐시를 비워도(`B05_Profile_UI_Page_Actions.ts:154`) 숨은 횡단 `sectionDetail` 은 옛 객체 → `forwardPipeOptions`(`design_view_profile.ts:299`)가 버린 객체를 고침 +- 관을 전부 지운 빈 초안 `[]` 을 「없음」으로 봐 서버 목록을 받음(`Drainage_Panel.ts:405-407`) → [저장]이 지운 관을 되살림 + +한쪽만 알림 + +- **구조물 · 관 바꿈이 횡단 면적에 다시 얹히는 길이 횡단 쪽에만** — `refreshDetailForStructures`(`design_view_cross.ts:143`) · 종단 보기에서 바꾸면 종단 누가토량이 옛 면적 (재현 1: 같은 초안 −13,478.3 대 −12,888.7) +- `toAll("onPipesChanged")`(`design_shared.ts:158-161`) — 횡단 쪽 묶음(`B06_Section_UI_Section_View_Profile.ts:179-216`)에 받는 칸 없음 → 실제로는 종단만 받음 +- 「구조물 배치」 폼 알림은 지금 보기에만(`design_shared.ts:183`) +- 관 옵션 넘김 한 방향 — 종단 → 횡단 `forwardPipeOptions` · 횡단 → 종단은 초안 + 200ms 재읽기(`design_view_cross.ts:146`)뿐 +- 숨은 종단 보기는 관 목록을 담기만(`design_view_profile.ts:153`) → 횡단에서 [저장]하면 옛 비정규 측점이 `uphill` 로 나감(`B05_Profile_UI_Page_Actions.ts:128-134`) + +## ④ 한 곳으로 모을 갈래 (8 · 작은 것부터) + +목표: 자료 하나 = 세션 키 하나(가게 칸 하나) · 두 보기는 읽기만 · 바꿈 알림 한 길 · 보기 넘길 때 서버로 덮지 않음 · [저장]은 세션 키를 그대로 보냄(5장 데이터 3층) + +| 차례 | 갈래 | 크기 | 고치는 재현 | +|---|---|---|---| +| 1 | 저장 줄기 관 한 번 — 둘째 `side.savePipes()` PUT 을 빼고 초안 하나만(시설 되붙이기는 초안 쪽에서) · `design_shared.ts:275-281` | 작음 | 관경 바로 저장 옛 값 | +| 2 | 카드 선택 초안(`crossdesign` · `rockb` · `cutslope` 등)을 상세를 **불러올 때 한 번** 캐시에 얹음(`B06_Section_Section_Store.ts` load) — 재계산 길에만 두지 않음 | 작음~중간 | 새로고침 뒤 카드 일반 · B05 누가토량 어긋남 | +| 3 | 일반 [저장]이 보기와 무관하게 횡단 ss 초안을 늘 보냄(`saveCachedCrossPatches` 가 ss 초안 포함) | 작음~중간 | B05 [저장] L형 빠짐 | +| 4 | ss `result:section-detail` 사본 — 제자리 고침을 같이 쓰거나 지움 | 작음 | 새로고침 뒤 옛 사본 | +| 5 | 구조물 · 관 바꿈 → 횡단 상세 벽 · 면적 다시 얹기를 공용 한 길로(어느 보기에서 바꿔도) · 종단 `currentSectionDetail` 대신 `Section_Store` 캐시 하나만 읽음 | 중간 | 같은 초안 누가토량 두 값 | +| 6 | 관 목록 한 벌 — 배수유역도 메모리 · 횡단 `pipeFacilities` · `shared.lastPipes` 를 ss 초안 읽기 전용으로 · `onPipesChanged` 를 두 보기 모두 · 빈 초안 `[]` 도 초안으로 | 중간 | 관 지점 두 벌 | +| 7 | 관 옵션 한 자리 — `culvertopt` · `revetx` · `extrawall` 을 관 초안 `options` 로 모음 · 흙막이 단 수 한 벌 · 저장 차례 겹침 없앰 | 큼 | 관 옵션 네 곳 | +| 8 | 가게를 실제 한 창구로 — 자료마다 키 하나 · `subscribe` 알림으로 두 보기 다시 그림 · 빈 칸 정리 · 보기 onShow 서버 재조회는 초안 없을 때만 | 큼 | 두 벌 전반 | diff --git a/resources/tester/helper_54_12_live_required.cjs b/resources/tester/helper_54_12_live_required.cjs new file mode 100644 index 000000000..4caaa0424 --- /dev/null +++ b/resources/tester/helper_54_12_live_required.cjs @@ -0,0 +1,111 @@ +/* 54-12 빈 필수 칸 실시간 반영 헬퍼 — `_Structures_Panel_Commit.commit` 을 Node 로 돌려 + * 결과를 표준출력 JSON 한 벌로. 화면 그림은 안 씀(ORCA 가 따로 봄). + * TS 로더는 `helper_54_9_etc_pill.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(); +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 } }; + +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 (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { commit } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel_Commit.ts"), +); + +const type = { + type_id: "stone_wall_test", + name: "돌쌓기(찰)", + group: "C", + placement: "point", + options: [ + { key: "height_m", label: "높이", input: "number" }, + { key: "back_length_m", label: "뒷길이", input: "number", required: true, phase: "detail" }, + ], +}; + +function run(live, height) { + const focused = []; + const entry = (key, value, required) => { + const input = { value, focus: () => focused.push(key), classList: { add() {} } }; + return { + key, + required, + input, + read: () => Number(input.value) || 0, + isEmpty: () => input.value.trim() === "", + }; + }; + const structures = [ + { + structure_id: "s1", + type_id: type.type_id, + placement: "point", + chainage_m: 80, + options: { height_m: 1.5 }, + }, + ]; + const changes = []; + const anchor = { read: () => 100, station: { focus: () => focused.push("anchor") } }; + const ctx = { + callbacks: { onChange: (next) => changes.push(next) }, + structures, + memoField: { value: "" }, + anchorFields: anchor, + startFields: anchor, + endFields: anchor, + optionRow: null, + optionInputs: [entry("height_m", String(height), false), entry("back_length_m", "", true)], + pipeFacilities: () => [], + interval: () => 20, + typeMap: () => new Map([[type.type_id, type]]), + currentType: () => type, + facilityFormKind: () => null, + hasComputedRange: () => false, + summary: () => null, + editingId: () => "s1", + groupBoxes: { get: () => ({}), shapes: () => ({}), shapeKeys: () => [] }, + renderList: () => {}, + cancelTempPipe: () => {}, + }; + return commit(ctx, live).then(() => ({ + changes: changes.length, + height: structures[0].options.height_m, + chainage: structures[0].chainage_m, + hasBack: "back_length_m" in structures[0].options, + focused, + })); +} + +Promise.all([run(true, 3.2), run(false, 3.2)]).then(([live, button]) => { + process.stdout.write(JSON.stringify({ live, button })); +}); 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/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 0a548359e..09c3e4f7c 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -199,9 +199,10 @@ out.pipeSet = merge.mergeFacilityOptions(pipePrev, { const row = buildSummaryRow(); const clicks = []; row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") }); -const [labelNode, nameNode, pickBtn, clearBtn] = row.root.children; +const [labelNode, line] = row.root.children; +const [nameNode, pickBtn, clearBtn] = line.children; out.rowEmpty = { - name: nameNode.textContent, + name: nameNode.value, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), @@ -211,18 +212,18 @@ out.rowEmpty = { }; row.set(A); out.rowSet = { - name: nameNode.textContent, + name: nameNode.value, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, title: nameNode.title, key: row.get().key, - nameShowsKey: nameNode.textContent.includes("SS000007"), + nameShowsKey: nameNode.value.includes("SS000007"), }; pickBtn.click(); clearBtn.click(); out.clicks = clicks; row.set(null); -out.rowCleared = { name: nameNode.textContent, clearDisabled: clearBtn.disabled }; +out.rowCleared = { name: nameNode.value, clearDisabled: clearBtn.disabled }; // 목록 줄 — 지정 색 · 툴팁 const list = new FakeNode("ul"); diff --git a/resources/tester/test_52_1b_pipe_links.py b/resources/tester/test_52_1b_pipe_links.py index 87ce7e553..8f6898236 100644 --- a/resources/tester/test_52_1b_pipe_links.py +++ b/resources/tester/test_52_1b_pipe_links.py @@ -82,3 +82,42 @@ def test_서버_컨테이너_표는_배관_명세와_짝(): r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text ) assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["pipe"]) + + +def test_54_11_흙막이는_선_단_수까지만(): + """단 수 2 → 1 = 흙막이 줄 2 → 1(값은 옵션에 그대로) · 다시 2 = 되살아남 · 모름 = 저장된 것 전부.""" + from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs + + item = {"type_id": "pipe", "options": SOIL, "복사본": "pipe@40.0"} + + def soil(tiers): + rows = container_rows({**item, "흙막이단": tiers}, {}) + return [r["그룹"] for r in rows if r["그룹"].startswith("흙막이")] + + assert soil(2) == ["흙막이 1", "흙막이 2"] + assert soil(1) == ["흙막이 1"] and "soil_add_2_structure" in item["options"] + assert soil(0) == [] and soil(2) == ["흙막이 1", "흙막이 2"] and len(soil(None)) == 2 + assert len(container_rows({**item, "흙막이단": 0}, {})) == 1 # 도수로/돌붙임은 그대로 + walls = [{"side": "outlet"}, {"side": "basin"}, {"side": "outlet"}] + designs = [ + {"chainage_m": 40.0, "design": {"extra_walls": walls}}, + {"chainage_m": 60.0, "design": {"extra_walls": []}}, + {"chainage_m": 80.0, "design": {"pipe_length_m": 9}}, + ] + assert soil_tiers_from_designs(designs) == {40.0: 2, 60.0: 0} + + +def test_54_11_집계표_미지정_알림도_선_단_수만(world): + root, system = world["root"], world["system"] + point = {**_pipe(PIPE), "group_items": {"gsoiladd1": RV}} + point["options"] = {**point["options"], **SOIL} + _save(root, [], [point]) + table = json.loads((system / "table/구조물집계표.json").read_text("utf-8")) + + def note(tiers): + found = fill.fill_table(root, table, None, tiers)["알림"] + return next((n for n in found if n.startswith("집계 항목 미지정")), "") + + many, one = note(None), note({40.0: 1}) + assert many != one # 숨은 흙막이 2 가 미지정 알림에서 빠짐 + assert int(many.split()[3].rstrip("건")) - int(one.split()[3].rstrip("건")) == 1 diff --git a/resources/tester/test_54_12_live_required.py b/resources/tester/test_54_12_live_required.py new file mode 100644 index 000000000..fb41486a3 --- /dev/null +++ b/resources/tester/test_54_12_live_required.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""54-12 필수 칸(뒷길이 등)이 빈 옛 구조물 — 다른 칸 · 기준 측점 바꿈은 실시간 반영 · [추가]는 그대로 막음. + +Node 헬퍼가 `_Structures_Panel_Commit.commit` 을 돌림(`helper_54_12_live_required.cjs`). +빈 칸 표시 · 안내 한 줄은 ORCA 로 따로 봄. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_12_live_required.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): + live = out["live"] + assert live["changes"] == 1 + assert live["height"] == 3.2 + assert live["chainage"] == 100 + assert live["hasBack"] is False # 빈 칸은 0 으로 채우지 않음 + assert live["focused"] == [] # 포커스를 빈 칸으로 뺏지 않음 + + +def test_추가_수정_버튼은_종전대로_막는다(out): + button = out["button"] + assert button["changes"] == 0 + assert button["height"] == 1.5 + assert button["focused"] == ["back_length_m"] + + +def test_빈_필수_칸_표시는_패널_세_곳에서_부른다(): + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel_Options.ts"), + encoding="utf-8", + ) as handle: + options = handle.read() + assert "필수 칸 비어 있음 — [저장] 전 채움" in options + assert options.count("markRequired(optionInputs, optionRow)") == 2 # change · input + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel.ts"), encoding="utf-8" + ) as handle: + assert "markRequired(optionInputs, optionRow)" in handle.read() # 그룹 상자 다시 세운 뒤 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/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index eca79410f..22ad774a1 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -80,7 +80,7 @@ def test_집계_항목_줄(out): True, None, ) - assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "선택", "해제") + assert (empty["label"], empty["pick"], empty["clear"]) == ("구조물 조합", "선택", "해제") row = out["rowSet"] assert row["assigned"] is True and row["clearDisabled"] is False assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"] @@ -116,8 +116,8 @@ def test_저장_기록에_집계_항목이_실리고_해제하면_칸이_빠짐( def test_집계_항목만_바뀌면_폼_검증과_무관하게_기록에_반영(out): - # 흠 2 — 필수 옵션이 비면 종전 commit 은 조용히 멈춤 - assert out["formBlocks"] is True + # 흠 2 — 필수 옵션이 비어도 실시간 반영은 나감(54-12 · 막기는 [추가] · [저장]) + assert out["formBlocks"] is False # 집계 항목 전용 반영은 그 검증을 안 거침 · 옵션은 그대로 assert out["summaryOnlyAssign"] == { "done": True, diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index 4153fc273..95257df30 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -69,7 +69,7 @@ c: b.dataset.specContainer, legend: b.querySelector("legend").firstChild.textContent, items: [...b.querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem), - link: b.querySelector(".b05-structure__summary-name")?.textContent, + link: b.querySelector(".b05-structure__summary-name")?.value, })); const linkName = (c, i = 0) => box(c, i).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); diff --git a/resources/tester/ui_checks/52-2_박스암거_컨테이너.js b/resources/tester/ui_checks/52-2_박스암거_컨테이너.js index 67c606819..69f87a91e 100644 --- a/resources/tester/ui_checks/52-2_박스암거_컨테이너.js +++ b/resources/tester/ui_checks/52-2_박스암거_컨테이너.js @@ -65,7 +65,7 @@ values: [...b.querySelectorAll("[data-spec-item]")].map( (n) => n.querySelector("select, input").value, ), - link: b.querySelector(".b05-structure__summary-name")?.textContent, + link: b.querySelector(".b05-structure__summary-name")?.value, })); const linked = (c) => box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); diff --git a/resources/tester/ui_checks/52-4_세월교_컨테이너.js b/resources/tester/ui_checks/52-4_세월교_컨테이너.js index c5c9143e0..0be28be0b 100644 --- a/resources/tester/ui_checks/52-4_세월교_컨테이너.js +++ b/resources/tester/ui_checks/52-4_세월교_컨테이너.js @@ -63,7 +63,7 @@ n.dataset.specItem, n.querySelector("select, input").value, ]), - link: b.querySelector(".b05-structure__summary-name")?.textContent, + link: b.querySelector(".b05-structure__summary-name")?.value, })); const assigned = (c) => box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); diff --git a/resources/tester/ui_checks/52-5_횡단수로_개거.js b/resources/tester/ui_checks/52-5_횡단수로_개거.js index 6745c6ed2..8b7b11211 100644 --- a/resources/tester/ui_checks/52-5_횡단수로_개거.js +++ b/resources/tester/ui_checks/52-5_횡단수로_개거.js @@ -52,7 +52,7 @@ summaryRightAfterPosition: after === summary, anchor: vis(anchorWrap), stale: vis(sec().querySelector(".b05-structure__stale")), - linked: summary.querySelector(".b05-structure__summary-name")?.textContent ?? "", + linked: summary.querySelector(".b05-structure__summary-name")?.value ?? "", }; }; const pickType = async (typeId) => { diff --git a/resources/tester/ui_checks/52-6_횡단맹암거.js b/resources/tester/ui_checks/52-6_횡단맹암거.js index 3519947f5..edd0922a7 100644 --- a/resources/tester/ui_checks/52-6_횡단맹암거.js +++ b/resources/tester/ui_checks/52-6_횡단맹암거.js @@ -1,4 +1,4 @@ -/* 52-6 — 횡단배수 › 맹암거(표 06 · 새 종류 `cross_underdrain`) · B05 · B06 「구조물 배치」 +/* 52-6 — 횡단배수 › 맹암거(표 06 · 새 종류 `cross_underdrain`) · B05 · B06 「구조물 배치」 * 시작 화면: B05 종단설계(#/b05-profile) · 아무것도 안 고름 · 측점 3+7 이 비어 있음 * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m526 = "단계";` 를 붙여 * orca eval --expression "window.__m526='edit'; $(cat 이 파일)" → 종류 목록 · 컨테이너 · 값 · 잇기 · [추가] · 다시 열기 @@ -53,7 +53,7 @@ (n) => `${n.dataset.optionKeys}|${n.querySelector("span").textContent}`, ), values: ["culvert_height_m", "culvert_slope_pct"].map((k) => input(k)?.value), - link: b?.querySelector(".b05-structure__summary-name").textContent, + link: b?.querySelector(".b05-structure__summary-name").value, linked: b?.querySelector(".b05-structure__summary-name").classList.contains("is-assigned"), linkRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length, }; diff --git a/resources/tester/ui_checks/54-11_흙막이_집계.js b/resources/tester/ui_checks/54-11_흙막이_집계.js new file mode 100644 index 000000000..e6f94bb91 --- /dev/null +++ b/resources/tester/ui_checks/54-11_흙막이_집계.js @@ -0,0 +1,138 @@ +/* 54-11 — 집계표 · 미지정 알림은 지금 보이는 흙막이 상자(단 수 안)만 셈 + * 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18) + * 부르는 법: orca eval --expression "window.__m5411={mode:'단계'}; $(cat 이 파일)" + * look → 단 수 · 보이는 흙막이 상자 · 파일의 흙막이 값 번호 · 집계표 줄 수 · 미지정 알림 + * prep → 보이는 흙막이 상자마다 구조물 조합 잇기 · 마지막 상자 형태 = form → [저장] → look + * tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - → [저장] → look + * 기대: 단 수 2 → 1 에 상자 2 → 1 · 집계 줄(또는 미지정 건) 1 줄어듦 · 파일 값 그대로 · + * 다시 2 에 줄 · 상자 값 되살아남 + */ +(async () => { + const ARGS = window.__m5411 || { mode: "look" }; + const PIPE = ARGS.pipe || "배관 2"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const fetchUrl = modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts"); + const fetchApi = await import(fetchUrl); + const m02Api = await import( + fetchUrl.replace( + /B04_PreProcess\/B04_PreProcess_Api_Fetch\.ts.*$/, + "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts", + ) + ); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const soils = () => [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])]; + const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const station = () => + items() + .find((l) => l.textContent.trim().endsWith(PIPE)) + ?.textContent.trim() + .split(PIPE)[0]; + const pickPipe = async () => { + await until(() => items().length > 0); + const chosen = document.querySelector(".b05-route__irregular-list li.is-selected"); + if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return; + const li = items().find((l) => l.textContent.trim().endsWith(PIPE)); + (li.querySelector("button") ?? li).click(); + await until(() => spec(), 5000); + await w(800); + }; + const tierRow = () => + spec() + ?.querySelector('[data-spec-container="outlet"]') + ?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)"); + const look = async () => { + const filled = (await m02Api.readFilled(projectId, "구조물집계표")).문서; + const points = (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points ?? []; + const sta = station(); + // 흙막이 값이 있는 관 지점마다 파일의 흙막이 번호 + const fileSoil = Object.fromEntries( + points + .map((p) => [ + p.chainage_m, + [ + ...new Set( + Object.keys(p.options ?? {}) + .map((k) => /^soil_add_(\d+)_/.exec(k)?.[1]) + .filter(Boolean), + ), + ].sort(), + ]) + .filter(([, list]) => list.length), + ); + return { + station: sta, + tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null, + soilBoxes: soils().length, + soilValues: soils().map( + (b) => + b.querySelector( + '[data-spec-item="soil_add.form"] select, [data-spec-item="soil_add.form"] input', + )?.value ?? "", + ), + fileSoil, + rows: filled.줄.filter((r) => !r.고정).length, + notes: (filled.알림 ?? []).filter((n) => n.startsWith("집계 항목")), + }; + }; + const pickLink = async (box) => { + box.querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(500); + }; + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 90000); + await w(1500); + }; + await pickPipe(); + const out = { mode: ARGS.mode, pipe: PIPE }; + // prep — 보이는 흙막이 상자마다 구조물 조합 잇기 · 마지막 상자 형태 = ARGS.form → [저장] + if (ARGS.mode === "prep") { + for (const box of soils()) await pickLink(box); + const el = soils().at(-1)?.querySelector('[data-spec-item="soil_add.form"] select'); + if (el && ARGS.form) { + el.value = ARGS.form; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(1500); + } + await save(); + } + if (ARGS.mode === "tier+" || ARGS.mode === "tier-") { + const btns = [...(tierRow()?.querySelectorAll("button") ?? [])]; + btns.find((b) => b.textContent === (ARGS.mode === "tier+" ? "+" : "-"))?.click(); + await w(1500); + await save(); + } + Object.assign(out, await look()); + return JSON.stringify(out); +})(); 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; +})(); diff --git a/resources/tester/ui_checks/54-17_구조물조합_2행.js b/resources/tester/ui_checks/54-17_구조물조합_2행.js new file mode 100644 index 000000000..04aab2119 --- /dev/null +++ b/resources/tester/ui_checks/54-17_구조물조합_2행.js @@ -0,0 +1,68 @@ +/* 54-17 — B05 · B06 좌측 「구조물 배치」 구조물 조합 잇기 줄 = 옵션 칸 꼴 2행 + * 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계(#/b06-section) · 검증 프로젝트(랩탑_보조) + * 부르는 법: 한 번 — 배관 · 맹암거 · 반사경 · 옹벽을 폼에서 골라(저장 안 함) 보이는 잇기 줄을 잼 · + * 결과를 sessionStorage `m5417` 에 page 별로 두고 B05 = B06 을 견줌 · 끝나면 폼 고르기를 비움 + * 판정: 제목 · 값 두 줄(제목 아래 = 값) · 제목 글꼴/크기/색 = 옵션 칸 제목 · 값 글꼴/크기/높이/테두리 = 입력 칸 + * 쓰는 것: 없음(폼 고르기만 · 저장 안 함) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const page = location.hash.includes("b06") ? "B06" : "B05"; + const sec = document.querySelector(".b05-structure-section"); + const [g, t] = sec.querySelectorAll(".b05-structure__grid select"); + const pick = async (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(400); + }; + const vis = (el) => !!el && el.getClientRects().length > 0; + const cs = (el) => getComputedStyle(el); + const font = (el) => [cs(el).fontFamily, cs(el).fontSize, cs(el).color].join("|"); + const box = (el) => + [ + cs(el).fontFamily, + cs(el).fontSize, + cs(el).height, + cs(el).padding, + cs(el).borderTopWidth, + cs(el).borderRadius, + ].join("|"); + const TARGETS = { + 배관: ["A", "pipe"], + 맹암거: ["A", "cross_underdrain"], + 반사경: ["E", "mirror"], + 옹벽: ["C", "retaining_wall"], + }; + const out = {}; + for (const [name, [gv, tv]] of Object.entries(TARGETS)) { + await pick(g, gv); + await pick(t, tv); + const rows = [...sec.querySelectorAll("[data-b05-summary=row]")].filter(vis); + const cap = [...sec.querySelectorAll(".b05-route__field > span")].find( + (s) => vis(s) && !s.closest("[data-b05-summary]"), + ); + const input = [ + ...sec.querySelectorAll(".b05-route__field input, .b05-route__field select"), + ].find((i) => vis(i) && !i.closest("[data-b05-summary]") && i.type !== "checkbox"); + out[name] = rows.map((r) => { + const title = r.children[0]; + const nameEl = r.querySelector(".b05-structure__summary-name"); + return { + title: title.textContent, + value: nameEl.value, + twoLines: title.getBoundingClientRect().bottom <= nameEl.getBoundingClientRect().top, + titleFont: font(title) === font(cap), + valueBox: box(nameEl) === box(input), + buttons: [...r.querySelectorAll("button")].map((b) => b.textContent).join(","), + fonts: { title: font(title), value: box(nameEl) }, + }; + }); + } + await pick(g, ""); + const store = JSON.parse(sessionStorage.getItem("m5417") || "{}"); + store[page] = out; + sessionStorage.setItem("m5417", JSON.stringify(store)); + const strip = (o) => JSON.stringify(o, (k, v) => (k === "value" ? undefined : v)); + const same = store.B05 && store.B06 ? strip(store.B05) === strip(store.B06) : null; + return JSON.stringify({ page, out, B05eqB06: same }); +})(); diff --git a/resources/tester/ui_checks/54-6_사면안정_컨테이너.js b/resources/tester/ui_checks/54-6_사면안정_컨테이너.js index 08d22c00c..080b98047 100644 --- a/resources/tester/ui_checks/54-6_사면안정_컨테이너.js +++ b/resources/tester/ui_checks/54-6_사면안정_컨테이너.js @@ -79,7 +79,7 @@ typeRows: [...section().querySelectorAll(".b05-structure__summary")].filter( (n) => !n.closest("[data-b05-group]") && visible(n), ).length, - link: box?.querySelector(".b05-structure__summary-name")?.textContent.trim(), + link: box?.querySelector(".b05-structure__summary-name")?.value.trim(), }; }; const ok = (type, s) =>