diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 7fa5c4872..635bb9f80 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -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; + 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); } diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index d08410e79..fe9c1f655 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -53,9 +53,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { }); /** - * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), - * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. - * 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다. + * 측점 설계 버튼 변경 처리: 선택을 세션 초안에 쓰고 브라우저 다시 셈(바뀐 카드만 다시 그림 · 전체 재렌더 없음). + * 다시 셈 입력 우선순위 = 세션 고름 → 저장 정본 → config 기본값. */ async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise { 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, { diff --git a/resources/tester/helper_272_choice_undo.mjs b/resources/tester/helper_272_choice_undo.mjs new file mode 100644 index 000000000..21fe04717 --- /dev/null +++ b/resources/tester/helper_272_choice_undo.mjs @@ -0,0 +1,134 @@ +/* 467 덧 헬퍼 — 횡단 고름(단추 · 노견 확장 · 소단 깊이 · 암 경계)을 세션에 쓰고 다시 셈 → 세션 칸을 지움(↶) → + * 다시 셈 = 고르기 전(정본) 설계와 같은가 · 다시 씀(↷) = 고른 값과 같은가. + * node helper_272_choice_undo.mjs <묶음.mjs> + * 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)); diff --git a/resources/tester/helper_272_choice_undo_entry.ts b/resources/tester/helper_272_choice_undo_entry.ts new file mode 100644 index 000000000..55584e1ae --- /dev/null +++ b/resources/tester/helper_272_choice_undo_entry.ts @@ -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"; diff --git a/resources/tester/test_272_choice_undo.py b/resources/tester/test_272_choice_undo.py new file mode 100644 index 000000000..fda0524ed --- /dev/null +++ b/resources/tester/test_272_choice_undo.py @@ -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