wip(B05·B06): 54-27 · 54-28 유출 「추가 흙막이(단)」 칸 · 자동 자리 글 삭제 — 중지(사용자) · 남은 일 횡단 흙막이 옮김 → 좌측 관 길이 화면 확인

- 관 보호공(유출) 상자 안 「추가 흙막이(단)」 칸(- 숫자 + · 0~9 · 지형이 세운 만큼) — 횡단 조정 단 수와 같은 자리
- 종단 보기에서 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀
- 늘어난 흙막이 상자 구조 칸 「흙막이 (기본)」
- 배관 유출 칸은 옛 단 수 줄을 안 받음(중복 방지)
- 관 길이 · 좌우 · 상하(사면) 자동 자리 읽기 글과 문구 키 삭제
- 벽 조작값이 바뀌면 좌측 관 길이 다시 셈(120ms 모음)
- 시험 test_54_27_soil_tier_field(단 칸 · N → 상자 N · 램프 자리 · 자동 자리 글 없음)

화면 확인(B05 12+18): 단 0→1→2→1 에 상자 0→1→2→1 · 램프 3→4→5→4 · 자동 자리 글 0 · B06 카드 다단 벽 2
남은 일: 횡단 흙막이 옮김 → 좌측 관 길이 바뀜 화면 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-06 13:00:32 +09:00
co-authored by Claude Opus 5.5
parent 16746f2bb7
commit cb8836c6a3
13 changed files with 364 additions and 75 deletions
+3 -1
View File
@@ -59,6 +59,8 @@ export interface SpecFormHooks {
* [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) ·
* undefined = 손 [추가]. */
count?: (c: SpecContainer) => number | null | undefined;
/** 컨테이너 칸 끝에 덧붙이는 칸(54-27 배관 유출 「추가 흙막이(단)」 · 값은 저장 옵션 밖). */
fields?: (c: SpecContainer, values: Record<string, string>) => HTMLElement[];
/** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) ·
* null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */
shown?: (
@@ -222,7 +224,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
);
return wrap;
});
box.body.append(grid(...fields));
box.body.append(grid(...fields, ...(hooks.fields?.(c, values) ?? [])));
if (c.required) {
const slot = slotOf(c.id);
// B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1)
@@ -5,7 +5,8 @@
* 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함).
* 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 ·
* 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝).
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) — 읽기만.
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) ·
* 쓰기는 횡단 보기 단 수 제어로만(`setPipeExtraCount` · 54-27).
* ========================================================================== */
import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets";
@@ -25,6 +26,23 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe
extraCountSource = source;
}
/** 유출 단 수를 쓰는 곳 — 횡단 보기의 단 수 제어(54-27) · 종단 보기는 횡단 보기를 숨긴 채 세워 씀. */
let extraCountSink: ((section: CrossSection, count: number) => void) | null = null;
let crossOpener: (() => Promise<void>) | null = null;
export function setPipeExtraCountSink(sink: (section: CrossSection, count: number) => void): void {
extraCountSink = sink;
}
export function setPipeCrossOpener(opener: () => Promise<void>): void {
crossOpener = opener;
}
/** 좌측 「추가 흙막이(단)」 → 주인 측점 유출 단 수(B06 횡단 조정과 같은 저장 자리 · 54-27). */
export async function setPipeExtraCount(chainageM: number | null, count: number): Promise<void> {
if (!extraCountSink) await crossOpener?.();
const found = ownerSection(chainageM);
if (found) extraCountSink?.(found.owner, count);
}
/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */
type InletChoice = NonNullable<CrossSection["design"]>["inlet_structure"];
let inletSource: ((section: CrossSection) => InletChoice) | null = null;
+15
View File
@@ -75,6 +75,7 @@ import {
import type { DesignPageHost } from "./design_page";
import {
pipeExtraCount,
setPipeExtraCountSink,
setPipeExtraCountSource,
setPipeInletSource,
setPipeSectionsSource,
@@ -132,6 +133,7 @@ export async function mountCrossView(
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
let pipeReloadTimer = 0;
let wallAdjustTimer = 0;
const structuresPanel = createB06StructuresPanel({
projectId,
shared,
@@ -294,9 +296,21 @@ export async function mountCrossView(
projectId: () => projectId,
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
onExtraCount: () => structuresPanel.facility.recount(),
// 흙막이 자리를 옮기면 관 길이가 바뀜 — 좌측 「관 길이 (m)」 다시(54-28) · 끌기 · 되받기는 한 번으로 모음
onWallAdjust: () => {
window.clearTimeout(wallAdjustTimer);
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
},
});
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
setPipeExtraCountSink((owner, count) => {
const picked = stationControls.revetOffset.highlightFor(owner) ?? "";
if (/^extra\d+$/.test(picked) && Number(picked.slice(5)) >= count)
stationControls.revetOffset.select(owner.chainage_m, "outlet");
stationControls.extraWalls.setCount(owner.chainage_m, count, "outlet");
});
// 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선
setPipeInletSource((section) => stationControls.inletStructure.valueFor(section));
@@ -527,6 +541,7 @@ export async function mountCrossView(
});
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
window.clearTimeout(wallAdjustTimer);
sectionView.dispose();
});
+3 -1
View File
@@ -69,7 +69,7 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import "../../B05_Profile/B05_Profile_UI_Style.css";
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
@@ -196,6 +196,8 @@ export async function mountProfileView(
let currentSectionDetail: SectionDetailResponse | null = null;
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null);
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
setPipeCrossOpener(() => shared.ensureView("cross"));
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
let corridorVisible = true;
let routeReady = false;
@@ -40,8 +40,10 @@ import {
INLET_REVET_KEYS,
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
OUTLET_REVET_KEYS,
stepper,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
@@ -61,6 +63,7 @@ import {
pipeDrawn,
pipeExtraCount,
pipeLengthM,
setPipeExtraCount,
} from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
@@ -298,7 +301,28 @@ export function createFacilityOptionsForm(
}
return drawnMemo;
};
// 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감
const tiersInput = numberInput("1", "0", "횡단 설계 뒤");
tiersInput.max = "9";
const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0));
tiersRow.dataset.specItem = "outlet.tiers";
tiersRow
.querySelectorAll("button")
.forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
const paintTiers = (values: Record<string, string>): HTMLElement => {
const count = pipeExtraCount(chainage);
tiersInput.value = count === null ? "" : String(count);
tiersInput.disabled = count === null;
tiersRow.hidden = !!values.structure && values.structure !== "기슭막이";
return tiersRow;
};
tiersInput.addEventListener("change", () => {
const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0)));
// 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림
void setPipeExtraCount(chainage, count).then(() => pipeForm.recount());
});
const pipeForm = createSpecForm(specLinks, {
fields: (c, values) => (c.id === "outlet" ? [paintTiers(values)] : []),
shown: (c, item, values, index) => {
if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) {
const drawn = drawnNow();
@@ -307,6 +331,8 @@ export function createFacilityOptionsForm(
: { value: "", tag: "자동(지형 규칙)" };
}
if (values[item.id]) return null;
// 흙막이 상자 구조 = 표 01 흙막이 하나(54-27)
if (c.id === "soil_add" && item.id === "structure") return { value: "기슭막이", tag: "기본" };
if (item.id !== "form") return byDefault(branchOf(item, values)?.key);
const basin = c.id === "inlet" && values.structure === "집수정";
const drawn = drawnNow();
+11 -11
View File
@@ -57,6 +57,12 @@ const SIDE_ROWS = [
".b06-structure-panel__struct--wall-height",
].join(", ");
/** 배관 유출 칸 — 읽기 줄(54-1) · 단 수 줄(폼 「추가 흙막이(단)」 칸이 맡음 · 54-27)은 안 받음. */
const PIPE_OUTLET_ROWS = SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "").replace(
".b06-structure-panel__struct--tier, ",
"",
);
/** 이식 자리가 지금 화면에 서 있는지 — 그 위 어느 줄이든 숨었으면(다른 시설 명세 폼 등) 옮기지 않는다. */
function slotUsable(target: HTMLElement | undefined): target is HTMLElement {
return !!target?.closest("fieldset") && !target.closest("[hidden]");
@@ -156,11 +162,7 @@ function relocateSideRows(panelRoot: HTMLElement): void {
]
: undefined;
if (soil) {
moveRows(
panelRoot,
SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", ""),
ownerSlot,
);
moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot);
setGroupLabel(soil, "");
soil.classList.add("is-active");
soil.scrollIntoView({ block: "nearest" });
@@ -172,14 +174,12 @@ function relocateSideRows(panelRoot: HTMLElement): void {
moveRows(panelRoot, EXTRA_ROWS, target);
// 단 수·옵션은 그 단이 딸린 부모 벽의 값이라 부모 칸에 그대로 세운다 — 다단을
// 고를 때 그 두 행이 사라지던 것을 막는다(2026-08-30 사용자).
if (slotUsable(ownerSlot)) moveRows(panelRoot, SIDE_ROWS, ownerSlot);
if (slotUsable(ownerSlot))
moveRows(panelRoot, ownerSlot === sideSlots.outlet ? PIPE_OUTLET_ROWS : SIDE_ROWS, ownerSlot);
} else {
if (!slotUsable(target)) return;
// 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내)을 안 받음(54-1)
const rows =
target === sideSlots.outlet
? SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "")
: SIDE_ROWS;
// 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내) · 단 수 줄을 안 받음(54-1 · 54-27)
const rows = target === sideSlots.outlet ? PIPE_OUTLET_ROWS : SIDE_ROWS;
moveRows(panelRoot, rows, target);
}
// 고른 구조물이 어느 쪽인지 그룹 테두리로 알린다(2026-08-29 사용자 지시 3).
@@ -44,8 +44,6 @@ export interface StructurePanelDeps {
setForm: (key: RevetKey, form: string) => void;
/** 자동 자리로 되돌린다(4축 모두). */
reset: (key: RevetKey) => void;
/** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */
pipeLengthM: () => number | null;
/** 창을 닫는다 = 구조물 선택 해제. */
close: () => void;
/** 유입측 구조물 형식(드롭다운 값 — 2026-08-22 사용자). */
@@ -155,16 +153,13 @@ export function buildStructurePanel(
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
// 정보 표시(2026-08-22 사용자 — 항목당 한 행): 관 길이 / 좌우 / 상하.
// 끄는 동안 미리 보기 글만 — 관 길이 · 좌우 · 상하 자동 자리 글은 뺌(54-28).
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
// 사본의 읽기 줄은 폼 칸으로 옮겨 붙는다 — 끌어 맞춘 좌우 · 상하가 폼에서도 보인다(24-4a).
if (opts?.dock) value.classList.add("b06-structure-panel__struct--readout");
const pipeLine = document.createElement("span");
const lateralLine = document.createElement("span");
const slopeLine = document.createElement("span");
const dragLine = dragReadout();
value.append(pipeLine, lateralLine, slopeLine, dragLine);
value.append(dragLine);
let current: RevetKey | null = null;
const act = (run: (key: RevetKey) => void) => () => {
@@ -630,41 +625,6 @@ export function buildStructurePanel(
"is-hidden",
!revetWall && linkState === null && currentSpan === null,
);
const adjust = deps.adjustFor(key);
// 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥,
// 상하는 위/아래로 적는다.
const pipeLength = deps.pipeLengthM();
pipeLine.classList.toggle("is-hidden", pipeLength === null);
pipeLine.textContent =
pipeLength === null ? "" : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m`;
lateralLine.textContent =
`${L("B06_Cross_Lateral_Label")} ` +
(Math.abs(adjust.x) > 1e-9
? `${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m`
: L("B06_Cross_Revet_Auto"));
// d = null → 자동(기본 자리). 숫자는 성토선 0점(벽 상단=노견) 기준 절대값
// (배관 벽 — 2026-08-23). 0 = 성토선 0 지점.
slopeLine.textContent =
`${L("B06_Cross_Slope_Label")} ` +
(adjust.d == null
? L("B06_Cross_Revet_Auto")
: Math.abs(adjust.d) > 1e-9
? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m`
: "0m");
// 집수정은 벽 값이 아니라 **집수정 자리**를 적는다 — 끌어 맞춘 위치가 폼 칸에서도 보인다(24-4a).
if (isBasin) {
const basin = deps.basinAdjustFor();
lateralLine.textContent = `${L("B06_Cross_Lateral_Label")} ${
basin.lateralM > 1e-9
? `${L("B06_Cross_Revet_Outward")} ${basin.lateralM.toFixed(1)}m`
: L("B06_Cross_Revet_Auto")
}`;
slopeLine.textContent = `${L("B06_Cross_Slope_Label")} ${
basin.slopeM > 1e-9
? `${L("B06_Cross_Revet_DirDown")} ${basin.slopeM.toFixed(1)}m`
: L("B06_Cross_Revet_Auto")
}`;
}
// 행 표시가 다 끝난 뒤에 되살린다 — 숨김 토글로 창 높이가 바뀌기 전에 넣으면
// 브라우저가 잘라 버린다.
restoreScroll();
+1 -9
View File
@@ -184,7 +184,6 @@ export function createCrossSectionCard(
let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet;
let setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined;
let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
// 이 카드의 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트). 조정창 걸음이
// 배관(1m)과 다르다(0.1m — 2026-08-29 사용자).
@@ -483,13 +482,7 @@ export function createCrossSectionCard(
toDisplayY,
);
}
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19). 관은 소유 측점
// 전용이라 링크 카드에서는 길이를 비우고, 나머지는 링크 카드도 채운다(2026-08-24).
// 숨김 기슭막이·링크 카드는 관이 없거나 옆에서 이어져 온 것 — 관 길이 표기 숨김.
culvertPipeLengthM =
isLinkedCulvert || culvertLayout?.culvert.hidden_pipe
? null
: (culvertLayout?.pipe.lengthM ?? null);
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19) · 링크 카드도 채운다(2026-08-24).
culvertInletIsBasin = !!culvertLayout?.basin;
culvertHiddenPipe = culvertLayout?.culvert.hidden_pipe === true;
if (culvertLayout) {
@@ -621,7 +614,6 @@ export function createCrossSectionCard(
drawnWallKeys: () => [...culvertWallSpecs.keys()],
formOfWall,
appliedD: () => culvertAppliedD,
pipeLengthM: () => culvertPipeLengthM,
inletIsBasin: () => culvertInletIsBasin,
hiddenPipe: () => culvertHiddenPipe,
inletOptions: () => culvertInletOptions,
@@ -64,7 +64,6 @@ export interface StructurePanelContext {
formOfWall: (key: RevetKey) => string;
/** 마지막 계산의 실제 적용 d(상하) — d 미지정 벽의 ▲▼ 시작값. */
appliedD: () => Map<RevetKey, number>;
pipeLengthM: () => number | null;
inletIsBasin: () => boolean;
/** 기준벽이 독립 기슭막이(관 없는 숨김 세트)인가 — 조정창 걸음 분류에 쓴다. */
hiddenPipe: () => boolean;
@@ -103,7 +102,6 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
setForm: (key, form) => ctx.revetOffset?.update(ctx.adjustChainage(), key, { m: form }),
heightFor: (key) => ctx.heightOfWall(key),
reset: (key) => ctx.revetOffset?.reset(ctx.adjustChainage(), key),
pipeLengthM: () => ctx.pipeLengthM(),
close: () => {
const active = ctx.activeRevet();
if (active) ctx.toggleRevet(active);
@@ -63,6 +63,8 @@ export interface StationControlDeps {
onSaveError?: (message: string) => void;
/** 다단 단 수가 바뀜 — 좌측 배관 흙막이 컨테이너 수를 맞춤(54-1). */
onExtraCount?: () => void;
/** 벽 조작값이 바뀜 — 좌측 관 길이(자동)를 다시 셈(54-28). */
onWallAdjust?: () => void;
}
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
@@ -206,6 +208,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
if (isDefaultAdjust(value)) delete bucket[role];
else bucket[role] = { ...value };
design.revet_adjust = bucket;
deps.onWallAdjust?.();
};
const axisEq = (a: number | null, b: number | null): boolean =>
a == null || b == null ? a === b : Math.abs(a - b) < 1e-9;
@@ -0,0 +1,203 @@
/* 배관 유출 「추가 흙막이(단)」 칸(PLAN 54-27) 단위검증 헬퍼 — 실제 시설 폼(`createFacilityOptionsForm`)을
* 그 자리에서 트랜스파일해 Node 로 돌린다. 단 수 읽기 · 쓰기(`design_structure_pipe_calc`)만 흉내 —
* 쓰면 그 수가 곧 지형이 세운 단 수(한도 2). 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_27_soil_tier_field.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
// ── 화면 그릇 흉내 — 태그 · 클래스 · data · 이벤트만 ─────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this.style = {};
this.parentElement = null;
this.hidden = false;
this.disabled = false;
this._classes = new Set();
this._listeners = {};
this._text = "";
this.value = "";
const self = this;
this.classList = {
add: (...n) => n.forEach((c) => self._classes.add(c)),
remove: (...n) => n.forEach((c) => self._classes.delete(c)),
toggle: (c, f) =>
(f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c),
contains: (c) => self._classes.has(c),
};
}
set className(v) {
this._classes = new Set(String(v).split(/\s+/).filter(Boolean));
}
set textContent(v) {
this._text = String(v);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent ?? "").join("");
}
get childElementCount() {
return this.children.length;
}
append(...kids) {
kids.forEach((k) => {
const node = typeof k === "string" ? { textContent: k } : k;
if (node.parentElement)
node.parentElement.children.splice(node.parentElement.children.indexOf(node), 1);
node.parentElement = this;
this.children.push(node);
});
}
replaceChildren(...kids) {
this.children.forEach((k) => (k.parentElement = null));
this.children = [];
this.append(...kids);
}
add(kid) {
this.append(kid);
}
setAttribute(n, v) {
this[n] = v;
}
addEventListener(type, fn) {
(this._listeners[type] ??= []).push(fn);
}
dispatchEvent(event) {
(this._listeners[event.type] ?? []).forEach((fn) => fn(event));
}
closest() {
return null;
}
matches(sel) {
return sel.split(",").some((s) => {
s = s.trim();
return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase();
});
}
querySelectorAll(sel) {
const found = [];
const walk = (n) =>
(n.children ?? []).forEach((k) => {
if (k.matches?.(sel)) found.push(k);
walk(k);
});
walk(this);
return found;
}
querySelector(sel) {
return this.querySelectorAll(sel)[0] ?? null;
}
}
globalThis.document = {
createElement: (t) => new FakeNode(t),
createComment: () => new FakeNode("#comment"),
};
globalThis.Event = function (type) {
this.type = type;
};
globalThis.queueMicrotask = (fn) => fn();
globalThis.Option = function (text, value) {
const o = new FakeNode("option");
o.textContent = text;
o.value = value;
return o;
};
// 단 수 자리 흉내 — 쓰면 지형 한도(2)까지만 섬
const LIMIT = 2;
let stored = 0;
const writes = [];
const PIPE_CALC = {
guardHeightHint: () => "",
pipeDrawn: () => null,
pipeLengthM: () => null,
pipeExtraCount: () => Math.min(stored, LIMIT),
setPipeExtraCount: (chainage, count) => {
writes.push([chainage, count]);
stored = count;
return Promise.resolve();
},
};
const STUBS = {
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
"../A00_Common/design/design_structure_pipe_calc": PIPE_CALC,
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": {
buildSummaryRow: () => {
const root = new FakeNode("div");
const label = new FakeNode("span");
label.className = "b05-structure__summary-label";
root.append(label);
return { root, set: () => {}, bind: () => {} };
},
},
};
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("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
return loadTs(path.join(path.dirname(full), `${request}.ts`));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const { createFacilityOptionsForm } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Facility.ts"),
);
const form = createFacilityOptionsForm();
form.setChainage(100);
form.setFacility("pipe", { pipe_diameter_mm: 1000 });
const spec = form.root.querySelector(".b05-structure__spec");
const boxesOf = (id) => spec.children.filter((k) => k.dataset?.specContainer === id);
const tierRow = () =>
boxesOf("outlet")[0]
?.querySelectorAll("label")
.find((l) => l.dataset.specItem === "outlet.tiers");
const tierInput = () => tierRow()?.querySelector("input");
const snap = () => ({
value: tierInput()?.value ?? null,
boxes: boxesOf("soil_add").length,
structures: boxesOf("soil_add").map((b) => b.querySelector("select")?.children[0]?.textContent),
});
async function main() {
const out = { label: tierRow()?.children[0]?.textContent ?? null, start: snap() };
for (const n of ["2", "1", "5", "0"]) {
const input = tierInput();
input.value = n;
input.dispatchEvent(new Event("change"));
await Promise.resolve();
await Promise.resolve();
out[`set${n}`] = snap();
}
// 횡단 조정에서 바꿈 → 좌측 칸 · 상자 따라감(B06 이 recount 를 부름)
stored = 2;
form.recount();
out.fromCross = snap();
out.writes = writes;
// 다른 구조(옛 값)면 칸을 가림
form.setFacility("pipe", { pipe_diameter_mm: 1000, outlet_type: "옛값" });
out.otherHidden = tierRow()?.hidden ?? null;
process.stdout.write(JSON.stringify(out));
}
main().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
@@ -0,0 +1,78 @@
"""배관 유출 「추가 흙막이(단)」 칸 (PLAN 54-27).
짝: `B05_Profile/B05_Profile_UI_Drainage_Facility.ts`(유출 상자 안 단 칸 · 흙막이 상자 구조 「흙막이 (기본)」) ·
`A00_Common/design/design_structure_pipe_calc.ts`(`setPipeExtraCount` = 횡단 조정 단 수와 같은 자리).
실제 TS 를 `helper_54_27_soil_tier_field.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_54_27_soil_tier_field.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) -> None:
assert out["label"] == "추가 흙막이(단)"
assert out["start"] == {"value": "0", "boxes": 0, "structures": []}
def test_단_수_N_이면_흙막이_상자_N(out) -> None:
assert (out["set2"]["value"], out["set2"]["boxes"]) == ("2", 2)
assert (out["set1"]["value"], out["set1"]["boxes"]) == ("1", 1)
assert (out["set0"]["value"], out["set0"]["boxes"]) == ("0", 0)
def test_지형_한도를_넘으면_세운_만큼(out) -> None:
assert (out["set5"]["value"], out["set5"]["boxes"]) == ("2", 2)
def test_좌측_칸은_횡단_단_수_자리에_씀(out) -> None:
assert out["writes"] == [[100, 2], [100, 1], [100, 5], [100, 0]]
def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None:
assert (out["fromCross"]["value"], out["fromCross"]["boxes"]) == ("2", 2)
def test_늘어난_흙막이_상자_구조는_흙막이_기본(out) -> None:
assert out["set2"]["structures"] == ["흙막이 (기본)", "흙막이 (기본)"]
def test_흙막이_아닌_구조면_칸을_가림(out) -> None:
assert out["otherHidden"] is True
def _src(rel: str) -> str:
with open(os.path.join(HERE, "..", "..", rel), encoding="utf-8") as f:
return f.read()
def test_자동_자리_읽기_글이_없음() -> None:
"""54-28 — 관 길이 · 좌우 바깥 · 상하(사면) 자동 자리 글을 만드는 뿌리(문구 · 줄)가 없음."""
locale = _src("ui_template/ui_template_locale_b2.ts")
for key in ("B06_Cross_Revet_Auto", "B06_Cross_Revet_Pipe", "B06_Cross_Lateral_Label"):
assert key not in locale
assert "lateralLine" not in _src("B06_Section/B06_Section_UI_Cross_Structure_Panel.ts")
def test_배관_유출_칸은_옛_단_수_줄을_안_받음() -> None:
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
assert '".b06-structure-panel__struct--tier, ",\n "",' in dock
-8
View File
@@ -243,10 +243,6 @@ export const ui_locales_b2 = {
],
B06_Cross_Revet_Label: ["기슭막이", "Revetment"],
B06_Cross_Revet_Close: ["조정창 닫기", "Close adjuster"],
B06_Cross_Revet_Auto: ["자동 자리", "Solved position"],
B06_Cross_Revet_Pipe: ["관 길이", "Pipe length"],
B06_Cross_Revet_Outward: ["바깥", "outward"],
B06_Cross_Revet_Inward: ["안쪽", "inward"],
B06_Cross_Revet_Left: ["왼쪽으로 — 관 길이 1m 단위", "Move left — 1m of pipe length"],
B06_Cross_Revet_Right: ["오른쪽으로 — 관 길이 1m 단위", "Move right — 1m of pipe length"],
B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"],
@@ -259,8 +255,6 @@ export const ui_locales_b2 = {
B06_Cross_Revet_Extra: ["기슭막이(추가 {n})", "Revetment (extra {n})"],
B06_Cross_Revet_Up: ["사면 위로(대각) 1m", "Up along fill slope 1m"],
B06_Cross_Revet_Down: ["사면 아래로(대각) 1m", "Down along fill slope 1m"],
B06_Cross_Revet_DirUp: ["위", "up"],
B06_Cross_Revet_DirDown: ["아래", "down"],
B06_Cross_Revet_Limit_Up: [
"사면 위로는 여기까지입니다 — 최소 성토고·토피(역경사) 한계",
"Cannot move further up the slope — min fill height / cover limit",
@@ -271,8 +265,6 @@ export const ui_locales_b2 = {
],
B06_Cross_Height_Label: ["높이", "Height"],
B06_Cross_Move_Label: ["이동(좌우·사면 상하)", "Move (lateral / along slope)"],
B06_Cross_Lateral_Label: ["좌우", "Lateral"],
B06_Cross_Slope_Label: ["상하(사면)", "Along slope"],
B06_Cross_Height_Minus: ["높이 −0.1m", "Height −0.1m"],
B06_Cross_Height_Plus: ["높이 +0.1m", "Height +0.1m"],
B06_Cross_Height_Limit: [