fix(387-4b): 관 옵션 → 횡단 조정값을 상세 가게가 얹음 — B05 만 연 면적 = B06 연 뒤
- 관 벽 높이 · 형태(조정 h · m) · 흙막이 n 단(h · m · 구간)을 layPipeOptions 가 얹음(관 초안 ?? 저장 관) - 상세 가게 읽기 · 다시 셈 창구(관 옵션을 고친 뒤 묶음 cross)에서 부름 — 예전에는 B06 보기가 열릴 때만 - 새 test_387_4b_pipe_options(상세 가게 얹기 = B06 폼 되보내기 · 면적 같음 · 옵션 바꿔도) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -56,6 +56,7 @@ import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile
|
||||
import { useStationKeys } from "./B06_Section_Station_Keys";
|
||||
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import { withPipeOptions } from "./B06_Section_Section_Adjusts";
|
||||
|
||||
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
|
||||
export interface CrossRefreshEdits {
|
||||
@@ -179,6 +180,7 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
|
||||
const sections = input.detail.cross_sections;
|
||||
if (sections.length && sections.every((section) => section.preview)) return [];
|
||||
useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3)
|
||||
await withPipeOptions(input.detail, input.projectId); // 관 옵션을 고친 뒤도 면적이 따라옴(387-4b)
|
||||
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
|
||||
if (!refreshLocally(input)) await refreshFromServer(input);
|
||||
// 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
* 조정창(`_UI_Page_Station_Controls` · `_Ford_Controls` · `_Span_Control`)이 고칠 때 캐시에 얹는 것과 같은
|
||||
* 칸 · 같은 꼴. 예전에는 고칠 때만 얹어 새로고침 · B05 만 연 페이지는 세션 값을 몰랐음 — 카드 그리기가 면적을
|
||||
* 고쳐 들어 B06 을 연 뒤에만 면적이 바뀜(B05 만 연 면적 ≠ B06). 조정창은 세션 → 설계 순으로 읽으니 겹쳐도 같음.
|
||||
*
|
||||
* 관 옵션(폼 칸 · 관 초안 ?? 저장 관)의 조정값 몫도 여기서(387-4b · `layPipeOptions`) — 관 벽 높이 · 형태(h · m) ·
|
||||
* 흙막이 n 단(h · m · 구간). 예전에는 B06 보기가 열릴 때 폼이 되보내야만 얹혀 B05 만 연 면적이 달랐음.
|
||||
* 형상(관 세트 · 흙막이 세트 · 도수로 · 세월교 · BOX 제원)은 형상 얹기(`attachShapes`)가 이미 함.
|
||||
* 같은 규칙 = `B06_Section_UI_Page_Pipe_Options`(폼을 고친 그 자리 즉시 반영 몫).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
@@ -13,6 +18,10 @@ import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culver
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./logic/B06_Section_UI_Cross_Culvert_Const";
|
||||
import { tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { computeStoredLayouts } from "./B06_Section_Structure_Layouts";
|
||||
|
||||
type Cells = Record<string, unknown>;
|
||||
type Obj = Record<string, unknown>;
|
||||
@@ -81,3 +90,114 @@ export function withDraftAdjusts(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 관 옵션 조정값 몫 얹기(387-4b) — 관 초안 ?? 저장 관(페이지 자료 줄). 가게 · 자료 줄은 부를 때 늦게 불러옴 —
|
||||
* 이 파일을 끄는 시험 · 묶음이 그 줄기(fetch · 가게)를 안 끌게. 못 받으면 그대로. */
|
||||
export async function withPipeOptions(
|
||||
detail: SectionDetailResponse,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
const pipes = await import("../B05_Profile/B05_Profile_Api_Pipes_Draft")
|
||||
.then(
|
||||
async ({ readPendingPipes }) =>
|
||||
readPendingPipes(projectId) ??
|
||||
(await import("../A00_Common/design/design_page_data"))
|
||||
.pagePipes(projectId)
|
||||
.then((res) => res.pipe_points),
|
||||
)
|
||||
.catch(() => null);
|
||||
if (pipes) layPipeOptions(detail, pipes as OptionPipe[]);
|
||||
}
|
||||
|
||||
/** 관 하나 — 자리 · 폼 칸(관 초안 · 저장 관 `pipe_points` 같은 꼴). */
|
||||
export interface OptionPipe {
|
||||
chainage_m: number;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const isDefault = (a: WallAdjust): boolean =>
|
||||
Math.abs(a.x) < 1e-9 && a.d == null && a.h == null && a.m == null;
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
|
||||
/** 조정창 `update` 와 같은 꼴 — x · d 는 그대로 · h · m 만(폼 한 칸이 주인 · 빈 칸 = 자동 null). */
|
||||
function setWall(design: CrossDesign, role: string, h: number | null, m: string | null): void {
|
||||
const current = { ...ZERO_ADJUST, ...(design.revet_adjust?.[role] ?? {}) } as WallAdjust;
|
||||
const next: WallAdjust = { ...current, h: h === null ? null : round1(h), m };
|
||||
const bucket = { ...(design.revet_adjust ?? {}) };
|
||||
if (isDefault(next)) delete bucket[role];
|
||||
else bucket[role] = next;
|
||||
design.revet_adjust = bucket;
|
||||
}
|
||||
|
||||
/** 관 옵션의 조정값 몫을 상세에 얹음(폼 칸 전부 = 되보내기와 같은 규칙) — 같은 값이면 그대로. */
|
||||
export function layPipeOptions(
|
||||
detail: SectionDetailResponse,
|
||||
pipes: ReadonlyArray<OptionPipe>,
|
||||
): void {
|
||||
const sections = detail.cross_sections;
|
||||
for (const pipe of pipes) {
|
||||
const options = pipe.options;
|
||||
if (!options) continue;
|
||||
const near = (at: number): boolean => Math.abs(at - pipe.chainage_m) < PIPE_MATCH_M;
|
||||
// 물넘이포장 = 제원만(66-2) · 벽 없음
|
||||
if (sections.some((s) => s.ford_pavement && near(s.ford_pavement.chainage_m ?? s.chainage_m)))
|
||||
continue;
|
||||
const owner = sections.find((s) => near(s.chainage_m));
|
||||
const culvert = owner?.culvert;
|
||||
const design = owner?.design;
|
||||
if (!owner || !culvert || !design) continue;
|
||||
const num = (key: string): number | undefined => {
|
||||
const value = Number(options[key]);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
for (const role of ["inlet", "outlet"] as const) {
|
||||
const form = options[`${role}_revet_form`];
|
||||
setWall(
|
||||
design,
|
||||
role,
|
||||
num(`${role}_revet_height_m`) ?? null,
|
||||
typeof form === "string" ? form : null,
|
||||
);
|
||||
}
|
||||
if (culvert.hidden_pipe && !owner.ford && !owner.box) continue;
|
||||
// 흙막이 n 단 = 지형이 세운 유출 다단(`pipeExtraCount` 와 같은 셈)
|
||||
const outlet = design.extra_wall_counts?.outlet ?? 0;
|
||||
const probe = {
|
||||
...owner,
|
||||
design: { ...design, extra_wall_counts: { basin: 0, ...design.extra_wall_counts, outlet } },
|
||||
};
|
||||
const tiers = outlet
|
||||
? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet)
|
||||
: 0;
|
||||
for (let n = 1; n <= tiers; n++) {
|
||||
const role = `extra${n - 1}`;
|
||||
const value = (item: string): number | undefined => {
|
||||
const raw = options[`soil_add_${n}_${item}`];
|
||||
return raw === undefined || raw === "" || !Number.isFinite(Number(raw))
|
||||
? undefined
|
||||
: Number(raw);
|
||||
};
|
||||
const form = options[`soil_add_${n}_form`];
|
||||
const height = value("height");
|
||||
setWall(
|
||||
design,
|
||||
role,
|
||||
height === undefined ? null : Number(Math.max(height, WALL_MIN_PURE_HEIGHT_M).toFixed(1)),
|
||||
typeof form === "string" && form ? form : null,
|
||||
);
|
||||
const span = { lengthM: value("length"), beforeM: value("before"), afterM: value("after") };
|
||||
if (Object.values(span).every((v) => v === undefined)) continue;
|
||||
const at = tierSpanOf(owner, role);
|
||||
const current = {
|
||||
lengthM: Math.round((at.beforeM + at.afterM) * 10) / 10,
|
||||
beforeM: Math.round(at.beforeM * 10) / 10,
|
||||
afterM: Math.round(at.afterM * 10) / 10,
|
||||
};
|
||||
const next = applySpanPatch(current, span);
|
||||
design.extra_spans = {
|
||||
...(design.extra_spans ?? {}),
|
||||
[role]: { length_m: next.lengthM, before_m: next.beforeM, after_m: next.afterM },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
withDraftStations,
|
||||
} from "./B06_Section_Section_Drafts";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import { withDraftAdjusts } from "./B06_Section_Section_Adjusts";
|
||||
import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts";
|
||||
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { forgetCanon, keepCanon, sessionDetail } from "./B06_Section_Section_Canon";
|
||||
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
|
||||
@@ -73,6 +73,7 @@ async function withDraftWalls(
|
||||
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
|
||||
await withDraftShapes(detail, projectId);
|
||||
withDraftAdjusts(detail, projectId, routeId); // 저장 안 한 조정창 4축 · 구간 · 형식(387-4 · B05 만 열어도)
|
||||
await withPipeOptions(detail, projectId); // 관 옵션 벽 높이 · 형태 · 흙막이 단(387-4b)
|
||||
return withStructureAreas(detail);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* 387-4b 헬퍼 — 관 옵션 → 횡단 조정값: 상세 가게 얹기(`layPipeOptions` · B05 만 연 페이지) = B06 폼 되보내기
|
||||
* (`applyPipeOptionsToCache` · 조정창 흉내 = 캐시에 같은 칸으로 씀)인지 · 옵션을 바꿔도 같은지 · 면적으로 견줌.
|
||||
* 짝 = `test_387_4b_pipe_options.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } };
|
||||
|
||||
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,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||||
for (const [prefix, dir] of Object.entries(alias))
|
||||
if (request.startsWith(prefix))
|
||||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
) ?? `${base}.ts`;
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
setItem: (k, v) => map.set(k, String(v)),
|
||||
removeItem: (k) => map.delete(k),
|
||||
key: (i) => [...map.keys()][i] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() };
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
||||
const { structureAreaRows, computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
|
||||
const { layPipeOptions } = loadTs(B06("B06_Section_Section_Adjusts"));
|
||||
const { applyPipeOptionsToCache } = loadTs(B06("B06_Section_UI_Page_Pipe_Options"));
|
||||
const { ZERO_ADJUST } = loadTs(B06("B06_Section_UI_Cross_Culvert_Types"));
|
||||
|
||||
const fresh = () =>
|
||||
JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "b06_186_1_pipe_292.json"), "utf8"))
|
||||
.cross_sections;
|
||||
const areaOf = (sections) => {
|
||||
const s = sections.find((x) => x.culvert);
|
||||
const row = structureAreaRows(sections).find((r) => r.chainage_m === s.chainage_m);
|
||||
return [row?.cut_area_m2 ?? null, row?.fill_area_m2 ?? null, s.design.revet_adjust ?? null];
|
||||
};
|
||||
|
||||
/** B06 길 — 조정창 흉내(세션 없음 · 캐시 칸에 같은 꼴로 씀 · `storeAdjust` 규칙). */
|
||||
function b06(sections, options, extra = 0) {
|
||||
const owner = sections.find((x) => x.culvert);
|
||||
owner.design.extra_wall_counts = { outlet: extra, basin: 0 };
|
||||
const adjustFor = (section, role) => ({
|
||||
...ZERO_ADJUST,
|
||||
...(section.design.revet_adjust?.[role] ?? {}),
|
||||
});
|
||||
const round1 = (v) => Math.round(v * 10) / 10;
|
||||
const ctx = {
|
||||
detail: () => ({ cross_sections: sections }),
|
||||
ford: {},
|
||||
box: {},
|
||||
revetOffset: {
|
||||
adjustFor,
|
||||
update: (at, role, patch) => {
|
||||
const section = sections.find((x) => Math.abs(x.chainage_m - at) < 0.01);
|
||||
const cur = adjustFor(section, role);
|
||||
const next = {
|
||||
...cur,
|
||||
h: patch.h === undefined ? cur.h : patch.h === null ? null : round1(patch.h),
|
||||
m: patch.m === undefined ? cur.m : patch.m,
|
||||
};
|
||||
const bucket = { ...(section.design.revet_adjust ?? {}) };
|
||||
const dflt = Math.abs(next.x) < 1e-9 && next.d == null && next.h == null && next.m == null;
|
||||
if (dflt) delete bucket[role];
|
||||
else bucket[role] = next;
|
||||
section.design.revet_adjust = bucket;
|
||||
},
|
||||
},
|
||||
soilTiers: () => {
|
||||
const probe = { ...owner, design: { ...owner.design } };
|
||||
return extra
|
||||
? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? extra)
|
||||
: 0;
|
||||
},
|
||||
overrideOptions: () => undefined,
|
||||
refreshCard: () => undefined,
|
||||
};
|
||||
applyPipeOptionsToCache(ctx, owner.chainage_m, options, true);
|
||||
return areaOf(sections);
|
||||
}
|
||||
|
||||
/** 상세 가게 길(B05 만 연 페이지) — 관 초안 · 저장 관의 폼 칸 그대로. */
|
||||
function store(sections, options, extra = 0) {
|
||||
const owner = sections.find((x) => x.culvert);
|
||||
owner.design.extra_wall_counts = { outlet: extra, basin: 0 };
|
||||
layPipeOptions({ cross_sections: sections }, [{ chainage_m: owner.chainage_m, options }]);
|
||||
return areaOf(sections);
|
||||
}
|
||||
|
||||
const at = fresh().find((x) => x.culvert).chainage_m;
|
||||
const first = {
|
||||
inlet_revet_height_m: 1.5,
|
||||
inlet_revet_form: "돌쌓기(찰)",
|
||||
outlet_revet_height_m: 1.5,
|
||||
outlet_revet_form: "돌쌓기(찰)",
|
||||
};
|
||||
const changed = { ...first, outlet_revet_height_m: 2.4, outlet_revet_form: "옹벽" };
|
||||
const out = { at, none: areaOf(fresh()) };
|
||||
for (const [name, options] of Object.entries({ first, changed })) {
|
||||
out[name] = { b06: b06(fresh(), options), store: store(fresh(), options) };
|
||||
}
|
||||
// 흙막이 단(soil_add) — 유출 다단 두 단 · 높이 · 형태
|
||||
const tiers = {
|
||||
...first,
|
||||
soil_add_1_height: 1.2,
|
||||
soil_add_1_form: "돌쌓기(찰)",
|
||||
soil_add_2_height: 0.05,
|
||||
};
|
||||
out.tiers = { b06: b06(fresh(), tiers, 2), store: store(fresh(), tiers, 2) };
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,49 @@
|
||||
"""387-4b 관 옵션 → 횡단 조정값을 상세 가게가 얹음 — B05 만 연 면적 = B06 연 뒤 면적(관 옵션을 바꿔도).
|
||||
|
||||
· 관 벽 높이 · 형태(조정 h · m) · 흙막이 n 단(h · m)을 상세 가게(`layPipeOptions`)가 얹은 값 = B06 폼 되보내기
|
||||
(`applyPipeOptionsToCache`)가 얹은 값 · 구조물 면적도 같음
|
||||
· 부르는 곳 = 상세 가게 읽기(새로고침 · 처음 열기) + 다시 셈 창구(관 옵션을 고친 뒤 묶음 cross)
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_387_4b_pipe_options.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).parent / "helper_387_4b_pipe_options.cjs"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["first", "changed", "tiers"])
|
||||
def test_상세_가게_얹기_B06_되보내기와_같음(out: dict, case: str) -> None:
|
||||
b06, store = out[case]["b06"], out[case]["store"]
|
||||
assert store[2] == b06[2] # 조정값 칸 그대로
|
||||
assert store[0] == pytest.approx(b06[0], abs=0.01) and store[1] == pytest.approx(
|
||||
b06[1], abs=0.01
|
||||
)
|
||||
|
||||
|
||||
def test_옵션을_바꾸면_면적이_따라옴(out: dict) -> None:
|
||||
assert out["first"]["store"][0] != out["none"][0]
|
||||
assert out["changed"]["store"][0] != out["first"]["store"][0]
|
||||
|
||||
|
||||
def test_부르는_곳() -> None:
|
||||
store = (ROOT / "B06_Section/B06_Section_Section_Store.ts").read_text(encoding="utf-8")
|
||||
refresh = (ROOT / "B06_Section/B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
assert "await withPipeOptions(detail, projectId);" in store
|
||||
assert "await withPipeOptions(input.detail, input.projectId);" in refresh
|
||||
Reference in New Issue
Block a user