fix(467 덧 고름 되돌림): 횡단 다시 셈 입력 물러섬 = 저장 정본 설계 · 고름 단추가 메모리 상세를 안 덮음
- Cross_Refresh refreshLocally — 세션 고름(단추 · 노견 확장 · 소단 깊이 · 암 경계 · 암 경사) ?? 저장 정본(savedDesigns) · 정본에 없는 측점만 메모리 설계 · 소단 깊이 · 소단 제원은 이번 원천 그대로(↶ 뒤 앞 값이 안 남음) - Design_Sync handleDesignChange — target.design 덮기 셋 뺌(세션만 · 카드는 다시 셈 알림이 그림) - 새 시험 test_272_choice_undo(옛 코드 = 넷 다 ↶ 뒤 앞 고름에 머묾) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
||||
type BermSpec,
|
||||
} from "@util/common_util_cross_berm";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { savedDesigns } from "./B06_Section_Section_Canon";
|
||||
import {
|
||||
crossDesignChoices,
|
||||
readBermDepths,
|
||||
@@ -398,11 +399,22 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
|
||||
// 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음)
|
||||
const grid = designTerrainGridNow(projectId)?.grid ?? null;
|
||||
// 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로
|
||||
// (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계
|
||||
const canon = new Map(
|
||||
(savedDesigns(projectId, input.routeId) ?? []).map((row) => [
|
||||
rockKey(row.chainage_m),
|
||||
row.design,
|
||||
]),
|
||||
);
|
||||
const updated: number[] = [];
|
||||
for (const section of detail.cross_sections) {
|
||||
const previous = section.design;
|
||||
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
|
||||
const design = previous as unknown as Record<string, unknown>;
|
||||
const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버
|
||||
// `recompute_designs_for_alignment` 의 우선순위와 같다.
|
||||
const sessionOffset = rockOffsets.get(rockKey(section.chainage_m));
|
||||
@@ -499,11 +511,15 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
);
|
||||
section.design.shoulder_extend_left_m = extendLeft;
|
||||
section.design.shoulder_extend_right_m = extendRight;
|
||||
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1)
|
||||
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이
|
||||
// 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음)
|
||||
for (const side of SIDES) {
|
||||
const own = next[`berm_depth_${side}_m`];
|
||||
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
|
||||
else delete section.design[`berm_depth_${side}_m`];
|
||||
}
|
||||
if (next.berm) section.design.berm = next.berm;
|
||||
else delete section.design.berm;
|
||||
memos.set(section, { samples: section.samples, fingerprint, design: section.design });
|
||||
updated.push(section.chainage_m);
|
||||
}
|
||||
|
||||
@@ -53,9 +53,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
});
|
||||
|
||||
/**
|
||||
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
|
||||
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음.
|
||||
* 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다.
|
||||
* 측점 설계 버튼 변경 처리: 선택을 세션 초안에 쓰고 브라우저 다시 셈(바뀐 카드만 다시 그림 · 전체 재렌더 없음).
|
||||
* 다시 셈 입력 우선순위 = 세션 고름 → 저장 정본 → config 기본값.
|
||||
*/
|
||||
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
|
||||
const sectionDetail = ctx.detail();
|
||||
@@ -66,39 +65,24 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
// 설계선 쉬프트(139-6) — 그 쪽 노견 확장만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구)
|
||||
// 고름은 세션에만 — 메모리 상세 설계를 안 덮음(덮으면 ↶ 로 세션 칸을 지워도 다시 셈이 덮인 값으로 물러섬 ·
|
||||
// 467 덧) · 카드는 다시 셈 알림(`onCrossDesignsUpdated`)이 그림
|
||||
// 설계선 쉬프트(139-6) — 그 쪽 노견 확장만 세션에 · 브라우저 재계산(같은 창구)
|
||||
const shift = change.shoulder_extend;
|
||||
if (shift) {
|
||||
writeShoulderExtend(projectId, currentRouteId, chainageM, shift.side, shift.valueM);
|
||||
if (target.design) target.design[`shoulder_extend_${shift.side}_m`] = shift.valueM;
|
||||
await reconcileStaleDesigns({ force: true });
|
||||
return;
|
||||
}
|
||||
// 소단 높이(194-1) — 그 쪽 깊이만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구)
|
||||
// 소단 높이(194-1) — 그 쪽 깊이만 세션에 · 브라우저 재계산(같은 창구)
|
||||
const berm = change.berm_depth;
|
||||
if (berm) {
|
||||
writeBermDepth(projectId, currentRouteId, chainageM, berm.side, berm.valueM);
|
||||
if (target.design) target.design[`berm_depth_${berm.side}_m`] = berm.valueM;
|
||||
await reconcileStaleDesigns({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
|
||||
if (target.design) {
|
||||
target.design = {
|
||||
...target.design,
|
||||
ground_type: change.ground_type,
|
||||
section_mode: change.section_mode,
|
||||
ditch_side: change.ditch_side ?? target.design.ditch_side,
|
||||
ditch_type: change.ditch_type,
|
||||
paved: change.paved,
|
||||
two_stage_slope: change.two_stage_slope,
|
||||
ditch_choice: change.ditch_choice,
|
||||
};
|
||||
ctx.view().refreshCard(chainageM);
|
||||
}
|
||||
|
||||
// (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
|
||||
// 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
|
||||
// [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로
|
||||
// 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다.
|
||||
writeCrossDesignChoice(projectId, currentRouteId, chainageM, {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/* 467 덧 헬퍼 — 횡단 고름(단추 · 노견 확장 · 소단 깊이 · 암 경계)을 세션에 쓰고 다시 셈 → 세션 칸을 지움(↶) →
|
||||
* 다시 셈 = 고르기 전(정본) 설계와 같은가 · 다시 씀(↷) = 고른 값과 같은가.
|
||||
* node helper_272_choice_undo.mjs <묶음.mjs> <job.json>
|
||||
* job = { standard, rockDefault, ground } · 짝 = `test_272_choice_undo.py` · 표준출력 JSON 한 벌.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const memory = () => {
|
||||
const m = new Map();
|
||||
return {
|
||||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||
setItem: (k, v) => m.set(k, String(v)),
|
||||
removeItem: (k) => m.delete(k),
|
||||
key: (i) => [...m.keys()][i] ?? null,
|
||||
get length() {
|
||||
return m.size;
|
||||
},
|
||||
clear: () => m.clear(),
|
||||
};
|
||||
};
|
||||
globalThis.window = globalThis;
|
||||
globalThis.sessionStorage = memory();
|
||||
globalThis.localStorage = memory();
|
||||
const any = new Proxy(function () {}, {
|
||||
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
|
||||
apply: () => any,
|
||||
construct: () => any,
|
||||
});
|
||||
globalThis.document = any;
|
||||
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
|
||||
globalThis.fetch = async () => new Response("{}", { status: 404 });
|
||||
|
||||
const m = await import(pathToFileURL(process.argv[2]).href);
|
||||
const job = JSON.parse(readFileSync(process.argv[3], "utf8"));
|
||||
const P = "p272undo";
|
||||
const R = 6;
|
||||
const AT = 40;
|
||||
m.writeState("std-cross", job.standard, P);
|
||||
m.rememberRockBoundaryDefault(P, job.rockDefault);
|
||||
// 소단 사본 = 측점을 덮는 구간(소단 깊이가 먹게)
|
||||
m.writeState(
|
||||
"berm",
|
||||
[{ start_m: 0, end_m: 100, width_m: 1, interval_m: 3, slope_deg: 0, depth_m: 1 }],
|
||||
P,
|
||||
R,
|
||||
);
|
||||
const canonDetail = () => ({
|
||||
longitudinal: { length_m: 100, stations: [], profile_alignment: { policy: {} } },
|
||||
cross_sections: [
|
||||
{
|
||||
station_id: "station_000000040000",
|
||||
chainage_m: AT,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_z: 100,
|
||||
azimuth_deg: 90,
|
||||
center_x: AT,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 1] },
|
||||
uphill_side: "left",
|
||||
samples: job.ground,
|
||||
design: {
|
||||
design_elevation_m: 100,
|
||||
ground_type: "soil",
|
||||
section_mode: "left_cut",
|
||||
ditch_type: "standard",
|
||||
ditch_side: "left",
|
||||
ditch_choice: null,
|
||||
two_stage_slope: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
m.writeState("section-detail", canonDetail(), P, R); // 저장 정본(세션 상세)
|
||||
const alignment = {
|
||||
stations: [{ chainage_m: AT, plan_elevation_m: 100.4 }],
|
||||
samples: [
|
||||
{ chainage_m: 0, elevation_m: 100.4 },
|
||||
{ chainage_m: 100000, elevation_m: 100.4 },
|
||||
],
|
||||
balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 },
|
||||
};
|
||||
const detail = canonDetail(); // 메모리 상세(가게가 쥔 객체)
|
||||
const refresh = async (target = detail) => {
|
||||
await m.refreshCrossDesigns({
|
||||
projectId: P,
|
||||
routeId: R,
|
||||
detail: target,
|
||||
edits: { station_offsets: {}, curve_radii: {} },
|
||||
alignment,
|
||||
});
|
||||
return JSON.stringify(target.cross_sections[0].design);
|
||||
};
|
||||
const clear = (name) => sessionStorage.removeItem(m.stateKey(name, P, R));
|
||||
|
||||
const pristine = await refresh(canonDetail()); // 고르기 전 = 정본으로 셈
|
||||
await refresh(); // 메모리 상세 한 번(측점 열쇠 세움)
|
||||
const out = { pristineSame: (await refresh()) === pristine, cases: {} };
|
||||
const CASES = {
|
||||
choice: [
|
||||
() =>
|
||||
m.writeCrossDesignChoice(P, R, AT, {
|
||||
ground_type: "blasting_rock",
|
||||
section_mode: "right_cut",
|
||||
ditch_side: "right",
|
||||
ditch_type: "standard",
|
||||
paved: false,
|
||||
two_stage_slope: true,
|
||||
ditch_choice: false,
|
||||
}),
|
||||
"crossdesign",
|
||||
],
|
||||
shoulder: [() => m.writeShoulderExtend(P, R, AT, "left", 1.2), "shoulderext"],
|
||||
bermDepth: [() => m.writeBermDepth(P, R, AT, "left", 2.5), "bermdepth"],
|
||||
rock: [() => m.writeKeyed("rockb", P, R, new Map([["40.00", 3.5]]), ["40.00"]), "rockb"],
|
||||
};
|
||||
for (const [name, [write, state]] of Object.entries(CASES)) {
|
||||
write();
|
||||
const picked = await refresh();
|
||||
clear(state); // ↶ = 세션 칸 지움
|
||||
const undone = await refresh();
|
||||
write(); // ↷ = 다시 고름
|
||||
const redone = await refresh();
|
||||
clear(state);
|
||||
const again = await refresh();
|
||||
out.cases[name] = {
|
||||
changed: picked !== pristine,
|
||||
undoIsCanon: undone === pristine,
|
||||
redoIsPicked: redone === picked,
|
||||
undoAgain: again === pristine,
|
||||
};
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,10 @@
|
||||
/* 467 덧 시험 묶음 진입점 — 횡단 고름 ↶ = 정본(진짜 모듈 · vite ssr). */
|
||||
export { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh";
|
||||
export {
|
||||
writeBermDepth,
|
||||
writeCrossDesignChoice,
|
||||
writeShoulderExtend,
|
||||
} from "../../B06_Section/B06_Section_Cross_Design_Session";
|
||||
export { writeKeyed } from "../../B06_Section/B06_Section_Station_Keys";
|
||||
export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
export { stateKey, writeState } from "../../A00_Common/b_page_state";
|
||||
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""467 덧 (요구 272) — 횡단 고름을 되돌리면(↶) 다시 셈이 정본 값으로 돌아옴.
|
||||
|
||||
못박는 것
|
||||
- 카드 단추(지반 · 단면 갈래 · 측구) · 노견 확장 · 소단 깊이 · 암 경계를 세션에 쓰고 다시 셈 → 세션 칸 지움(↶) →
|
||||
설계 = 고르기 전(정본으로 셈) 설계와 글자까지 같음 · 다시 씀(↷) = 고른 값과 같음.
|
||||
- 다시 셈 입력 물러섬 = 저장 정본 설계(메모리 설계에 남은 앞 고름 · 결과 아님).
|
||||
- 고름 단추가 메모리 상세 설계를 덮지 않음(`handleDesignChange`).
|
||||
|
||||
Node 헬퍼 `helper_272_choice_undo.mjs` 가 vite ssr 로 묶은 진짜 모듈을 부름.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from config.config_system import ( # noqa: E402
|
||||
STANDARD_CROSS_SECTION,
|
||||
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
)
|
||||
|
||||
GROUND = [
|
||||
{"offset_m": o / 2, "elevation_m": round(100.0 + 0.35 * o / 2, 4), "valid": True}
|
||||
for o in range(-24, 25)
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory):
|
||||
work = tmp_path_factory.mktemp("b272undo")
|
||||
build = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../resources/tester/helper_272_choice_undo_entry.ts",
|
||||
"--outDir",
|
||||
str(work),
|
||||
"--logLevel",
|
||||
"error",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
)
|
||||
assert build.returncode == 0, build.stderr
|
||||
bundle = work / "bundle.mjs"
|
||||
shutil.copy(work / "helper_272_choice_undo_entry.js", bundle)
|
||||
job = work / "job.json"
|
||||
job.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"standard": STANDARD_CROSS_SECTION,
|
||||
"rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
"ground": GROUND,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run( # noqa: S603
|
||||
["node", str(HERE / "helper_272_choice_undo.mjs"), str(bundle), str(job)],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_고르기_전_메모리_다시_셈_같음(out):
|
||||
assert out["pristineSame"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["choice", "shoulder", "bermDepth"])
|
||||
def test_고름_되돌림_정본_다시_고름_같음(out, name):
|
||||
case = out["cases"][name]
|
||||
assert case["changed"], name # 고름이 실제로 설계를 바꿈
|
||||
assert case["undoIsCanon"], name # ↶ = 정본
|
||||
assert case["redoIsPicked"], name # ↷ = 고른 값
|
||||
assert case["undoAgain"], name
|
||||
|
||||
|
||||
def test_암_경계_되돌림_정본(out):
|
||||
case = out["cases"]["rock"]
|
||||
assert case["undoIsCanon"] and case["redoIsPicked"] and case["undoAgain"]
|
||||
|
||||
|
||||
def test_단추가_메모리_설계를_안_덮음():
|
||||
sync = (ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts").read_text(encoding="utf-8")
|
||||
body = sync[
|
||||
sync.index("async function handleDesignChange") : sync.index("function recomputeIfRock")
|
||||
]
|
||||
assert "target.design" not in body
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
assert "savedDesigns(projectId, input.routeId)" in refresh
|
||||
Reference in New Issue
Block a user