fix(b06): 관 종류 바꿈 = 그 측점 옛 조정값(세션 아홉 칸 · 미리 뜬 횡단 사본) 버림 · [저장] 때 structure_reset 으로 정본 옛 조정값 비움 · 같은 행 여러 줄 이어 얹음(CASE 첫 WHEN 만 쓰던 것) (318-4 · 요구 216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
+22
-13
@@ -137,6 +137,8 @@ export const STATE_REGISTRY = {
|
||||
selection: { bucket: "draft", scope: "project" },
|
||||
/** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */
|
||||
"structure-type-cache": { bucket: "draft", scope: "project" },
|
||||
/** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */
|
||||
kindreset: { bucket: "draft", scope: "project" },
|
||||
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
||||
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
||||
/** 배수관 이동(측점 옮김) 예약. */
|
||||
@@ -485,29 +487,36 @@ export function clearState(
|
||||
routeId?: number | string | null,
|
||||
): void {
|
||||
const entry = entryOf(name);
|
||||
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다.
|
||||
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다(`routeStateKeys`).
|
||||
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
|
||||
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
|
||||
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
|
||||
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
|
||||
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
|
||||
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
||||
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
|
||||
try {
|
||||
const doomed: string[] = [];
|
||||
for (let i = 0; i < window.sessionStorage.length; i += 1) {
|
||||
const key = window.sessionStorage.key(i);
|
||||
if (key && key.startsWith(prefix)) doomed.push(key);
|
||||
}
|
||||
doomed.forEach((key) => writeRaw(key, null));
|
||||
} catch {
|
||||
/* 무시 — 저장소가 막힌 브라우저 */
|
||||
}
|
||||
routeStateKeys(name, projectId).forEach((key) => writeRaw(key, null));
|
||||
return;
|
||||
}
|
||||
writeStateRaw(name, null, projectId, routeId);
|
||||
}
|
||||
|
||||
/** 노선 범위 이름의 그 프로젝트 키 전부(모든 노선 · 세션에 있는 것만). */
|
||||
export function routeStateKeys(name: StateName, projectId: string): string[] {
|
||||
const entry = entryOf(name);
|
||||
if (entry?.scope !== "route") return [];
|
||||
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
||||
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
|
||||
const keys: string[] = [];
|
||||
try {
|
||||
for (let i = 0; i < window.sessionStorage.length; i += 1) {
|
||||
const key = window.sessionStorage.key(i);
|
||||
if (key && key.startsWith(prefix)) keys.push(key);
|
||||
}
|
||||
} catch {
|
||||
/* 무시 — 저장소가 막힌 브라우저 */
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** 등록표에서 한 통에 속한 이름만 고른다. */
|
||||
export function namesInBucket(bucket: StateBucket): StateName[] {
|
||||
return (Object.keys(STATE_REGISTRY) as StateName[]).filter(
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type StandardCrossSection,
|
||||
} from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
|
||||
import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import {
|
||||
createCutSlopeStore,
|
||||
createRockBoundaryStore,
|
||||
@@ -304,6 +305,7 @@ export async function mountCrossView(
|
||||
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
|
||||
},
|
||||
});
|
||||
setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4)
|
||||
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
|
||||
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
|
||||
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
|
||||
|
||||
@@ -15,6 +15,7 @@ import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "../A00_Common/design/design_selection";
|
||||
import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item";
|
||||
import { showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { forgetStationAdjusts } from "../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
|
||||
/** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */
|
||||
/** 배선에 필요한 값 — 저장 쪽(`StructuresCommitContext`)과 겹치는 것은 그대로 물려받는다. */
|
||||
@@ -112,6 +113,9 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
|
||||
ctx.groupBoxes.setShapes(back?.options ?? {});
|
||||
ctx.syncFacilityForm(back?.options, pipeItem?.design_flow_m3s ?? null); // 유량 = 월류 높이(65-4)
|
||||
ctx.liveCommit();
|
||||
// 관 지점 종류 바꿈 — 그 측점 옛 조정값(세션)은 버리고 [저장] 때 정본에서도 비움(318-4)
|
||||
if (pipeItem)
|
||||
forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id, next.type_id);
|
||||
// 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림
|
||||
if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
* 조작·데이터 흐름 정책, 2026-08-29 사용자 확정). 종전 800ms 디바운스 자동 PUT은
|
||||
* 폐지했다 — 저장을 누르지 않았는데 영구저장소가 바뀌면 [초기화]의 기준선이 흐려진다.
|
||||
* 세션에 담는 이유는 B06에서 만진 값을 B05 [저장]으로도 내보내야 하기 때문이다.
|
||||
*
|
||||
* 관 지점 종류를 바꾸면(318-4) 그 측점에 쌓인 옛 종류 조정값은 뜻을 잃음 — 세션 몫은 바꾸는
|
||||
* 자리에서 버리고(`forgetStationAdjusts`) 정본 몫은 [저장] 때 `structure_reset` 으로 비움.
|
||||
* 열쇠 목록 `STRUCTURE_ADJUST_KEYS` = 서버 `B06_Section_Router_Design` 짝(거울 시험 test_318_kind_reset).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -17,6 +21,16 @@ import {
|
||||
saveDetailPipePoints,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
readByKey,
|
||||
readState,
|
||||
routeStateKeys,
|
||||
writeByKey,
|
||||
writeState,
|
||||
type StateName,
|
||||
} from "../A00_Common/b_page_state";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { forgetPreviewAdjusts } from "./B06_Section_Section_Drafts";
|
||||
|
||||
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
|
||||
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
|
||||
@@ -37,21 +51,17 @@ const keyOf = (chainageM: number): string => chainageM.toFixed(2);
|
||||
function readPending(sessionKey: string | null): PendingMap {
|
||||
if (!sessionKey) return {};
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(sessionKey);
|
||||
const raw = readByKey(sessionKey);
|
||||
return raw ? (JSON.parse(raw) as PendingMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 세션 저장 실패는 `writeByKey` 가 삼킴 — 값은 화면 캐시에 남아 있다. */
|
||||
function writePending(sessionKey: string | null, pending: PendingMap): void {
|
||||
if (!sessionKey) return;
|
||||
try {
|
||||
if (Object.keys(pending).length === 0) window.sessionStorage.removeItem(sessionKey);
|
||||
else window.sessionStorage.setItem(sessionKey, JSON.stringify(pending));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 화면 캐시에 남아 있다. */
|
||||
}
|
||||
if (sessionKey)
|
||||
writeByKey(sessionKey, Object.keys(pending).length ? JSON.stringify(pending) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +91,91 @@ export async function flushCulvertOptions(
|
||||
writePending(sessionKey, {});
|
||||
}
|
||||
|
||||
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */
|
||||
export const STRUCTURE_ADJUST_KEYS = [
|
||||
"inlet_structure",
|
||||
"basin_adjust",
|
||||
"revet_adjust",
|
||||
"ford_adjust",
|
||||
"box_adjust",
|
||||
"extra_wall_counts",
|
||||
"extra_spans",
|
||||
"revet_link_detached",
|
||||
"revet_follow_grade",
|
||||
] as const;
|
||||
|
||||
/** 측점 조정값 세션 — 열쇠가 누가거리(`b` · `:역할` 붙은 것 포함)로 시작함. */
|
||||
const STATION_ADJUST_STATES: readonly StateName[] = [
|
||||
"culvertopt",
|
||||
"revetx",
|
||||
"basinadjust",
|
||||
"inletstruct",
|
||||
"extrawall",
|
||||
"extraspan",
|
||||
"revetlink",
|
||||
"fordadjust",
|
||||
"boxadjust",
|
||||
];
|
||||
|
||||
let reloadStationAdjusts: (() => void) | null = null;
|
||||
|
||||
/** 횡단 보기가 메모리 사본을 세션에서 다시 읽는 창구(한 페이지 하나 · 나중 것이 씀). */
|
||||
export function setStationAdjustReload(reload: () => void): void {
|
||||
reloadStationAdjusts = reload;
|
||||
}
|
||||
|
||||
const atChainage = (key: string, chainageM: number): boolean => {
|
||||
const head = /^b?(-?\d+(?:\.\d+)?)/.exec(key);
|
||||
return head !== null && Math.abs(Number(head[1]) - chainageM) < PIPE_MATCH_M;
|
||||
};
|
||||
|
||||
function dropAt(map: unknown, chainageM: number): void {
|
||||
if (!map || typeof map !== "object") return;
|
||||
const entries = map as Record<string, unknown>;
|
||||
for (const key of Object.keys(entries)) if (atChainage(key, chainageM)) delete entries[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관 지점 종류를 바꿈(318-4) — 그 측점 옛 조정값(세션 · 미리 뜬 횡단 사본)을 버리고, [저장] 때
|
||||
* 정본에서도 비울 자리로 적음. 처음 종류로 되돌리면 그 자리는 지움(저장분 그대로).
|
||||
*/
|
||||
export function forgetStationAdjusts(
|
||||
projectId: string | null,
|
||||
chainageM: number,
|
||||
was: string,
|
||||
next: string,
|
||||
): void {
|
||||
if (!projectId || was === next) return;
|
||||
for (const name of STATION_ADJUST_STATES)
|
||||
for (const key of routeStateKeys(name, projectId)) {
|
||||
const raw = readByKey(key);
|
||||
if (raw === null) continue;
|
||||
try {
|
||||
const stored = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (name === "revetlink") {
|
||||
dropAt(stored.detached, chainageM);
|
||||
dropAt(stored.grade, chainageM);
|
||||
} else dropAt(stored, chainageM);
|
||||
writeByKey(key, JSON.stringify(stored));
|
||||
} catch {
|
||||
/* 손상된 세션 값은 그대로 — 읽는 쪽이 버림 */
|
||||
}
|
||||
}
|
||||
const marks = readState<Record<string, string>>("kindreset", projectId) ?? {};
|
||||
const hit = Object.keys(marks).find((key) => atChainage(key, chainageM));
|
||||
const first = hit === undefined ? was : marks[hit];
|
||||
if (hit !== undefined) delete marks[hit];
|
||||
if (next !== first) marks[chainageM.toFixed(2)] = first;
|
||||
writeState("kindreset", Object.keys(marks).length ? marks : null, projectId);
|
||||
forgetPreviewAdjusts(projectId, chainageM, STRUCTURE_ADJUST_KEYS);
|
||||
reloadStationAdjusts?.();
|
||||
}
|
||||
|
||||
/** [저장] 때 옛 조정값을 정본에서 비울 측점 — 종류를 바꾼 관 자리(누가거리). */
|
||||
export function kindChangedChainages(projectId: string | null): number[] {
|
||||
return Object.keys(readState<Record<string, string>>("kindreset", projectId) ?? {}).map(Number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장기 하나를 만든다. `projectId`·`sessionKey`가 없으면 예약만 메모리에 남는다
|
||||
* (조회 전용 상태). `onError`는 [저장] 시점 실패를 화면에 알리는 쪽이 구현한다.
|
||||
|
||||
@@ -645,6 +645,8 @@ export interface CrossSectionPatch {
|
||||
structure_note_cad?: CrossNoteCad;
|
||||
/** [상세] 표 손질(179-4) — 세션 초안 그대로. */
|
||||
detail_table?: CrossDetailTable;
|
||||
/** 관 종류를 바꾼 측점(318-4) — 서버가 옛 종류 조정값(`STRUCTURE_ADJUST_KEYS`)을 먼저 비움. */
|
||||
structure_reset?: boolean;
|
||||
}
|
||||
|
||||
/** [상세] 표 칸 출처(179-4) — area = 설계 면적 칸(표 키) · length = 두 끝 [offset, 표고](m) · text = 글. */
|
||||
|
||||
@@ -37,7 +37,7 @@ async def merge_cross_section_designs(
|
||||
"""측점 여러 곳의 `data.design` 을 한 번에 쓴다. 실제로 바뀐 행 수를 돌려준다.
|
||||
|
||||
`replace=True` 면 design 을 통째로 갈아 끼우고(`update_cross_section_design` 과 같은 뜻),
|
||||
`False` 면 키만 얹는다(`merge_cross_section_design_patch` 와 같은 뜻).
|
||||
`False` 면 키만 얹는다(`merge_cross_section_design_patch` 와 같은 뜻 · 값 None = 그 키 지움).
|
||||
|
||||
행이 없는 측점은 `project_id` 가 오면 새로 만든다 — 구조물(비정규) 측점은 B05 확정이
|
||||
파일만 쓰고 DB 행을 안 만들기 때문이다(한 행짜리 함수와 같은 규칙).
|
||||
@@ -66,14 +66,20 @@ async def merge_cross_section_designs(
|
||||
best = row_id
|
||||
return best
|
||||
|
||||
updates: list[tuple[int, str]] = []
|
||||
# 행마다 한 벌 — 같은 행에 여러 줄이 오면 앞 줄 결과 위에 얹음(`CASE id` 는 첫 WHEN 만 씀 ·
|
||||
# 318-4 옛 조정값 비움 뒤 새 값)
|
||||
updates: dict[int, str] = {}
|
||||
inserts: list[tuple[str, int, float, str]] = []
|
||||
for chainage_m, payload in entries:
|
||||
if not payload and not replace:
|
||||
continue
|
||||
# 얹기의 None = 그 열쇠 지움(JSON merge-patch · 318-4 관 종류 바꿈 옛 조정값)
|
||||
drop = [] if replace else [key for key, value in payload.items() if value is None]
|
||||
if drop:
|
||||
payload = {key: value for key, value in payload.items() if value is not None}
|
||||
row_id = find(chainage_m)
|
||||
if row_id is None:
|
||||
if project_id is None:
|
||||
if project_id is None or not (payload or replace):
|
||||
continue
|
||||
inserts.append(
|
||||
(
|
||||
@@ -91,8 +97,11 @@ async def merge_cross_section_designs(
|
||||
design = data.get("design")
|
||||
design = dict(design) if isinstance(design, dict) else {}
|
||||
design.update(payload)
|
||||
for key in drop:
|
||||
design.pop(key, None)
|
||||
data["design"] = design
|
||||
updates.append((row_id, json.dumps(data, ensure_ascii=False)))
|
||||
existing[row_id] = data
|
||||
updates[row_id] = json.dumps(data, ensure_ascii=False)
|
||||
|
||||
written = 0
|
||||
async with connection.cursor() as cursor:
|
||||
@@ -100,9 +109,9 @@ async def merge_cross_section_designs(
|
||||
# 한 문장 — `CASE id WHEN … THEN …` 이라 왕복이 한 번이다.
|
||||
cases = " ".join("WHEN %s THEN %s" for _ in updates)
|
||||
params: list[Any] = []
|
||||
for row_id, blob in updates:
|
||||
for row_id, blob in updates.items():
|
||||
params.extend((row_id, blob))
|
||||
params.extend(row_id for row_id, _ in updates)
|
||||
params.extend(updates)
|
||||
placeholders = ", ".join("%s" for _ in updates)
|
||||
await cursor.execute(
|
||||
f"UPDATE cross_sections SET data = CASE id {cases} END "
|
||||
|
||||
@@ -38,6 +38,7 @@ from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
|
||||
# 원본은 `B06_Section_Router_Design` 이다 — `B06_Section_Router` 를 거쳐 들여오던 것을
|
||||
# 곧바로 잇는다(2026-09-06). 그 재수출이 없어지면서 서버가 뜨지 못했다.
|
||||
from B06_Section.B06_Section_Router_Design import STRUCTURE_ADJUST_KEYS
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
compute_default_designs as _compute_default_designs,
|
||||
)
|
||||
@@ -135,12 +136,17 @@ async def _apply_section_edits(
|
||||
# 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합.
|
||||
if request and request.cross_patches:
|
||||
patches: list[tuple[float, dict[str, Any]]] = []
|
||||
# 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값을 **먼저** 지움(None = 지움)
|
||||
# · 뒤 patch 가 새 값을 얹음
|
||||
resets: list[tuple[float, dict[str, Any]]] = []
|
||||
for patch_item in request.cross_patches:
|
||||
# 필드를 손으로 나열하지 않는다(2026-09-07) — 나열이 네 곳에 흩어져 있어 새 값을
|
||||
# 더할 때 한 곳만 빠지면 그 값이 **조용히 사라졌다**(`extra_spans` 실사고 `b6941bd2`).
|
||||
# 스키마가 곧 목록이므로 통째로 덤프하고 **최상위 None 만** 걷는다.
|
||||
# (중첩 None 은 남긴다 — 기슭막이 4축의 `d: null` 은 「자동」이라는 뜻이다.)
|
||||
dumped = patch_item.model_dump()
|
||||
if dumped.pop("structure_reset", None):
|
||||
resets.append((patch_item.chainage_m, dict.fromkeys(STRUCTURE_ADJUST_KEYS)))
|
||||
patch: dict[str, Any] = {
|
||||
key: value
|
||||
for key, value in dumped.items()
|
||||
@@ -150,7 +156,7 @@ async def _apply_section_edits(
|
||||
patches.append((patch_item.chainage_m, patch))
|
||||
# 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다.
|
||||
await merge_cross_section_designs(
|
||||
connection, route_id=route_id, entries=patches, replace=False
|
||||
connection, route_id=route_id, entries=resets + patches, replace=False
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -177,6 +177,21 @@ USER_TOUCHED_KEYS = (
|
||||
"berm_depth_right_m",
|
||||
)
|
||||
|
||||
# 구조물(관 · 박스 · 세월교) 조정값 — 측점의 관 종류를 바꾸면 버리는 몫(318-4 · `structure_reset`).
|
||||
# 브라우저 짝 = `B06_Section_Api_Culvert_Options.ts` 의 `STRUCTURE_ADJUST_KEYS`
|
||||
# (거울 시험 test_318_kind_reset).
|
||||
STRUCTURE_ADJUST_KEYS = (
|
||||
"inlet_structure",
|
||||
"basin_adjust",
|
||||
"revet_adjust",
|
||||
"ford_adjust",
|
||||
"box_adjust",
|
||||
"extra_wall_counts",
|
||||
"extra_spans",
|
||||
"revet_link_detached",
|
||||
"revet_follow_grade",
|
||||
)
|
||||
|
||||
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
|
||||
# design_line · road_edges 같은 결과 칸과 단면 유형 · 측구 방향(새 노선 지형이 정함) · 구조물
|
||||
# 조작값은 넘기지 않는다. 받는 쪽은 `compute_default_designs(carried=…)`.
|
||||
|
||||
@@ -259,6 +259,9 @@ class CrossSectionPatch(BaseModel):
|
||||
structure_note_cad: CrossNoteCad | None = None
|
||||
# [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈
|
||||
detail_table: CrossDetailTable | None = None
|
||||
# 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값(`STRUCTURE_ADJUST_KEYS`)을 먼저 비움
|
||||
# · design 에 안 씀
|
||||
structure_reset: bool | None = None
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -58,6 +58,24 @@ export function forgetDraftStations(projectId: string): void {
|
||||
for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key);
|
||||
}
|
||||
|
||||
/** 종류 바꿈(318-4) — 그 자리 미리 뜬 횡단 설계에 얹혔던 조정값 사본을 걷음(조작이 캐시에 쓴 것). */
|
||||
export function forgetPreviewAdjusts(
|
||||
projectId: string,
|
||||
chainageM: number,
|
||||
keys: readonly string[],
|
||||
): void {
|
||||
for (const [key, section] of previews) {
|
||||
if (
|
||||
!key.startsWith(`${projectId}:`) ||
|
||||
!section?.design ||
|
||||
!near(section.chainage_m, chainageM)
|
||||
)
|
||||
continue;
|
||||
const design = section.design as unknown as Record<string, unknown>;
|
||||
for (const name of keys) delete design[name];
|
||||
}
|
||||
}
|
||||
|
||||
function savedOf(projectId: string): Promise<[Pipes, StructureInstance[]]> {
|
||||
let hit = saved.get(projectId);
|
||||
if (!hit) {
|
||||
|
||||
@@ -50,6 +50,7 @@ import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structu
|
||||
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
|
||||
import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
|
||||
import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options";
|
||||
|
||||
const cache = new Map<string, SectionDetailResponse>();
|
||||
const pending = new Map<string, Promise<SectionDetailResponse>>();
|
||||
@@ -268,12 +269,20 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo
|
||||
*
|
||||
* 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)만 싣는다.
|
||||
*/
|
||||
export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSectionPatch[] {
|
||||
export function crossPatchesFromCache(
|
||||
detail: SectionDetailResponse,
|
||||
kindChanged: readonly number[] = [],
|
||||
): CrossSectionPatch[] {
|
||||
const patches: CrossSectionPatch[] = [];
|
||||
// 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음
|
||||
const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS;
|
||||
const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M);
|
||||
for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true });
|
||||
for (const section of detail.cross_sections) {
|
||||
const design = section.design;
|
||||
if (!design) continue;
|
||||
const patch: CrossSectionPatch = { chainage_m: section.chainage_m };
|
||||
const skip = reset(section.chainage_m);
|
||||
let touched = false;
|
||||
// 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고
|
||||
// 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이
|
||||
@@ -282,7 +291,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
|
||||
const target = patch as unknown as Record<string, unknown>;
|
||||
for (const key of USER_TOUCHED_KEYS) {
|
||||
const value = source[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue;
|
||||
target[key] = value;
|
||||
touched = true;
|
||||
}
|
||||
@@ -298,8 +307,9 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
|
||||
export async function saveCachedCrossPatches(projectId: string, routeId: number): Promise<number> {
|
||||
const detail = cache.get(keyOf(projectId, routeId));
|
||||
if (!detail) return 0;
|
||||
const patches = crossPatchesFromCache(detail);
|
||||
const patches = crossPatchesFromCache(detail, kindChangedChainages(projectId));
|
||||
if (!patches.length) return 0;
|
||||
await saveSections(projectId, routeId, undefined, patches);
|
||||
clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4)
|
||||
return patches.length;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@ import {
|
||||
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
|
||||
import type { DesignViewSave } from "../A00_Common/design/design_shared";
|
||||
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||
import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
clearState,
|
||||
readByKey,
|
||||
readState,
|
||||
writeByKey,
|
||||
writeState,
|
||||
} from "../A00_Common/b_page_state";
|
||||
import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options";
|
||||
import {
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
@@ -436,6 +443,8 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
if (sides.left !== undefined) patch.berm_depth_left_m = sides.left;
|
||||
if (sides.right !== undefined) patch.berm_depth_right_m = sides.right;
|
||||
});
|
||||
// 관 종류를 바꾼 측점(318-4) — 서버가 옛 종류 조정값을 먼저 비우고 이 patch 값만 얹음
|
||||
for (const at of kindChangedChainages(ctx.projectId)) patchFor(at).structure_reset = true;
|
||||
if (detail) {
|
||||
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
|
||||
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
|
||||
@@ -487,6 +496,7 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave {
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4)
|
||||
},
|
||||
done: () => {
|
||||
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
/* PLAN 318 단위검증 헬퍼 — B06 횡단 카드: 단추 줄 빈자리 고름 · 중심고 Y축 상자 · 빈 아래 띠 ·
|
||||
* 관 종류 바꿈 = 옛 측점 조정값 버림(세션 · 미리 뜬 횡단 · [저장] patch).
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 무거운 화면 모듈은 흉내) · 결과 = 표준출력 JSON 한 벌.
|
||||
* 짝 = `test_318_cross_card.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 B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
|
||||
|
||||
// ── 화면 그릇 흉내 — 쓰는 만큼만 ─────────────────────────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = String(tag).toLowerCase();
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.listeners = {};
|
||||
this.parent = null;
|
||||
this.dataset = {};
|
||||
this.style = { setProperty: () => {} };
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
||||
toggle: (name, force) => {
|
||||
const on = force === undefined ? !self._classes.has(name) : !!force;
|
||||
on ? self._classes.add(name) : self._classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
contains: (name) => self._classes.has(name),
|
||||
};
|
||||
}
|
||||
set className(value) {
|
||||
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(value) {
|
||||
this._text = String(value);
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
get childElementCount() {
|
||||
return this.children.length;
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((kid) => {
|
||||
if (typeof kid === "string") {
|
||||
const text = new FakeNode("#text");
|
||||
text._text = kid;
|
||||
kid = text;
|
||||
}
|
||||
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
|
||||
kid.parent = this;
|
||||
this.children.push(kid);
|
||||
});
|
||||
}
|
||||
appendChild(kid) {
|
||||
this.append(kid);
|
||||
return kid;
|
||||
}
|
||||
prepend(...kids) {
|
||||
const rest = this.children;
|
||||
this.children = [];
|
||||
this.append(...kids, ...rest);
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children.forEach((k) => (k.parent = null));
|
||||
this.children = [];
|
||||
this.append(...kids);
|
||||
}
|
||||
remove() {
|
||||
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
|
||||
this.parent = null;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attrs[name] = String(value);
|
||||
if (name === "class") this.className = value;
|
||||
}
|
||||
getAttribute(name) {
|
||||
return name === "class" ? this.className : (this.attrs[name] ?? null);
|
||||
}
|
||||
removeAttribute(name) {
|
||||
delete this.attrs[name];
|
||||
}
|
||||
hasAttribute(name) {
|
||||
return name in this.attrs;
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
(this.listeners[type] ??= []).push(fn);
|
||||
}
|
||||
removeEventListener() {}
|
||||
matches(selector) {
|
||||
return selector
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.some((part) =>
|
||||
part.startsWith(".") ? this._classes.has(part.slice(1)) : this.tagName === part,
|
||||
);
|
||||
}
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parent) if (node.matches(selector)) return node;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const out = [];
|
||||
const walk = (node) =>
|
||||
node.children.forEach((kid) => {
|
||||
if (kid.matches(selector)) out.push(kid);
|
||||
walk(kid);
|
||||
});
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
/** 붙기 전 = 0 — 상자 폭은 어림으로 감. */
|
||||
getComputedTextLength() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 누름 — 눌린 자리에서 위로 올라가며 듣개를 부름(stopPropagation 이면 멈춤). */
|
||||
function click(target) {
|
||||
const event = {
|
||||
type: "click",
|
||||
target,
|
||||
stopped: false,
|
||||
stopPropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
preventDefault() {},
|
||||
};
|
||||
for (let node = target; node && !event.stopped; node = node.parent)
|
||||
(node.listeners.click ?? []).forEach((fn) => fn(event));
|
||||
return event;
|
||||
}
|
||||
|
||||
class FakeStorage {
|
||||
constructor() {
|
||||
this.map = new Map();
|
||||
}
|
||||
get length() {
|
||||
return this.map.size;
|
||||
}
|
||||
key(index) {
|
||||
return [...this.map.keys()][index] ?? null;
|
||||
}
|
||||
getItem(key) {
|
||||
return this.map.has(key) ? this.map.get(key) : null;
|
||||
}
|
||||
setItem(key, value) {
|
||||
this.map.set(key, String(value));
|
||||
}
|
||||
removeItem(key) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.window = {
|
||||
sessionStorage: new FakeStorage(),
|
||||
localStorage: new FakeStorage(),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
globalThis.sessionStorage = window.sessionStorage;
|
||||
globalThis.localStorage = window.localStorage;
|
||||
globalThis.requestAnimationFrame = () => 0;
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
globalThis.document = {
|
||||
createElement: (tag) => new FakeNode(tag),
|
||||
createElementNS: (_ns, tag) => new FakeNode(tag),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
documentElement: new FakeNode("html"),
|
||||
};
|
||||
|
||||
const anything = () =>
|
||||
new Proxy({}, { get: (_t, key) => (key === "__esModule" ? false : () => undefined) });
|
||||
|
||||
/**
|
||||
* 로더 하나 — `real` 에 든 파일만 진짜로 읽고, 그 밖 상대 경로는 흉내(무거운 화면 사슬을 안 끌어옴).
|
||||
* `stubs` 는 지정자 그대로 갈음.
|
||||
*/
|
||||
function makeLoader(real, stubs = {}) {
|
||||
const cache = new Map();
|
||||
const realSet = new Set(real.map((file) => path.resolve(file)));
|
||||
const load = (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 need = (spec) => {
|
||||
if (spec in stubs) return stubs[spec];
|
||||
let target = null;
|
||||
if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`);
|
||||
else if (spec.startsWith(".")) target = path.join(path.dirname(full), `${spec}.ts`);
|
||||
if (target && realSet.has(path.resolve(target))) return load(target);
|
||||
return anything();
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
};
|
||||
return load;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
|
||||
// ── 1. 중심고 Y축 상자(318-2) ──────────────────────────────────────────────
|
||||
{
|
||||
const load = makeLoader(
|
||||
[
|
||||
B06("B06_Section_UI_Cross_Axes"),
|
||||
B06("B06_Section_UI_Section_Common"),
|
||||
path.join(ROOT, "common_util", "common_util_svg.ts"),
|
||||
],
|
||||
{
|
||||
"@config/config_frontend": { PIPE_TYPES: [] },
|
||||
"../B05_Profile/B05_Profile_UI_IrregularStations": { PIPE_DISPLAY_NAME: {} },
|
||||
},
|
||||
);
|
||||
const { createCrossAxes, IDENTITY_VIEW } = load(B06("B06_Section_UI_Cross_Axes"));
|
||||
const { CROSS_PAD } = load(B06("B06_Section_UI_Section_Common"));
|
||||
const base = {
|
||||
widthPx: 480,
|
||||
heightPx: 300,
|
||||
pixelsPerMeter: 10,
|
||||
maxOffset: 20,
|
||||
displayMax: 890,
|
||||
elevationMid: 880,
|
||||
exaggeration: 1,
|
||||
};
|
||||
const draw = (centerZ) => {
|
||||
const layer = new FakeNode("g");
|
||||
createCrossAxes(layer, { ...base, centerZ })(IDENTITY_VIEW);
|
||||
return layer;
|
||||
};
|
||||
const layer = draw(876.57);
|
||||
const box = layer.querySelector(".b06-chart__center-box");
|
||||
const tag = layer.querySelector(".b06-chart__center-text");
|
||||
const centerY = CROSS_PAD.top + (base.displayMax - 876.57) * base.pixelsPerMeter;
|
||||
const ticks = layer
|
||||
.querySelectorAll("text")
|
||||
.filter(
|
||||
(t) => !t.classList.contains("b06-chart__center-text") && t.attrs["text-anchor"] === "end",
|
||||
);
|
||||
out.center = {
|
||||
axis_x: CROSS_PAD.left,
|
||||
box: box && {
|
||||
x: Number(box.attrs.x),
|
||||
width: Number(box.attrs.width),
|
||||
y: Number(box.attrs.y),
|
||||
height: Number(box.attrs.height),
|
||||
rx: Number(box.attrs.rx),
|
||||
},
|
||||
center_y: centerY,
|
||||
text: tag?.textContent ?? null,
|
||||
text_classes: tag?.className ?? null,
|
||||
text_x: tag ? Number(tag.attrs.x) : null,
|
||||
tick_x: ticks.length ? Number(ticks[0].attrs.x) : null,
|
||||
tick_near_center: ticks.filter((t) => Math.abs(Number(t.attrs.y) - 3 - centerY) < 14).length,
|
||||
tick_count: ticks.length,
|
||||
hidden_when_out_of_view: draw(800).querySelector(".b06-chart__center-box") === null,
|
||||
absent_without_center: draw(null).querySelector(".b06-chart__center-box") === null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 2. 아래 띠(318-2 · 318-3) — 중심고 글 없음 · 암 경계 맨 왼쪽 · 토사도 띠 남음 ─────
|
||||
{
|
||||
const control = (cls) => () => {
|
||||
const node = new FakeNode("div");
|
||||
node.className = cls;
|
||||
return node;
|
||||
};
|
||||
const load = makeLoader([B06("B06_Section_UI_Cross_Card_Chrome")], {
|
||||
"./B06_Section_UI_Cross_CutSlope": { buildCutSlopeControl: control("cutslope") },
|
||||
"./B06_Section_UI_Cross_Design": {
|
||||
buildRockBoundaryControl: control("rockb"),
|
||||
buildDesignControls: () => ({ bar: new FakeNode("div"), groundSegment: new FakeNode("div") }),
|
||||
sectionModeLabel: () => "",
|
||||
},
|
||||
"./B06_Section_UI_Cross_Fit": { fillSlopeLengths: () => [] },
|
||||
"./B06_Section_UI_Section_Common": {
|
||||
L: (key) => (key === "B06_Profile_View_CenterElevation" ? "중심고" : key),
|
||||
stationLabel: () => "",
|
||||
structureDisplayName: () => "",
|
||||
},
|
||||
});
|
||||
const { appendCardFooter } = load(B06("B06_Section_UI_Cross_Card_Chrome"));
|
||||
const footerOf = (preset) => {
|
||||
const card = new FakeNode("article");
|
||||
appendCardFooter(card, { center_z: 876.57, design: { geometry_preset: preset } }, {}, {});
|
||||
return card.querySelector("footer");
|
||||
};
|
||||
const rock = footerOf("rock");
|
||||
const soil = footerOf("soil");
|
||||
out.footer = {
|
||||
rock_first: rock?.children[0]?.className ?? null,
|
||||
rock_classes: rock?.children.map((kid) => kid.className) ?? null,
|
||||
rock_text: rock?.textContent ?? null,
|
||||
soil_exists: soil !== null,
|
||||
soil_children: soil?.childElementCount ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ───────────
|
||||
{
|
||||
const load = makeLoader([B06("B06_Section_UI_Cross_Design")], {
|
||||
"@ui/ui_template_locale": {
|
||||
currentLanguageIndex: 0,
|
||||
ui_locales: new Proxy({}, { get: (_t, key) => [String(key)] }),
|
||||
},
|
||||
});
|
||||
const { buildDesignControls } = load(B06("B06_Section_UI_Cross_Design"));
|
||||
const { bar } = buildDesignControls(
|
||||
{
|
||||
chainage_m: 100,
|
||||
uphill_side: "left",
|
||||
design: { ground_type: "soil", section_mode: "left_cut" },
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
const card = new FakeNode("article");
|
||||
let picked = 0;
|
||||
card.addEventListener("click", () => (picked += 1));
|
||||
card.append(bar);
|
||||
const pickedBy = (node) => {
|
||||
const before = picked;
|
||||
click(node);
|
||||
return picked - before;
|
||||
};
|
||||
const button = bar.querySelector("button");
|
||||
const label =
|
||||
bar.querySelectorAll("div").find((node) => !node.closest("button, details")) ?? null;
|
||||
const summary = bar.querySelector("summary");
|
||||
out.bar = {
|
||||
empty: pickedBy(bar),
|
||||
button: button ? pickedBy(button) : null,
|
||||
legend: label ? pickedBy(label) : null,
|
||||
summary: summary ? pickedBy(summary) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 4. 종류 바꿈 = 그 측점 세션 조정값 버림 · 비울 자리 적음(318-4) ─────────────
|
||||
{
|
||||
const previewCalls = [];
|
||||
const load = makeLoader(
|
||||
[
|
||||
B06("B06_Section_Api_Culvert_Options"),
|
||||
path.join(ROOT, "A00_Common", "b_page_state.ts"),
|
||||
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
|
||||
],
|
||||
{
|
||||
"./B06_Section_Section_Drafts": {
|
||||
forgetPreviewAdjusts: (...args) => previewCalls.push([args[0], args[1], [...args[2]]]),
|
||||
},
|
||||
},
|
||||
);
|
||||
const mod = load(B06("B06_Section_Api_Culvert_Options"));
|
||||
const store = window.sessionStorage;
|
||||
const put = (key, value) => store.setItem(key, JSON.stringify(value));
|
||||
const get = (key) => JSON.parse(store.getItem(key) ?? "null");
|
||||
put("aislo:draft:culvertopt:P:7", { "100.00": { inlet_revet_length_m: 3 }, "140.00": { a: 1 } });
|
||||
put("aislo:draft:culvertopt:P:8", { "100.00": { a: 1 } });
|
||||
put("aislo:draft:culvertopt:Q:7", { "100.00": { a: 1 } });
|
||||
put("aislo:draft:revetx:P:7", {
|
||||
"100.00:inlet": { x: 1 },
|
||||
"100.00:extra0": { x: 2 },
|
||||
"140.00:inlet": { x: 3 },
|
||||
});
|
||||
put("aislo:draft:extrawall:P:7", { "100.00": 2, "b100.00": 1, "140.00": 1 });
|
||||
put("aislo:draft:extraspan:P:7", { "100.00:extra0": { length_m: 1 } });
|
||||
put("aislo:draft:revetlink:P:7", {
|
||||
detached: { "100.00": true, "140.00": true },
|
||||
grade: { "100.00": true },
|
||||
});
|
||||
put("aislo:draft:fordadjust:P:7", { "100.00": { w: 1 } });
|
||||
put("aislo:draft:boxadjust:P:7", { "100.30": { w: 1 } });
|
||||
put("aislo:draft:basinadjust:P:7", { "100.00": { d: 1 } });
|
||||
put("aislo:draft:inletstruct:P:7", { "100.00": "basin" });
|
||||
put("aislo:draft:crossw:P:7", { "100.00": 5 });
|
||||
let reloads = 0;
|
||||
mod.setStationAdjustReload(() => (reloads += 1));
|
||||
mod.forgetStationAdjusts("P", 100, "pipe", "box_culvert");
|
||||
out.forget = {
|
||||
culvertopt: get("aislo:draft:culvertopt:P:7"),
|
||||
culvertopt_other_route: get("aislo:draft:culvertopt:P:8"),
|
||||
culvertopt_other_project: get("aislo:draft:culvertopt:Q:7"),
|
||||
revetx: get("aislo:draft:revetx:P:7"),
|
||||
extrawall: get("aislo:draft:extrawall:P:7"),
|
||||
extraspan: get("aislo:draft:extraspan:P:7"),
|
||||
revetlink: get("aislo:draft:revetlink:P:7"),
|
||||
fordadjust: get("aislo:draft:fordadjust:P:7"),
|
||||
boxadjust: get("aislo:draft:boxadjust:P:7"),
|
||||
basinadjust: get("aislo:draft:basinadjust:P:7"),
|
||||
inletstruct: get("aislo:draft:inletstruct:P:7"),
|
||||
crossw: get("aislo:draft:crossw:P:7"),
|
||||
kindreset: get("aislo:draft:kindreset:P"),
|
||||
changed: mod.kindChangedChainages("P"),
|
||||
preview_calls: previewCalls,
|
||||
reloads,
|
||||
};
|
||||
mod.forgetStationAdjusts("P", 100, "box_culvert", "ford_bridge");
|
||||
out.forget.second = get("aislo:draft:kindreset:P");
|
||||
mod.forgetStationAdjusts("P", 100, "ford_bridge", "pipe");
|
||||
out.forget.reverted = get("aislo:draft:kindreset:P");
|
||||
const before = store.length;
|
||||
mod.forgetStationAdjusts("P", 140, "pipe", "pipe");
|
||||
out.forget.same_kind_untouched =
|
||||
store.length === before && get("aislo:draft:culvertopt:P:7")["140.00"] !== undefined;
|
||||
out.forget.keys = [...mod.STRUCTURE_ADJUST_KEYS];
|
||||
}
|
||||
|
||||
// ── 5. 미리 뜬 횡단 사본 걷음(318-4) — 그 자리 조정값만 · 다른 칸 그대로 ─────────
|
||||
const state = { draftPipes: null, savedPipes: [] };
|
||||
const loadDrafts = makeLoader(
|
||||
[
|
||||
B06("B06_Section_Section_Drafts"),
|
||||
path.join(ROOT, "common_util", "common_util_structure_station.ts"),
|
||||
path.join(ROOT, "common_util", "common_util_culvert_sets.ts"),
|
||||
],
|
||||
{
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
|
||||
fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }),
|
||||
},
|
||||
"../B05_Profile/B05_Profile_Api_Structures": {
|
||||
fetchStructures: async () => ({ structures: [] }),
|
||||
fetchStructureTypes: async () => [],
|
||||
readPendingStructures: () => null,
|
||||
},
|
||||
"../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes },
|
||||
"./B06_Section_Api_Fetch": {
|
||||
previewStructureSections: async (_p, chainages) =>
|
||||
chainages.map((at) => ({
|
||||
chainage_m: at,
|
||||
kind: "irregular",
|
||||
design: { ground_type: "soil", ford_adjust: { w: 1 }, revet_adjust: { inlet: { x: 1 } } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ── 6. B05 [저장](캐시 patch) — 종류 바꾼 측점 = structure_reset · 옛 조정값 안 실음 ─────
|
||||
const loadStore = makeLoader(
|
||||
[
|
||||
B06("B06_Section_Section_Store"),
|
||||
B06("B06_Section_Api_Culvert_Options"),
|
||||
path.join(ROOT, "A00_Common", "b_page_state.ts"),
|
||||
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
|
||||
],
|
||||
{
|
||||
"./B06_Section_Cross_Refresh": {
|
||||
USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
(async () => {
|
||||
const { withDraftStations, forgetPreviewAdjusts } = loadDrafts(B06("B06_Section_Section_Drafts"));
|
||||
const detail = { cross_sections: [{ chainage_m: 0, kind: "regular", design: {} }] };
|
||||
state.draftPipes = [{ chainage_m: 50, facility: "box_culvert" }];
|
||||
await withDraftStations(detail, "P", 7);
|
||||
const at50 = () => detail.cross_sections.find((s) => Math.abs(s.chainage_m - 50) < 0.01);
|
||||
const had = Object.keys(at50()?.design ?? {}).sort();
|
||||
forgetPreviewAdjusts("P", 50, ["ford_adjust", "revet_adjust"]);
|
||||
forgetPreviewAdjusts("OTHER", 0, ["ground_type"]);
|
||||
out.preview = { had, after: Object.keys(at50()?.design ?? {}).sort() };
|
||||
|
||||
const { crossPatchesFromCache } = loadStore(B06("B06_Section_Section_Store"));
|
||||
out.cache_patches = crossPatchesFromCache(
|
||||
{
|
||||
cross_sections: [
|
||||
{ chainage_m: 100, design: { ford_adjust: { w: 1 }, display_half_width_m: 6 } },
|
||||
{ chainage_m: 140, design: { ford_adjust: { w: 2 } } },
|
||||
],
|
||||
},
|
||||
[100],
|
||||
);
|
||||
out.cache_patches_plain = crossPatchesFromCache({
|
||||
cross_sections: [{ chainage_m: 140, design: { ford_adjust: { w: 2 } } }],
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
"""PLAN 318 — B06 횡단 카드(요구 215 · 216).
|
||||
|
||||
- 318-1 단추 줄: 빈자리 누름 = 카드 고름 · 단추 · ⋯ 펼침 누름 = 카드로 안 넘김
|
||||
- 318-2 중심고: 아래 띠에서 빼고 Y축 라벨 둥근 상자(눈금과 같은 글자 · 소수 첫째 자리 ·
|
||||
축선 안 침범) · 암 경계 = 띠 왼쪽
|
||||
- 318-3 아래 띠 아래 여유: 카드 = 내용 높이(같은 줄 큰 카드에 안 늘어남) · 빈 띠도 높이 그대로
|
||||
- 318-4 관 종류 바꿈: 그 측점 옛 조정값(세션 · 미리 뜬 횡단 사본)을 버리고
|
||||
[저장] 때 정본에서도 비움
|
||||
|
||||
화면 TS 는 `helper_318_cross_card.cjs` 가 Node 로 돌린다(무거운 화면 사슬 · 서버 부름은 흉내).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from B06_Section import B06_Section_Router_Confirm as confirm
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from B06_Section.B06_Section_Router_Design import STRUCTURE_ADJUST_KEYS, USER_TOUCHED_KEYS
|
||||
from B06_Section.B06_Section_Schema import SectionConfirmRequest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
NODE_READY = os.path.isdir(os.path.join(ROOT, "config", "node_modules"))
|
||||
|
||||
|
||||
def _src(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def result() -> dict:
|
||||
if not NODE_READY:
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
out = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_318_cross_card.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=ROOT,
|
||||
timeout=120,
|
||||
)
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
# ── 318-1 단추 줄 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bar_empty_area_picks_card(result: dict) -> None:
|
||||
bar = result["bar"]
|
||||
assert bar["empty"] == 1 # 단추 줄 빈자리
|
||||
assert bar["legend"] == 1 # 단추 묶음 바탕(단추 아닌 자리)
|
||||
|
||||
|
||||
def test_bar_controls_do_not_pick_card(result: dict) -> None:
|
||||
assert result["bar"]["button"] == 0
|
||||
assert result["bar"]["summary"] == 0 # ⋯ 펼침
|
||||
|
||||
|
||||
# ── 318-2 중심고 Y축 상자 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_center_tag_on_y_axis(result: dict) -> None:
|
||||
center = result["center"]
|
||||
box = center["box"]
|
||||
assert center["text"] == "876.6" # 소수 첫째 자리
|
||||
assert "b06-chart__tick" in center["text_classes"] # 다른 Y 라벨과 같은 글자 크기
|
||||
assert box["rx"] > 0 # 둥근 상자
|
||||
assert box["x"] + box["width"] < center["axis_x"] # Y 축선 안 침범
|
||||
assert box["x"] > 0
|
||||
assert abs(box["y"] + box["height"] / 2 - center["center_y"]) < 0.01 # 중심고 높이에
|
||||
assert center["text_x"] == center["tick_x"] # 글 오른끝 = 다른 눈금 자리
|
||||
assert center["tick_near_center"] == 0 # 겹치는 눈금 글은 비켜 줌
|
||||
assert center["tick_count"] > 3
|
||||
assert center["hidden_when_out_of_view"]
|
||||
assert center["absent_without_center"]
|
||||
|
||||
|
||||
def test_footer_without_center_text(result: dict) -> None:
|
||||
footer = result["footer"]
|
||||
assert "중심고" not in footer["rock_text"]
|
||||
assert "876" not in footer["rock_text"]
|
||||
assert footer["rock_first"].split()[-1] == "b06-cross-card__rockb" # 암 경계 = 왼쪽 첫 칸
|
||||
assert footer["rock_classes"][-1].split()[-1] == "b06-cross-card__cutslope"
|
||||
assert footer["soil_exists"] and footer["soil_children"] == 0 # 토사도 띠는 남음(빈 띠)
|
||||
|
||||
|
||||
def test_center_wired_into_card_and_modal() -> None:
|
||||
assert "centerZ: section.center_z ?? null" in _src(
|
||||
"B06_Section", "B06_Section_UI_Cross_View.ts"
|
||||
)
|
||||
assert "centerZ: section.center_z ?? null" in _src(
|
||||
"B06_Section", "B06_Section_UI_Section_View.ts"
|
||||
)
|
||||
|
||||
|
||||
# ── 318-2 · 318-3 양식 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _rule(css: str, selector: str) -> str:
|
||||
match = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||||
assert match, selector
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_css_layout() -> None:
|
||||
css = _src("B06_Section", "B06_Section_UI_Style_Cross.css")
|
||||
assert "align-items: start" in _rule(css, ".b06-section__grid") # 카드 = 내용 높이
|
||||
rock = _rule(css, ".b06-cross-card__rockb")
|
||||
assert "position: absolute" not in rock and "translateX" not in rock # 왼쪽 맞춤
|
||||
assert 'content: "\\200b"' in _rule(css, ".b06-cross-card > footer:empty::before")
|
||||
box = _rule(css, ".b06-chart__center-box")
|
||||
assert "var(--color-accent)" in box # 테마 색
|
||||
assert "font-size" not in _rule(css, ".b06-chart__center-text") # 글자 크기 = 눈금 그대로
|
||||
|
||||
|
||||
# ── 318-4 관 종류 바꿈 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_forget_session_adjusts_at_station(result: dict) -> None:
|
||||
forget = result["forget"]
|
||||
assert forget["culvertopt"] == {"140.00": {"a": 1}}
|
||||
assert forget["culvertopt_other_route"] == {} # 노선 모두
|
||||
assert forget["culvertopt_other_project"] == {"100.00": {"a": 1}} # 다른 프로젝트 그대로
|
||||
assert forget["revetx"] == {"140.00:inlet": {"x": 3}}
|
||||
assert forget["extrawall"] == {"140.00": 1} # `b` 머리(집수정 단)까지
|
||||
assert forget["extraspan"] == {}
|
||||
assert forget["revetlink"] == {"detached": {"140.00": True}, "grade": {}}
|
||||
assert forget["fordadjust"] == {} and forget["boxadjust"] == {} # 100.30 = 같은 관(0.51 안)
|
||||
assert forget["basinadjust"] == {} and forget["inletstruct"] == {}
|
||||
assert forget["crossw"] == {"100.00": 5} # 구조물 아닌 값(표시 반폭)은 그대로
|
||||
assert forget["reloads"] == 1 # 횡단 보기 메모리 사본 다시 읽음
|
||||
assert forget["preview_calls"][0] == ["P", 100, list(STRUCTURE_ADJUST_KEYS)]
|
||||
|
||||
|
||||
def test_kind_reset_marks(result: dict) -> None:
|
||||
forget = result["forget"]
|
||||
assert forget["kindreset"] == {"100.00": "pipe"}
|
||||
assert forget["changed"] == [100]
|
||||
assert forget["second"] == {"100.00": "pipe"} # 처음 종류를 기억
|
||||
assert forget["reverted"] is None # 처음 종류로 되돌림 = 저장분 그대로
|
||||
assert forget["same_kind_untouched"]
|
||||
|
||||
|
||||
def test_preview_copy_stripped(result: dict) -> None:
|
||||
assert result["preview"]["had"] == ["ford_adjust", "ground_type", "revet_adjust"]
|
||||
assert result["preview"]["after"] == ["ground_type"]
|
||||
|
||||
|
||||
def test_b05_save_path_patches(result: dict) -> None:
|
||||
assert result["cache_patches"] == [
|
||||
{"chainage_m": 100, "structure_reset": True},
|
||||
{"chainage_m": 100, "display_half_width_m": 6}, # 옛 ford_adjust 안 실음
|
||||
{"chainage_m": 140, "ford_adjust": {"w": 2}},
|
||||
]
|
||||
assert result["cache_patches_plain"] == [{"chainage_m": 140, "ford_adjust": {"w": 2}}]
|
||||
|
||||
|
||||
def test_wiring_sources() -> None:
|
||||
events = _src("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts")
|
||||
live = events.index("ctx.liveCommit();\n // 관 지점 종류 바꿈")
|
||||
assert "forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id" in events[live:]
|
||||
persist = _src("B06_Section", "B06_Section_UI_Page_Persist.ts")
|
||||
assert "patchFor(at).structure_reset = true" in persist
|
||||
assert 'clearState("kindreset", ctx.projectId)' in persist
|
||||
store = _src("B06_Section", "B06_Section_Section_Store.ts")
|
||||
assert "crossPatchesFromCache(detail, kindChangedChainages(projectId))" in store
|
||||
view = _src("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "setStationAdjustReload(() => stationControls.load())" in view
|
||||
|
||||
|
||||
def test_structure_keys_mirror() -> None:
|
||||
"""서버 · 브라우저 열쇠 목록이 같음 · 모두 승계 목록 안."""
|
||||
ts = _src("B06_Section", "B06_Section_Api_Culvert_Options.ts")
|
||||
block = ts[ts.index("export const STRUCTURE_ADJUST_KEYS = [") :]
|
||||
block = block[: block.index("] as const")]
|
||||
assert re.findall(r'"([a-z_]+)"', block) == list(STRUCTURE_ADJUST_KEYS)
|
||||
assert set(STRUCTURE_ADJUST_KEYS) <= set(USER_TOUCHED_KEYS)
|
||||
assert "structure_note" not in STRUCTURE_ADJUST_KEYS # 사용자 글 · 그림은 안 버림
|
||||
|
||||
|
||||
# ── 318-4 서버 — 비움이 먼저 · 같은 행은 이어 얹음 · None = 지움 ───────────────
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.calls = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, params=None):
|
||||
self.calls.append((" ".join(sql.split()), list(params) if params else []))
|
||||
|
||||
async def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, rows):
|
||||
self.cursor_obj = _Cursor(rows)
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_obj
|
||||
|
||||
|
||||
def _written(connection: _Connection) -> dict:
|
||||
for sql, params in connection.cursor_obj.calls:
|
||||
if sql.startswith("UPDATE cross_sections SET data = CASE"):
|
||||
count = len(params) // 3
|
||||
return {params[i * 2]: json.loads(params[i * 2 + 1]) for i in range(count)}
|
||||
return {}
|
||||
|
||||
|
||||
def test_merge_none_deletes_and_same_row_accumulates() -> None:
|
||||
stored = {
|
||||
"design": {
|
||||
"ground_type": "soil",
|
||||
"ford_adjust": {"w": 1},
|
||||
"revet_adjust": {"inlet": {"x": 1}},
|
||||
"display_half_width_m": 6,
|
||||
}
|
||||
}
|
||||
connection = _Connection([(7, 100.0, json.dumps(stored))])
|
||||
asyncio.run(
|
||||
merge_cross_section_designs(
|
||||
connection,
|
||||
route_id=1,
|
||||
entries=[
|
||||
(100.0, dict.fromkeys(STRUCTURE_ADJUST_KEYS)),
|
||||
(100.0, {"box_adjust": {"w": 2}}),
|
||||
],
|
||||
replace=False,
|
||||
)
|
||||
)
|
||||
written = _written(connection)
|
||||
assert list(written) == [7] # 한 행 한 벌
|
||||
assert written[7]["design"] == {
|
||||
"ground_type": "soil",
|
||||
"display_half_width_m": 6,
|
||||
"box_adjust": {"w": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_merge_none_only_entry_for_missing_row_inserts_nothing() -> None:
|
||||
connection = _Connection([])
|
||||
asyncio.run(
|
||||
merge_cross_section_designs(
|
||||
connection,
|
||||
route_id=1,
|
||||
entries=[(50.0, dict.fromkeys(STRUCTURE_ADJUST_KEYS))],
|
||||
replace=False,
|
||||
project_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
)
|
||||
assert not any("INSERT" in sql for sql, _ in connection.cursor_obj.calls)
|
||||
|
||||
|
||||
def test_apply_edits_resets_before_patches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[list, bool]] = []
|
||||
|
||||
async def fake_merge(connection, *, route_id, entries, replace, project_id=None):
|
||||
calls.append((list(entries), replace))
|
||||
return len(entries)
|
||||
|
||||
monkeypatch.setattr(confirm, "merge_cross_section_designs", fake_merge)
|
||||
request = SectionConfirmRequest(
|
||||
cross_patches=[
|
||||
{"chainage_m": 100, "structure_reset": True, "box_adjust": {"left": {"lengthM": 1}}},
|
||||
{"chainage_m": 140, "display_half_width_m": 6},
|
||||
]
|
||||
)
|
||||
asyncio.run(confirm._apply_section_edits(None, 1, request, []))
|
||||
entries = [entries for entries, replace in calls if not replace][-1]
|
||||
assert entries[0] == (100, dict.fromkeys(STRUCTURE_ADJUST_KEYS)) # 비움이 먼저
|
||||
rest = dict(entries[1:])
|
||||
assert "structure_reset" not in rest[100] # design 에 안 씀
|
||||
assert rest[100]["box_adjust"]["left"]["lengthM"] == 1 # 새 값은 뒤에 얹힘
|
||||
assert rest[140] == {"display_half_width_m": 6}
|
||||
@@ -26,8 +26,9 @@ from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa:
|
||||
from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS # noqa: E402
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
|
||||
|
||||
#: 측점을 가리키는 열쇠 — 값이 아니다.
|
||||
_KEY_FIELDS = {"chainage_m"}
|
||||
#: 측점을 가리키는 열쇠 · 저장 안 하는 지시 칸 — 값이 아니다.
|
||||
#: `structure_reset`(318-4) = 옛 구조물 조정값을 비우라는 지시 · 서버가 떼어 내고 design 에 안 씀.
|
||||
_KEY_FIELDS = {"chainage_m", "structure_reset"}
|
||||
|
||||
#: 사용자 값이 아니라 **계산 값**이라 재계산이 다시 내는 것이 옳은 필드.
|
||||
#: 브라우저가 조작 중 값을 함께 보내지만 정본은 서버가 다시 낸다(CLAUDE.md 5장).
|
||||
|
||||
Reference in New Issue
Block a user