Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-06 11:11:54 +09:00
11 changed files with 580 additions and 18 deletions
+10 -6
View File
@@ -56,7 +56,8 @@ export interface SpecFormHooks {
hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string;
onChange?: (c: SpecContainer, item: SpecItem) => void;
/** 선택 컨테이너 개수를 밖에서 정함(54-1 배관 흙막이 = 추가 기슭막이(단)) — 수면 그만큼 늘 보임 ·
* [추가] · [빼기] 없음 · 줄면 끝에서부터 빠짐 · null = 모름(저장된 것 그대로) · undefined = 손 [추가]. */
* [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) ·
* undefined = 손 [추가]. */
count?: (c: SpecContainer) => number | null | undefined;
}
@@ -228,15 +229,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return box.root;
};
/** 밖에서 정한 개수로 맞춤 — 줄면 끝 인스턴스 · 잇기를 뺌. 바뀌었으면 true. */
/** 밖에서 정한 개수 — 모자라면 빈 인스턴스를 늘림(늘었으면 true) · 넘친 인스턴스 · 잇기는
* 가리기만 하고 값에 남김(54-10 · 파일에 그대로). */
const fit = (c: SpecContainer, list: Instance[]): boolean => {
const n = hooks.count?.(c);
if (c.required || n === undefined || n === null || n === list.length) return false;
for (let k = n + 1; k <= list.length; k++) links?.set(linkOf(c, k), null);
list.splice(n);
if (c.required || n === undefined || n === null || n <= list.length) return false;
while (list.length < n) list.push({});
return true;
};
const shown = (c: SpecContainer, list: Instance[]): Instance[] => {
const n = c.required ? null : hooks.count?.(c);
return n === undefined || n === null ? list : list.slice(0, n);
};
function paint(quiet = true): void {
rows.length = 0;
@@ -245,7 +249,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
for (const c of spec?.containers ?? []) {
const list = (state[c.id] ??= c.required ? [{}] : []);
if (fit(c, list)) fitted.push(c);
list.forEach((values, index) => boxes.push(instanceBox(c, values, index)));
shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index)));
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
const add = document.createElement("button");
add.type = "button";
@@ -5,7 +5,7 @@
* 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함).
* 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 ·
* 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝).
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) — 읽기만.
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) — 읽기만.
* ========================================================================== */
import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets";
@@ -51,11 +51,17 @@ export function pipeLengthM(chainageM: number | null): number | null {
return typeof length === "number" && length > 0 ? length : null;
}
/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 — 횡단 자료가 없으면 null(모름). */
/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 중 지형이 세운 단(54-10) — 횡단 자료가 없으면
* null(모름). 저장 단 수가 더 커도 넘친 단은 컨테이너를 안 세움(값은 파일에 그대로). */
export function pipeExtraCount(chainageM: number | null): number | null {
const owner = ownerSection(chainageM)?.owner;
if (!owner) return null;
return extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0;
const found = ownerSection(chainageM);
if (!found) return null;
const { owner, sections } = found;
const outlet = extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0;
if (!outlet || !owner.design) return outlet;
const counts = { basin: 0, ...owner.design.extra_wall_counts, outlet };
const probe = { ...owner, design: { ...owner.design, extra_wall_counts: counts } };
return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet;
}
/** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */
+10 -2
View File
@@ -41,7 +41,7 @@ import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
replayDraftPipeOptions,
@@ -73,7 +73,11 @@ import {
createStandardSlot,
} from "../../B06_Section/B06_Section_UI_Page_Panel";
import type { DesignPageHost } from "./design_page";
import { setPipeExtraCountSource, setPipeSectionsSource } from "./design_structure_pipe_calc";
import {
pipeExtraCount,
setPipeExtraCountSource,
setPipeSectionsSource,
} from "./design_structure_pipe_calc";
import {
bindDesignView,
bindPipeOptions,
@@ -152,6 +156,7 @@ export async function mountCrossView(
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
if (adjust?.h == null) return null;
if (role !== "inlet" && role !== "outlet") return adjust.h; // 다단 = 기하가 되받은 적용 높이(54-10)
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
.pureHeight;
},
@@ -164,6 +169,7 @@ export async function mountCrossView(
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
if (role !== "inlet" && role !== "outlet") return revetFormOf(adjust.m); // 다단(54-10)
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
},
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
@@ -333,6 +339,8 @@ export async function mountCrossView(
ford: stationControls.ford,
box: stationControls.box,
revetOffset: stationControls.revetOffset,
structureSpan: stationControls.structureSpan,
soilTiers: pipeExtraCount,
overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
};
+21
View File
@@ -145,6 +145,27 @@ function relocateSideRows(panelRoot: HTMLElement): void {
const group = target.closest<HTMLElement>("fieldset");
if (!group) return;
const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet");
// 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 ·
// 단 수 · 옵션은 부모(유출) 칸에 그대로
const soil =
ownerSlot === sideSlots.outlet && slotUsable(ownerSlot)
? ownerSlot
.closest(".b05-structure__spec")
?.querySelectorAll<HTMLElement>('fieldset[data-spec-container="soil_add"]')[
Number(panelRoot.dataset.extraIndex)
]
: undefined;
if (soil) {
moveRows(
panelRoot,
SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", ""),
ownerSlot,
);
setGroupLabel(soil, "");
soil.classList.add("is-active");
soil.scrollIntoView({ block: "nearest" });
return;
}
const owner = ownerSlot.closest("fieldset");
if (owner && owner !== group) owner.after(group);
group.hidden = false;
@@ -588,6 +588,7 @@ export function buildStructurePanel(
// 이름 자체로(2026-08-30 사용자 지시 1). 어느 구조물을 만지는지 폼에서 읽힌다.
// 다단은 칸 이름이 이미 "추가 기슭막이"라 단 번호만 붙인다 — "추가 기슭막이 (2단)".
root.dataset.panelTitle = isExtra ? `${extraIndex + 1}단` : (title.textContent ?? "");
root.dataset.extraIndex = isExtra ? String(extraIndex) : ""; // 흙막이 컨테이너 차례(54-10)
const tiers =
tierChannel === "outlet"
? deps.extraState()
@@ -12,7 +12,15 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
import {
applySpanPatch,
EXTRA_WALL_MIN_HEIGHT_M,
REVET_EMBED_DEPTH_M,
revetFormOf,
revetHeightLimit,
revetWallSpec,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
type StationControls = ReturnType<typeof createStationControls>;
@@ -23,6 +31,10 @@ export interface PipeOptionsContext {
ford: StationControls["ford"];
box: StationControls["box"];
revetOffset: StationControls["revetOffset"];
/** 다단 단별 구간값 — 흙막이 컨테이너 길이 · 전 · 후(54-10). */
structureSpan?: StationControls["structureSpan"];
/** 흙막이 컨테이너 수 = 지형이 세운 유출 다단 수(`pipeExtraCount` · 54-10) — 없으면 다단 안 만짐. */
soilTiers?: (chainageM: number) => number | null;
/** 기하가 잘라 낸 실제 값을 폼에 되돌린다. */
overrideOptions: (chainageM: number, values: Record<string, number | string>) => void;
refreshCard: (chainageM: number) => void;
@@ -129,6 +141,7 @@ export function applyPipeOptionsToCache(
);
}
}
if (!culvert.hidden_pipe) applySoilTiers(ctx, owner, patch, whole);
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
@@ -149,6 +162,89 @@ export function applyPipeOptionsToCache(
}
}
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
* 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */
const soilSent = new Map<string, { h: number | null; m: string | null }>();
/**
* 흙막이 n 컨테이너(`soil_add_{n}_*`) → 유출 다단 n 단(`extra{n-1}`) 그림(54-10). 형태 · 높이 = 조정값
* (m · h · 빈 칸 = 자동) · 길이 · 전 · 후 = 단별 구간값(빈 칸 = 그대로). 지형이 세운 단까지만 —
* 넘친 컨테이너 값은 파일에만 남음. 높이 = 요청 · 서는 키는 기하 규칙 그대로(지형 맞춤 · 형태 한계) ·
* 형태 한계 · 하한을 넘은 요청만 폼에 되돌림.
*/
function applySoilTiers(
ctx: PipeOptionsContext,
owner: CrossSection,
patch: Record<string, number | string>,
whole: boolean,
): void {
const culvert = owner.culvert!;
const ownerForm = revetWallSpec(
culvert.outlet,
ctx.revetOffset.adjustFor(owner, "outlet"),
false,
culvert.diameter_m,
).form;
const value = (n: number, item: string): number | undefined => {
const raw = patch[`soil_add_${n}_${item}`];
return raw === undefined || raw === "" || !Number.isFinite(Number(raw))
? undefined
: Number(raw);
};
const blank = whole ? null : undefined;
const clipped: Record<string, number> = {};
for (let n = 1; n <= (ctx.soilTiers?.(owner.chainage_m) ?? 0); n++) {
const role = `extra${n - 1}` as const;
const key = `${owner.chainage_m.toFixed(2)}:${role}`;
const form = patch[`soil_add_${n}_form`];
const want = {
h: value(n, "height") ?? blank,
m: typeof form === "string" && form ? form : blank,
};
if (want.h != null) {
const limit = revetHeightLimit(
revetFormOf(want.m ?? ctx.revetOffset.adjustFor(owner, role).m, ownerForm),
);
const h = Math.min(
Math.max(want.h, EXTRA_WALL_MIN_HEIGHT_M, REVET_EMBED_DEPTH_M + 0.1),
limit,
);
if (Math.abs(h - want.h) > 0.05) {
clipped[`soil_add_${n}_height`] = Number(h.toFixed(1));
showToast(
`흙막이 ${n} 높이는 ${h < want.h ? "형태 한계로" : "최소"} ${h.toFixed(1)}m` +
`${h < want.h ? "까지만" : "로"} 적용됩니다.`,
"error",
);
}
want.h = Number(h.toFixed(1));
}
const sent = soilSent.get(key) ?? ctx.revetOffset.adjustFor(owner, role);
if (
(want.h !== undefined && want.h !== sent.h) ||
(want.m !== undefined && want.m !== sent.m)
) {
ctx.revetOffset.update(owner.chainage_m, role, want);
soilSent.set(key, { h: want.h ?? sent.h, m: want.m ?? sent.m });
}
const span = {
lengthM: value(n, "length"),
beforeM: value(n, "before"),
afterM: value(n, "after"),
};
if (!ctx.structureSpan || Object.values(span).every((v) => v === undefined)) continue;
const current = ctx.structureSpan.tierValuesFor(owner, role);
const next = applySpanPatch(current, span);
if (
(["lengthM", "beforeM", "afterM"] as const).some((k) => Math.abs(next[k] - current[k]) > 1e-6)
)
ctx.structureSpan.updateTier(owner, role, span);
}
// 폼에 되돌림은 이 변경을 패널이 옵션에 담은 뒤 — 지금 되돌리면 옛 옵션으로 폼을 다시 세움
if (Object.keys(clipped).length)
queueMicrotask(() => ctx.overrideOptions(owner.chainage_m, clipped));
}
/** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */
const ADJUST_OWNED = /_revet_(height_m|form)$/;
@@ -78,9 +78,9 @@ export interface B06StructuresPanelDeps {
detail?: () => SectionDetailResponse | null;
/** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본
* 높이도 이 값을 쓴다(조정창 표시값과 같다). */
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null;
wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null;
/** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null;
wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null;
/** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */
wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null;
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
@@ -140,8 +140,8 @@ export function withSpecDefaults(
detail: SectionDetailResponse | null,
chainageM: number,
types: StructureType[],
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null,
wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null,
): Merged | undefined {
const owner = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
@@ -160,5 +160,16 @@ export function withSpecDefaults(
const drawnHeight = wallHeight?.(chainageM, prefix);
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
}
// 다단 n 단 조작값 → 흙막이 n 컨테이너(54-10) — 앞 컨테이너가 비었으면 자리만 둠(차례 지킴)
for (let i = 0; i < 9 && !culvert.hidden_pipe; i++) {
const form = wallForm?.(chainageM, `extra${i}`);
const height = wallHeight?.(chainageM, `extra${i}`);
if (!form && height == null) continue;
for (let n = 1; n <= i; n++)
if (!Object.keys(merged).some((key) => key.startsWith(`soil_add_${n}_`)))
merged[`soil_add_${n}_structure`] = "";
if (form) merged[`soil_add_${i + 1}_form`] = form;
if (height != null) merged[`soil_add_${i + 1}_height`] = Number(height.toFixed(1));
}
return merged;
}
@@ -0,0 +1,216 @@
/* 다단 흙막이 ↔ 흙막이 컨테이너(PLAN 54-10) 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* ① 폼 → 다단 그림(`applyPipeOptionsToCache`) ② 밖에서 정한 개수보다 많은 컨테이너 값은 가리기만(`createSpecForm`).
* 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_10_soil_tiers.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._classes = new Set();
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("");
}
append(...kids) {
kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k));
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
add(kid) {
this.children.push(kid);
}
setAttribute(n, v) {
this[n] = v;
}
addEventListener() {}
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) };
globalThis.queueMicrotask = (fn) => fn(); // 폼 되돌림을 바로 받아 봄
globalThis.Option = function (text, value) {
const o = new FakeNode("option");
o.textContent = text;
o.value = value;
return o;
};
const toasts = [];
const STUBS = {
"@ui/ui_template_elements": { showToast: (msg) => toasts.push(msg) },
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
"./B06_Section_UI_Page_Ford_Controls": {
applyFordFormOptions: () => {},
applyBoxFormOptions: () => {},
},
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null },
};
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 out = {};
// ── ① 폼 → 다단 그림 ──────────────────────────────────────────────────────
const { applyPipeOptionsToCache } = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"),
);
const adjusts = {};
const updates = [];
const tiers = [];
const overrides = [];
const section = {
chainage_m: 100,
culvert: { diameter_m: 1, hidden_pipe: false, inlet: {}, outlet: { revet_form: "돌쌓기(찰)" } },
};
const ctx = {
detail: () => ({ cross_sections: [section] }),
ford: {},
box: {},
revetOffset: {
adjustFor: (_s, role) => adjusts[role] ?? { x: 0, d: null, h: null, m: null },
update: (_c, role, patch) => {
updates.push({ role, ...patch });
const now = adjusts[role] ?? { x: 0, d: null, h: null, m: null };
const m = patch.m === undefined ? now.m : patch.m;
// 기하 되받기(syncApplied) 흉내 — 형태 한계로 자름(돌쌓기(메) 2.0)
const h =
patch.h == null
? patch.h === undefined
? now.h
: null
: Math.min(patch.h, m === "돌쌓기(메)" ? 2.0 : 5.0);
adjusts[role] = { ...now, h, m };
},
},
structureSpan: {
tierValuesFor: () => ({ lengthM: 10, beforeM: 5, afterM: 5 }),
updateTier: (_s, key, patch) => tiers.push({ key, ...patch }),
},
soilTiers: () => 2,
overrideOptions: (_c, values) => overrides.push(values),
refreshCard: () => {},
};
applyPipeOptionsToCache(ctx, 100, {
soil_add_1_form: "콘크리트",
soil_add_1_height: 1.8,
soil_add_1_length: 8,
soil_add_1_before: 3,
soil_add_1_after: 5,
soil_add_2_form: "돌쌓기(메)",
soil_add_2_height: 2.5,
soil_add_3_height: 1.2, // 지형이 못 세운 단 — 그림에 안 감
});
out.filled = { updates: updates.splice(0), tiers: tiers.splice(0), overrides: overrides.splice(0) };
out.toasts = toasts.splice(0);
// 같은 값을 다시 보내면 다시 그리지 않음
applyPipeOptionsToCache(ctx, 100, {
soil_add_1_form: "콘크리트",
soil_add_1_height: 1.8,
soil_add_2_form: "돌쌓기(메)",
soil_add_2_height: 2.0,
});
out.same = { updates: updates.splice(0), tiers: tiers.splice(0) };
// 비운 칸 = 자동(높이 · 형태) · 구간값은 그대로
applyPipeOptionsToCache(ctx, 100, {});
out.cleared = { updates: updates.splice(0), tiers: tiers.splice(0) };
// 독립 기슭막이(관 숨김)는 다단을 안 만짐
section.culvert.hidden_pipe = true;
applyPipeOptionsToCache(ctx, 100, { soil_add_1_height: 1.0 });
out.hidden = updates.splice(0);
// ── ② 개수보다 많은 컨테이너 값은 가리기만 · 값은 남김 ───────────────────────
const { createSpecForm } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"),
);
const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts"));
let count = 1;
const form = createSpecForm(null, { count: (c) => (c.id === "soil_add" ? count : undefined) });
form.write(PIPE_SPEC, {
pipe_diameter_mm: 1000,
soil_add_1_form: "콘크리트",
soil_add_2_form: "돌망태",
soil_add_2_height: 1.5,
});
const boxes = () =>
form.root.children.filter((k) => k.dataset?.specContainer === "soil_add").length;
const soilKeys = () =>
Object.keys(form.read())
.filter((k) => k.startsWith("soil_add_"))
.sort();
out.form = { shown1: boxes(), keys1: soilKeys() };
count = 0; // 단 수 0 저장 — 옛 흙막이 값도 남음
form.recount();
out.form.shown0 = boxes();
out.form.keys0 = soilKeys();
count = 3;
form.recount();
out.form.shown3 = boxes();
out.form.keys3 = soilKeys();
process.stdout.write(JSON.stringify(out));
+92
View File
@@ -0,0 +1,92 @@
"""다단 흙막이 ↔ 흙막이 컨테이너 (PLAN 54-10).
짝: `B06_Section/B06_Section_UI_Page_Pipe_Options.ts`(컨테이너 값 → 다단 조정값 · 단별 구간값) ·
`A00_Common/design/design_structure_form.ts`(개수보다 많은 컨테이너는 가리기만 · 값은 남김) ·
`B06_Section/B06_Section_UI_Adjust_Dock.ts`(다단 벽 고르기 → 그 단의 흙막이 컨테이너).
실제 TS 를 `helper_54_10_soil_tiers.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(__file__)
ROOT = Path(HERE).resolve().parents[1]
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_54_10_soil_tiers.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def extras(updates: list[dict]) -> list[dict]:
return [u for u in updates if u["role"].startswith("extra")]
def test_컨테이너_형태_높이가_그_단_조정값으로(out) -> None:
assert extras(out["filled"]["updates"]) == [
{"role": "extra0", "h": 1.8, "m": "콘크리트"},
{"role": "extra1", "h": 2.0, "m": "돌쌓기(메)"},
]
def test_지형이_못_세운_단은_그림에_안_감(out) -> None:
roles = {u["role"] for u in out["filled"]["updates"]}
assert "extra2" not in roles
def test_높이_한도는_형태_규칙_그대로_폼에_되돌림(out) -> None:
assert out["filled"]["overrides"] == [{"soil_add_2_height": 2}]
assert out["toasts"] == ["흙막이 2 높이는 형태 한계로 2.0m까지만 적용됩니다."]
def test_길이_전_후는_단별_구간값(out) -> None:
assert out["filled"]["tiers"] == [{"key": "extra0", "lengthM": 8, "beforeM": 3, "afterM": 5}]
def test_같은_값은_다시_그리지_않음(out) -> None:
assert extras(out["same"]["updates"]) == []
assert out["same"]["tiers"] == []
def test_비운_칸은_자동_구간값은_그대로(out) -> None:
assert extras(out["cleared"]["updates"]) == [
{"role": "extra0", "h": None, "m": None},
{"role": "extra1", "h": None, "m": None},
]
assert out["cleared"]["tiers"] == []
def test_독립_기슭막이는_다단을_안_만짐(out) -> None:
assert extras(out["hidden"]) == []
def test_개수보다_많은_컨테이너는_가리고_값은_남김(out) -> None:
form = out["form"]
kept = ["soil_add_1_form", "soil_add_2_form", "soil_add_2_height"]
assert (form["shown1"], form["keys1"]) == (1, kept)
assert (form["shown0"], form["keys0"]) == (0, kept) # 단 수 0 저장에도 남음
assert form["shown3"] == 3 and form["keys3"][:3] == kept
def test_다단_벽_고르기는_흙막이_컨테이너로() -> None:
dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert 'fieldset[data-spec-container="soil_add"]' in dock
assert "panelRoot.dataset.extraIndex" in dock
panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text(
encoding="utf-8"
)
assert "root.dataset.extraIndex" in panel
@@ -0,0 +1,107 @@
/* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너
* 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18)
* 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)"
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이)
* wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look
* set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / -
* 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안)
*/
(async () => {
const ARGS = window.__m5410 || { 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 visible = (n) => n.offsetParent !== null && !n.closest("[hidden]");
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 card = () =>
[...document.querySelectorAll(".b06-cross-card")].find((c) =>
c.textContent.startsWith(station()),
);
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 extraShapes = () =>
[...(card()?.querySelectorAll(".b06-chart__culvert-revet") ?? [])]
.map((s) => s.querySelector("title")?.textContent ?? "")
.filter((t) => t.startsWith("추가"))
.map((t) => /^(.*? H=[\d.]+m)/.exec(t)?.[1] ?? t.slice(0, 40));
const look = () => {
const boxes = soils();
const rect = (b) => b.getBoundingClientRect();
return {
station: station(),
soilCount: boxes.length,
active: boxes
.map((b, i) => (b.classList.contains("is-active") ? i : -1))
.filter((i) => i >= 0),
activeInView: boxes
.filter((b) => b.classList.contains("is-active"))
.map((b) => rect(b).top >= 0 && rect(b).bottom <= innerHeight),
values: boxes.map((b) =>
Object.fromEntries(
[...b.querySelectorAll("[data-spec-item]")].map((n) => [
n.dataset.specItem.split(".")[1],
(n.querySelector("select, input, output") ?? {}).value ?? "",
]),
),
),
extraGroupShown: [...document.querySelectorAll("legend")].filter(
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
).length,
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
walls: extraShapes(),
};
};
await pickPipe();
const out = { mode: ARGS.mode, pipe: PIPE };
if (ARGS.mode === "wall") {
const hits = [...(card()?.querySelectorAll(".b06-chart__culvert-revet-hit") ?? [])].filter(
(h) => h.querySelector("title")?.textContent.startsWith("추가"),
);
out.hits = hits.length;
hits[ARGS.n]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(1500);
}
if (ARGS.mode === "set") {
const field = soils()[ARGS.n]?.querySelector(`[data-spec-item="soil_add.${ARGS.item}"]`);
const el = field?.querySelector("select, input");
out.found = !!el;
if (el) {
el.value = ARGS.value;
el.dispatchEvent(new Event("change", { bubbles: true }));
}
await w(1500);
}
if (ARGS.mode === "tier+" || ARGS.mode === "tier-") {
const btns = [...(tierRow()?.querySelectorAll("button") ?? [])];
btns.find((b) => b.textContent === (ARGS.mode === "tier+" ? "+" : "-"))?.click();
await w(1500);
}
Object.assign(out, look());
return JSON.stringify(out);
})();