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:
2026-10-13 21:24:37 +09:00
co-authored by Claude Opus 5.5
parent 6329ea2a3a
commit 6d914b7647
15 changed files with 1012 additions and 34 deletions
+22 -13
View File
@@ -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(
+2
View File
@@ -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;
}
+103 -8
View File
@@ -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`는 [저장] 시점 실패를 화면에 알리는 쪽이 구현한다.
+2
View File
@@ -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 = 글. */
+15 -6
View File
@@ -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 "
+7 -1
View File
@@ -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
)
+15
View File
@@ -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=…)`.
+3
View File
@@ -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):
+18
View File
@@ -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) {
+13 -3
View File
@@ -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;
}
+11 -1
View File
@@ -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: () => {
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
+503
View File
@@ -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);
});
+291
View File
@@ -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장).