From f2338bd5c8827134c9bc779d83778290c82f743a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:35:14 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(443=20B05=20=EC=A0=80=EC=9E=A5):=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=3D=20=EC=B4=88=EC=95=88=20=EB=82=A8=EA=B8=B0?= =?UTF-8?q?=EA=B3=A0=20=EA=B7=B8=20=EC=B0=A8=EB=A1=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A9=88=EC=B6=A4=20=C2=B7=20=EC=83=81=EB=8B=A8=EC=B8=A1=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=EC=84=B1=EA=B3=B5=20=EB=92=A4=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=B9=84=EC=9B=80=20=C2=B7=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EC=8B=A4=ED=8C=A8=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물(H6) — 409 · 그 밖 실패를 토스트로 삼키지 않고 던짐 · 저장 줄이 앞 실패에 막히지 않음 - 상단측(M3) — 차례 성공하면 uphill 세션 비움 · 종단 보기도 세션에서 읽음 - 확정 하위(M6) — 응답 failed_parts(비정규 측점 횡단 · 상단측 병합 · 종단 없음) · 화면이 던짐 - 시험 test_443_save_stops.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- B05_Profile/B05_Profile_Api_Fetch.ts | 13 +- B05_Profile/B05_Profile_Router_Lifecycle.py | 10 +- B05_Profile/B05_Profile_Schema.py | 2 + B05_Profile/B05_Profile_UI_Page_Actions.ts | 48 +++-- B05_Profile/B05_Profile_UI_Page_Structures.ts | 18 +- resources/tester/helper_443_save_stops.cjs | 110 ++++++++++ resources/tester/test_443_save_stops.py | 201 ++++++++++++++++++ 7 files changed, 367 insertions(+), 35 deletions(-) create mode 100644 resources/tester/helper_443_save_stops.cjs create mode 100644 resources/tester/test_443_save_stops.py diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index 05e7e1310..7f2f0a568 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -101,6 +101,8 @@ export interface RouteConfirmResponse { project_id: string; route_id: number; confirmed: boolean; + /** 실패한 하위 차례(비정규 측점 횡단 · 상단측 병합) — 확정은 됨(443). */ + failed_parts?: string[]; } export interface RouteLatestResponse { @@ -278,7 +280,16 @@ export async function flushUphillOverrides(projectId: string): Promise { .filter(([, side]) => side === "left" || side === "right") .map(([chainage, side]) => ({ chainage_m: Number(chainage), side })); if (!overrides.length) return; - await confirmRoute(projectId, { uphill_overrides: overrides }, false); + await saveRouteParts(projectId, { uphill_overrides: overrides }); +} + +/** [저장] 차례의 확정(단계 전이 없음) — 하위 차례가 실패하면 던짐(443 · 저장 한 줄기가 그 차례에서 멈춤 · + * 세션 그대로). 다 되면 보낸 상단측 세션을 비움 — 안 비우면 매 [저장]이 옛 값을 다시 보냄. */ +export async function saveRouteParts(projectId: string, body: RouteConfirmRequest): Promise { + const saved = await confirmRoute(projectId, body, false); + const failed = saved.failed_parts ?? []; + if (failed.length) throw new Error(`${failed.join(" · ")} 실패`); + if (body.uphill_overrides?.length) clearState("uphill", projectId); } /** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */ diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 285cf5dd2..d49503b57 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -58,6 +58,8 @@ async def confirm_latest_route( status_code=404, content={"status": "error", "message": "확정할 경로가 없습니다."}, ) + # 하위 차례 실패 — 확정은 진행하되 응답에 실어 화면이 그 차례에서 멈추게(443 · M6) + failed_parts: list[str] = [] if request.can_regenerate(): try: await _append_irregular_cross_sections(connection, project_id, latest, request) @@ -68,6 +70,7 @@ async def confirm_latest_route( project_id, latest["id"], ) + failed_parts.append("비정규 측점 횡단") # 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적. if request.uphill_overrides: try: @@ -94,12 +97,15 @@ async def confirm_latest_route( str(longitudinal["longitudinal_file_path"]), overrides, ) + else: + failed_parts.append("상단측 병합(종단 없음)") except Exception: logger.exception( "B05 상단측 변경 병합 실패 (경로 확정은 진행): project_id=%s route_id=%s", project_id, latest["id"], ) + failed_parts.append("상단측 병합") await connection.begin() try: log_b05_debug( @@ -132,7 +138,9 @@ async def confirm_latest_route( reason=str(exc), ) raise - return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"]) + return RouteConfirmResponse( + project_id=str(project_id), route_id=latest["id"], failed_parts=failed_parts + ) except Exception: logger.exception("B05 경로 확정 실패: project_id=%s", project_id) return JSONResponse( diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index d429a5aa9..c943f5628 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -298,6 +298,8 @@ class RouteConfirmResponse(BaseModel): project_id: str route_id: int confirmed: bool = True + #: 실패한 하위 차례(비정규 측점 횡단 · 상단측 병합) — 확정은 됨 · 화면이 알리고 멈춤(443) + failed_parts: list[str] = Field(default_factory=list) class RouteLatestResponse(BaseModel): diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index f9f79c93d..deb03576f 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -7,7 +7,8 @@ * 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다. * ========================================================================== */ -import { confirmRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; +import { saveRouteParts, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; +import { loadUphillOverrides } from "./B05_Profile_UI_Page_Helpers"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { pageRouteId } from "../A00_Common/design/design_page_route"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; @@ -42,34 +43,35 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave { return { routeId: () => (ctx.routeReady() ? pageRouteId(projectId) : null), // 노선 id 한 칸(386-2b) steps: { - // 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고 - // 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장). + // 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 초안을 남기고 던져 + // 저장 한 줄기가 이 차례에서 멈춘다(443). structures: () => ctx.bridge().saveStructuresIfDirty(), // 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. + // 하위 실패 = 던짐 · 성공 = 상단측 세션 비움(443). 상단측은 세션에서 읽음 — 노선 바꾼 [저장]이 + // 기본 창구로 세션을 비웠으면 메모리 값을 다시 보내지 않게. uphill: async () => { const latest = ctx.latest(); - await confirmRoute( - projectId, - { - filter_key: latest?.surface_params.source_filter, - method: latest?.surface_params.method, - smooth: latest?.surface_params.smooth, - surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, - irregular_stations: ctx - .bridge() - .irregularStations() - .map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure, - })), - // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. - uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({ + await saveRouteParts(projectId, { + filter_key: latest?.surface_params.source_filter, + method: latest?.surface_params.method, + smooth: latest?.surface_params.smooth, + surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, + irregular_stations: ctx + .bridge() + .irregularStations() + .map((station) => ({ + chainage_m: station.chainage_m, + structure: station.structure, + })), + // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. + uphill_overrides: [...loadUphillOverrides(projectId).entries()].map( + ([chainage, side]) => ({ chainage_m: Number(chainage), side, - })), - }, - false, - ); + }), + ), + }); + ctx.uphillOverrides.clear(); }, }, // 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움) diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 7420aaaa1..8cf42e300 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -216,8 +216,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { // 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다. // 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는 // 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐). - structureSaving = structureSaving.then(() => persistStructures(readPending() ?? ownStructures)); - await structureSaving; + // 실패는 저장 한 줄기로 던짐(그 차례에서 멈춤 · 443) — 줄은 이어 다음 [저장]이 막히지 않게. + const run = structureSaving.then(() => persistStructures(readPending() ?? ownStructures)); + structureSaving = run.catch(() => undefined); + await run; } /** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */ @@ -262,14 +264,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { } catch (error) { // 실패해도 **초안은 지우지 않는다** — 지우면 사용자가 넣은 구조물이 사라진다 // (2026-09-06 정정). 화면 목록도 그대로 두고 다시 [저장]하면 된다. - if (error instanceof StructureConflictError) { - showToast( - "다른 창에서 구조물이 먼저 저장돼 이번 저장을 건너뛰었습니다. 다시 [저장]해 주세요.", - "error", - ); - return; - } - showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error"); + // 삼키지 않고 던짐 — 저장 한 줄기가 이 차례에서 멈추고 알림(443 · 「저장했습니다」 안 뜸). + if (error instanceof StructureConflictError) + throw new Error("다른 창에서 구조물이 먼저 저장됐습니다 — 다시 [저장]해 주세요."); + throw error instanceof Error ? error : new Error("구조물 저장에 실패했습니다."); } } diff --git a/resources/tester/helper_443_save_stops.cjs b/resources/tester/helper_443_save_stops.cjs new file mode 100644 index 000000000..9031f6c45 --- /dev/null +++ b/resources/tester/helper_443_save_stops.cjs @@ -0,0 +1,110 @@ +/* PLAN 443 단위검증 헬퍼 — 저장 누락 ① (H6 · M3 · M6). + * H6 구조물 저장 실패(409 · 그 밖) = 던짐 · 초안 남음 · 다음 [저장]이 줄에 안 막힘 + * M3 상단측 차례 성공 = uphill 세션 비움 · 실패 = 그대로 + * M6 확정 응답 failed_parts = 던짐(그 차례에서 멈춤) + * TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_443_save_stops.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = { setTimeout, clearTimeout }; + +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); + +function load(file, stubs) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r] ?? anything()); + return box.exports; +} + +const settle = (promise) => + promise.then( + () => "ok", + (error) => String(error.message), + ); + +async function run() { + const out = {}; + + // ── H6 구조물 ── + class StructureConflictError extends Error {} + let pending = [{ structure_id: "s1" }]; + let mode = "conflict"; + const puts = []; + const bridgeMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Page_Structures.ts"), { + "./B05_Profile_Api_Structures": { + StructureConflictError, + fetchStructures: async () => ({ revision: 3, structures: [] }), + saveStructures: async (_p, revision, list) => { + puts.push(revision); + if (mode === "conflict") throw new StructureConflictError("409"); + if (mode === "boom") throw new Error("서버 500"); + return { revision: revision + 1 }; + }, + readPendingStructures: () => pending, + writePendingStructures: (_p, next) => (pending = next), + definitionStale: () => false, + staleDefinitionNotice: () => null, + }, + "@ui/ui_template_elements": { showToast: () => undefined }, + }); + const quiet = new Proxy({}, { get: () => () => undefined }); + const bridge = bridgeMod.createStructuresBridge({ + projectId: "p1", + panel: () => ({ structures: quiet, values: () => ({}) }), + profilePanel: () => quiet, + renderStationLines: () => undefined, + isRestoring: () => false, + }); + out.conflict = await settle(bridge.saveStructuresIfDirty()); + out.pendingAfterConflict = pending; + mode = "boom"; + out.boom = await settle(bridge.saveStructuresIfDirty()); // 앞 실패에 줄이 안 막힘 + out.pendingAfterBoom = pending; + mode = "ok"; + out.ok = await settle(bridge.saveStructuresIfDirty()); + out.pendingAfterOk = pending; + out.puts = puts; + + // ── M3 · M6 상단측 · 확정 하위 ── + const state = new Map(); + let reply = {}; + const bodies = []; + globalThis.fetch = async (_url, init) => { + bodies.push(JSON.parse(init.body)); + return { ok: true, json: async () => reply }; + }; + const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Fetch.ts"), { + "../A00_Common/b_page_state": { + readState: (name, p) => state.get(`${name}:${p}`) ?? null, + writeState: (name, value, p) => state.set(`${name}:${p}`, value), + clearState: (name, p) => state.delete(`${name}:${p}`), + }, + "@config/config_frontend": { API_BASE_URL: "", API_TIMEOUT_MS: 1000 }, + }); + state.set("uphill:p1", { "40.000": "right" }); + reply = { failed_parts: ["상단측 병합"] }; + out.flushFailed = await settle(api.flushUphillOverrides("p1")); + out.uphillAfterFail = state.get("uphill:p1") ?? null; + reply = { failed_parts: [] }; + out.flushOk = await settle(api.flushUphillOverrides("p1")); + out.uphillAfterOk = state.get("uphill:p1") ?? null; + out.sentAgain = await settle(api.flushUphillOverrides("p1")); // 비었으니 안 보냄 + out.bodies = bodies; + reply = { failed_parts: ["비정규 측점 횡단"] }; + out.partsFailed = await settle(api.saveRouteParts("p1", { irregular_stations: [] })); + return out; +} + +run().then( + (out) => process.stdout.write(JSON.stringify(out)), + (error) => { + process.stderr.write(String(error.stack ?? error)); + process.exit(1); + }, +); diff --git a/resources/tester/test_443_save_stops.py b/resources/tester/test_443_save_stops.py new file mode 100644 index 000000000..a0b6a2921 --- /dev/null +++ b/resources/tester/test_443_save_stops.py @@ -0,0 +1,201 @@ +"""PLAN 443 — 저장 누락 ① 구조물 실패 · 상단측 재전송 · 확정 하위 실패(H6 · M3 · M6). + +- H6 구조물 저장 실패(409 · 그 밖) = 던짐(저장 한 줄기가 구조물 차례에서 멈춤) · 초안 남음 · 줄 안 막힘. +- M3 상단측 차례 성공 = uphill 세션 비움(다음 [저장]이 다시 안 보냄) · 실패 = 세션 그대로. +- M6 B05 확정 하위(비정규 측점 횡단 · 상단측 병합) 실패 = 응답 failed_parts · 화면이 던짐. +Node 헬퍼 = `helper_443_save_stops.cjs`. + +./venv/Scripts/python.exe -m pytest resources/tester/test_443_save_stops.py -q +""" + +import asyncio +import json +import subprocess +from pathlib import Path +from uuid import uuid4 + +import pytest + +from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle +from B05_Profile.B05_Profile_Schema import RouteConfirmRequest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_443_save_stops.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── H6 ─────────────────────────────────────────────────────────────────────── + + +def test_구조물_409_던짐_초안_남음(out): + assert "먼저 저장" in out["conflict"] + assert out["pendingAfterConflict"] == [{"structure_id": "s1"}] + + +def test_구조물_실패_뒤_줄이_안_막힘(out): + assert out["boom"] == "서버 500" and out["pendingAfterBoom"] == [{"structure_id": "s1"}] + assert out["ok"] == "ok" and out["pendingAfterOk"] is None + assert out["puts"] == [3, 3, 3] # 세 번 다 나감 + + +def test_구조물_차례가_토스트로_삼키지_않음(): + page = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Structures.ts").read_text("utf-8") + body = page.split("async function persistStructures(")[1].split("\n }\n")[0] + assert "showToast(error" not in body and "return;\n" not in body.split("catch (error)")[1] + + +# ── M3 · M6 화면 ───────────────────────────────────────────────────────────── + + +def test_상단측_실패면_세션_그대로_성공이면_비움(out): + assert out["flushFailed"] == "상단측 병합 실패" + assert out["uphillAfterFail"] == {"40.000": "right"} + assert out["flushOk"] == "ok" and out["uphillAfterOk"] is None + assert len(out["bodies"]) == 3 # 실패 · 성공 · (빈 세션은 안 보냄) · 확정 하위 하나 + + +def test_확정_하위_실패면_던짐(out): + assert out["partsFailed"] == "비정규 측점 횡단 실패" + + +def test_종단_보기_상단측_차례도_같은_창구(): + actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8") + assert "await saveRouteParts(projectId, {" in actions + assert "loadUphillOverrides(projectId)" in actions # 세션에서 읽음(메모리 옛 값 안 보냄) + assert "confirmRoute(" not in actions + + +# ── M6 서버 ────────────────────────────────────────────────────────────────── + + +class _Cursor: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _Connection: + def __init__(self): + self.committed = False + + async def begin(self): + pass + + async def commit(self): + self.committed = True + + async def rollback(self): + pass + + def cursor(self): + return _Cursor() + + +class _Pool: + def __init__(self, connection): + self.connection = connection + + def acquire(self): + connection = self.connection + + class _Ctx: + async def __aenter__(self): + return connection + + async def __aexit__(self, *exc): + return False + + return _Ctx() + + +def _patch(monkeypatch, connection, *, append, longitudinal, merge): + async def latest(*_a): + return {"id": 7, "status": "DRAFT"} + + async def noop(*_a, **_k): + return None + + async def storage(*_a): + return "p" + + async def get_long(*_a): + return longitudinal + + monkeypatch.setattr(lifecycle, "get_db_pool", lambda: _Pool(connection)) + monkeypatch.setattr(lifecycle, "get_latest_route", latest) + monkeypatch.setattr(lifecycle, "confirm_route", noop) + monkeypatch.setattr(lifecycle, "complete_stage", noop) + monkeypatch.setattr(lifecycle, "_append_irregular_cross_sections", append) + monkeypatch.setattr(lifecycle, "get_project_storage_relative_path", storage) + monkeypatch.setattr(lifecycle, "get_longitudinal_section", get_long) + monkeypatch.setattr(lifecycle, "resolve_stored_project_path", lambda p: p) + monkeypatch.setattr(lifecycle, "_merge_uphill_overrides_into_longitudinal", merge) + monkeypatch.setattr(lifecycle, "sync_uphill_overrides_into_designs", noop) + + +def _boom(*_a, **_k): + raise RuntimeError("boom") + + +async def _aboom(*_a, **_k): + raise RuntimeError("boom") + + +_REQUEST = RouteConfirmRequest( + filter_key="csf", + method="tin", + surface_model_id=1, + uphill_overrides=[{"chainage_m": 40.0, "side": "right"}], +) + + +def test_확정_하위_실패를_응답에_실음(monkeypatch): + connection = _Connection() + _patch( + monkeypatch, + connection, + append=_aboom, + longitudinal={"longitudinal_file_path": "l.json"}, + merge=_boom, + ) + result = asyncio.run(lifecycle.confirm_latest_route(uuid4(), _REQUEST, False)) + assert result.failed_parts == ["비정규 측점 횡단", "상단측 병합"] + assert connection.committed # 확정 자체는 진행(비치명) + + +def test_종단_없으면_상단측_병합_실패(monkeypatch): + async def ok(*_a, **_k): + return None + + _patch(monkeypatch, _Connection(), append=ok, longitudinal=None, merge=lambda *a: None) + result = asyncio.run(lifecycle.confirm_latest_route(uuid4(), _REQUEST, False)) + assert result.failed_parts == ["상단측 병합(종단 없음)"] + + +def test_다_되면_빈_목록(monkeypatch): + async def ok(*_a, **_k): + return None + + _patch( + monkeypatch, + _Connection(), + append=ok, + longitudinal={"longitudinal_file_path": "l.json"}, + merge=lambda *a: None, + ) + result = asyncio.run(lifecycle.confirm_latest_route(uuid4(), _REQUEST, False)) + assert result.failed_parts == [] From f44b12d62a99bfd7264689ba87512f87aa833ba1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:37:35 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(440=20B06=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=A8=EB=A1=80):=20=EC=95=94?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=84=EC=84=A0=20=ED=8C=90=EC=A0=95=20=3D=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=ED=8C=90=EC=A0=95=EB=A9=B4=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=20=E2=80=94=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=95=84=EB=9E=AB=EB=B6=80=EB=B6=84=20=EB=88=84=EB=A6=84?= =?UTF-8?q?=EC=9D=B4=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 실측(검증_sub7 · 노선 초안): 암 경계 판정이 벽 판정면 40 % · 관 판정면 23 % 를 덮음 → 0 - 시험: test_440 ④ · 356 배선 꼴 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- B06_Section/B06_Section_UI_Cross_Pick_Order.ts | 12 +++++++++--- resources/tester/test_356_b06_card_center.py | 4 +++- resources/tester/test_440_cross_draft_pick.py | 10 ++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts index b688e670f..3cad07890 100644 --- a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -3,7 +3,8 @@ * 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대). * * 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) · - * 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로. + * 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로 · + * 암 경계선 판정(굵은 투명 선)은 판정면 아래 — 구조물 아랫부분을 덮지 않음(440). * 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님. * 맹암거(306-2) = 안쪽 판정면은 맨 아래(겹친 띠 · 구조물이 위) · 테두리 판정(투명 굵은 선)이 면 판정 맨 위. * ========================================================================== */ @@ -15,6 +16,8 @@ import { CENTER_HIT_CLASS } from "./B06_Section_UI_Cross_Center"; const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; /** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */ const LINE_HIT_CLASS = "b06-chart__design-hit"; +/** 암 경계선 판정(`_Cross_Design_Pick` `ROCK_HIT_CLASS`) — 판정면 아래(440). */ +const ROCK_HIT_CLASS = "b06-chart__rock-hit"; /** 맹암거 몸(그림) · 테두리 판정 클래스(306-2). */ const UNDERDRAIN_GROUP = "b06-chart__underdrain"; const UNDERDRAIN_BODY = "b06-chart__underdrain-body"; @@ -69,9 +72,12 @@ export function stackPickTargets(layer: SVGElement): void { .map((el) => ({ el, area: pointsArea(el.getAttribute("points")) })) .sort((a, b) => b.area - a.area) .map(({ el }) => el); - const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); + const rocks = kids.filter((el) => el.classList.contains(ROCK_HIT_CLASS)); + const lines = kids.filter( + (el) => el.classList.contains(LINE_HIT_CLASS) && !el.classList.contains(ROCK_HIT_CLASS), + ); const centers = kids.filter((el) => el.classList.contains(CENTER_HIT_CLASS)); // 중심점 = 맨 위(356-3) - layer.append(...surfaces, ...edges, ...lines, ...centers); + layer.append(...rocks, ...surfaces, ...edges, ...lines, ...centers); let down: { x: number; y: number } | null = null; layer.addEventListener( "pointerdown", diff --git a/resources/tester/test_356_b06_card_center.py b/resources/tester/test_356_b06_card_center.py index 9b17676ae..9d412e4d0 100644 --- a/resources/tester/test_356_b06_card_center.py +++ b/resources/tester/test_356_b06_card_center.py @@ -78,7 +78,9 @@ def test_중심점_좌우_없음_위아래만(ts): def test_배선_걷음(): order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8") - assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order + assert ( + "layer.append(...rocks, ...surfaces, ...edges, ...lines, ...centers);" in order + ) # 암 = 판정면 아래(440) for gone in ( ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Shift.ts", ROOT / "B05_Profile" / "logic" / "B05_Profile_RouteShift.ts", diff --git a/resources/tester/test_440_cross_draft_pick.py b/resources/tester/test_440_cross_draft_pick.py index d50a9f44a..1ac7e4c18 100644 --- a/resources/tester/test_440_cross_draft_pick.py +++ b/resources/tester/test_440_cross_draft_pick.py @@ -4,6 +4,7 @@ (갈아끼운 캐시)에 모든 카드 번호가 있음 · 찾기 넷(카드 고름 · 목록 → 카드 · 넘김 · 풀기)은 지금 그린 상세. ② 평면 패널 빈 곳 누름 = 평면 마커가 고른 것만 풂(`test_409` 의 deselectOther · deselectOwn). ③ 바뀜 신호에서 B06 목록도 자리가 바뀌었으면 다시 실음. +④ 암 경계선 판정(굵은 투명 선)은 구조물 판정면 아래 — 구조물 아랫부분 누름이 구조물을 고름. TS 셈은 `helper_440_draft_card_ids.cjs`. ./venv/Scripts/python.exe -m pytest resources/tester/test_440_cross_draft_pick.py -q @@ -77,3 +78,12 @@ def test_평면_빈_곳_누름은_마커_고름만() -> None: marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") assert "deselect: () => planMarks(canvas)?.picked === ownPick && deselect()," in marks assert "ownPick = mark.id;" in marks + + +def test_암_경계_판정은_구조물_판정면_아래() -> None: + order = _read("B06_Section", "B06_Section_UI_Cross_Pick_Order.ts") + assert "layer.append(...rocks, ...surfaces, ...edges, ...lines, ...centers);" in order + lines = order[order.index("const lines = kids.filter(") :] + assert "!el.classList.contains(ROCK_HIT_CLASS)" in lines[: lines.index(");")] + pick = _read("B06_Section", "B06_Section_UI_Cross_Design_Pick.ts") + assert 'export const ROCK_HIT_CLASS = "b06-chart__rock-hit";' in pick From 97b5a78695e801ae75e2fec1eab9f6fdbe2c9bc9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:42:06 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(441=20B05=20=EC=8A=A4=EB=83=85=20?= =?UTF-8?q?=EB=81=8C=EA=B8=B0):=20=EB=A7=9E=EC=B6=A4=20=EA=B5=90=EC=A0=90?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20=ED=95=9C=EB=8F=84=20=3D=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=C2=B7=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=B0=96=20=EB=8A=98=EB=A9=B4=20=EA=B7=B8?= =?UTF-8?q?=20=EA=B1=B8=EC=9D=8C=20=EB=90=98=EB=8F=8C=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=EC=97=86=EB=8A=94=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=3D=20=EC=A0=80=EC=9E=A5=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StationFit reach = intervalM(반 간격이면 MC 18+9 ~ 19+1 못 맞춤) - 끌기 · 방향키 — 미리보기 측점 밖 곡선 수가 누를 때보다 늘면 그 걸음 되돌림 · dragMoved 는 막힘 검사 뒤(433-14) - 계산 줄 stations 단계 · follow 새 측점 — 초안 없으면 저장 노선 station_snap · station_snap_step(snapOptions) - 시험 — 390_5 끌기 훑기(놓은 뒤 측점 밖 0 · 자동 · 고정 R) · 344_3 저장 스냅 · 266 사면 이동 한 걸음 · 다시 그림 한 번 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_pipeline_page.ts | 15 ++-- .../design/logic/design_pipeline_stations.ts | 28 ++++-- B05_Profile/B05_Profile_UI_RouteEdit.ts | 1 + B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts | 15 +++- .../B05_Profile_UI_RouteEdit_Pointer.ts | 19 ++++- .../logic/B05_Profile_RouteEdit_StationFit.ts | 4 +- resources/tester/helper_266_card_toggle.mjs | 7 +- .../tester/helper_266_card_toggle_entry.ts | 5 +- .../tester/helper_344_3_pipeline_stations.cjs | 20 +++++ .../tester/helper_390_5_snap_step_ops.cjs | 85 ++++++++++++++++++- .../tester/test_266_card_toggle_mismatch.py | 4 +- .../test_344_3_pipeline_stations_mirror.py | 11 +++ resources/tester/test_390_5_snap_step_ops.py | 23 ++++- 13 files changed, 211 insertions(+), 26 deletions(-) diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 3103c59f7..04d89ca2d 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -27,7 +27,7 @@ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./log import { SOURCE_INPUT } from "./logic/design_pipeline_types"; export { pipelineWork } from "./logic/design_pipeline"; import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross"; -import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations"; +import { createStationsStage, snapOptions, stationDiff } from "./logic/design_pipeline_stations"; import type { ChangeSource, FollowPatch, @@ -298,12 +298,7 @@ function createPagePipeline( route.curves, (patch.pipes ?? inputs.pipes ?? []) as PlanPipe[], (patch.structures ?? inputs.structures ?? []) as PlanStructure[], - { - intervalM: options.stationIntervalM, - types: enabled, - stationSnap: inputs.route?.stationSnap ?? false, - stationSnapStep: inputs.route?.stationSnapStep, - }, + { intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) }, ).map((station) => station.chainageM), }); /** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */ @@ -342,7 +337,11 @@ function createPagePipeline( }; return followStage({ ...ctx, inputs }); }, - stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => enabled }), + stations: createStationsStage({ + intervalM: options.stationIntervalM, + types: () => enabled, + saved: () => plan, + }), ground: createGroundStage(groundDeps), planline: createPlanlineStage({ savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base, diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index c2df06dc0..9c4d19d62 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -17,6 +17,7 @@ import type { import type { PipelineRunRecord, PipelineStation, + RouteDraft, StageContext, StageFn, } from "./design_pipeline_types"; @@ -41,12 +42,30 @@ export function reuseOutside( }); } -/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 노선 초안 스냅. */ +/** 저장 노선의 측점 스냅 설정(`RoutePlanResponse` 칸). */ +export interface SavedSnap { + station_snap?: boolean; + station_snap_step?: number | null; +} + +/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남). */ +export function snapOptions( + draft: RouteDraft | null | undefined, + saved: SavedSnap | null | undefined, +): Pick { + return draft + ? { stationSnap: draft.stationSnap, stationSnapStep: draft.stationSnapStep } + : { stationSnap: saved?.station_snap ?? false, stationSnapStep: saved?.station_snap_step }; +} + +/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */ export function createStationsStage( options: Omit & { types: () => ReadonlyArray; + saved?: () => SavedSnap | null; }, ): StageFn<"stations"> { + const { saved, ...rest } = options; return (ctx: StageContext<"stations">) => { const route = ctx.out.route; if (!route) return []; @@ -55,12 +74,7 @@ export function createStationsStage( route.curves, (ctx.inputs.pipes as PlanPipe[] | null) ?? [], (ctx.inputs.structures as PlanStructure[] | null) ?? [], - { - ...options, - types: options.types(), - stationSnap: ctx.inputs.route?.stationSnap ?? false, - stationSnapStep: ctx.inputs.route?.stationSnapStep, - }, + { ...rest, types: options.types(), ...snapOptions(ctx.inputs.route, saved?.()) }, ); return reuseOutside(next, ctx.prev, ctx.range); }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 85adc5795..3fc47eccc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -544,6 +544,7 @@ export async function mountRouteEditor( if (base) markEdited(); return base !== undefined; }, + offStation: () => (stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0), }); bindNodeSplice({ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts index d57b14104..c7db6c03d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts @@ -31,6 +31,8 @@ interface Hold { start: Vertex; /** 스냅 미리보기 전 노선(맞추기 전) — 다음 걸음 · 하한 검사는 여기서(맞춤이 쌓이지 않음). */ raw: Vertex[] | null; + /** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림. */ + off: number; moved: boolean; } @@ -74,8 +76,18 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () = params.draw(); return; } - now.moved = true; fitPreview(now, state.planned); + if (now.raw && params.offStation() > now.off) { + // 맞춰도 MC 가 측점 밖에 남는 자리 — 끌기와 같게 그 걸음만 되돌림(441) + const planned = params.state().planned; + now.raw.forEach((vertex, index) => (planned[index] = vertex)); + planned[now.index] = node; + fitPreview(now, planned); + status.textContent = `${params.routeHead()} — 곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다.`; + params.draw(); + return; + } + now.moved = true; status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`; params.draw(); } @@ -123,6 +135,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () = before: state.planned.map(([x, y]): Vertex => [x, y]), start: [node[0], node[1]], raw: null, + off: params.offStation(), moved: false, }; hold = now; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 45f975742..73d635759 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -106,6 +106,8 @@ export interface RouteEditPointerParams { /** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 · * 맞췄으면 true(끔이면 false · 아무 일 없음). */ fitDrag: (index: number) => boolean; + /** 측점 밖 곡선 수(스냅 끔 = 0 · 441 끌기 · 방향키 막힘). */ + offStation: () => number; } export interface RouteEditPointer { @@ -191,8 +193,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ let dragMoved = false; - /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */ + /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격 밂). */ let dragRaw: Vertex[] | null = null; + /** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림(못 맞추는 자리로 못 끎). */ + let dragOff = 0; /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); @@ -227,6 +231,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①). dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1; dragRaw = null; + dragOff = params.offStation(); dragHandle = hitNode >= 0 ? null : handleAt(px, py); dragMoved = false; if (hitNode >= 0) { @@ -353,7 +358,6 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM); const previous = state.planned[dragNode]; - dragMoved = true; state.planned[dragNode] = toMetric(px, py); params.markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. const after = params.state(); @@ -372,6 +376,17 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP return; } fitPreview(state.planned); // 스냅 켬 = 끄는 동안 MC 를 간격 배수에(390-5 · 놓을 때 정본) + if (dragRaw && params.offStation() > dragOff) { + // 맞춰도 MC 가 측점 밖에 남는 자리 — 하한 막힘과 같게 그 걸음만 되돌림(441) + const now = params.state().planned; + dragRaw.forEach((vertex, index) => (now[index] = vertex)); + now[dragNode] = previous; + fitPreview(now); + status.textContent = `${params.routeHead()} — 곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다.`; + params.draw(); + return; + } + dragMoved = true; // 막힘 검사 뒤 — 막힌 채 놓으면 되돌릴 걸음 없음(433-14) // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`; diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index 8a9fbd34c..f54583bba 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -3,7 +3,7 @@ * 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면. * * 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴: - * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격/2 안에서 + * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격 안에서 * 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠. * · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로. * · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음. @@ -113,7 +113,7 @@ function solve( if (!(length > 1e-9)) return null; const ux = (base[0] - before[0]) / length; const uy = (base[1] - before[1]) / length; - const reach = limits.intervalM / 2; + const reach = limits.intervalM; // 한가운데(18+9 ~ 19+1)도 가까운 측점까지(441 · 반 간격이면 못 닿음) const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d)); const at = (d: number) => { const next = planned.slice(); diff --git a/resources/tester/helper_266_card_toggle.mjs b/resources/tester/helper_266_card_toggle.mjs index ffb22fe62..33f07986e 100644 --- a/resources/tester/helper_266_card_toggle.mjs +++ b/resources/tester/helper_266_card_toggle.mjs @@ -95,15 +95,17 @@ async function toggles(projectId, shift) { const steps = []; const run = async (tag) => { const updated = []; + let calls = 0; // 받는 쪽 = 카드 다시 그림 + 종단 · 유토 다시 그림(design_shared) — 한 걸음에 한 번 const out = await m.refreshSavedCross( { projectId, routeId: R, detail, edits: EDITS, alignment }, stations, - (rows) => updated.push(...rows), + (rows) => (calls++, updated.push(...rows)), ); steps.push({ tag, out: out ? Object.keys(out.byKey).length : null, updated, + calls, ...pick(target.design), }); }; @@ -126,6 +128,9 @@ async function toggles(projectId, shift) { m.writeCrossDesignChoice(projectId, R, target.chainage_m, { ...choice }); await run(tag); } + // 사면 이동 한 걸음(B06 ◀▶ 0.1 m · 깎기 쪽 · 441 · 요구 268-4) + m.writeShoulderExtend(projectId, R, target.chainage_m, job.mode.split("_")[0], 0.1); + await run("slope"); return { target: target.chainage_m, steps }; } diff --git a/resources/tester/helper_266_card_toggle_entry.ts b/resources/tester/helper_266_card_toggle_entry.ts index 4ec8245d0..bc2b48863 100644 --- a/resources/tester/helper_266_card_toggle_entry.ts +++ b/resources/tester/helper_266_card_toggle_entry.ts @@ -1,5 +1,8 @@ /* PLAN 439 시험 묶음 진입점 — 저장 노선 cross 차례(카드 단추 → 다시 셈 · 측점 어긋남) · 진짜 모듈 · vite ssr. */ export { refreshSavedCross } from "../../A00_Common/design/design_shared"; -export { writeCrossDesignChoice } from "../../B06_Section/B06_Section_Cross_Design_Session"; +export { + writeCrossDesignChoice, + writeShoulderExtend, +} from "../../B06_Section/B06_Section_Cross_Design_Session"; export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel"; export { writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/helper_344_3_pipeline_stations.cjs b/resources/tester/helper_344_3_pipeline_stations.cjs index 45d4a74a5..b7508c923 100644 --- a/resources/tester/helper_344_3_pipeline_stations.cjs +++ b/resources/tester/helper_344_3_pipeline_stations.cjs @@ -78,4 +78,24 @@ out.move = { same: after.map((s) => byKey.get(s.key) === s), empty: run(null, null, null, null), }; +// 441 — 초안 없는 저장 노선 = 저장 스냅 설정 따름 · 초안이 있으면 초안 값(스냅 case = cases[1]) +const snapCase = job.cases[1]; +const savedRun = (route, saved) => + stage + .createStationsStage({ types: () => job.types, saved: () => saved })({ + generation: 1, + range: null, + signal: new AbortController().signal, + inputs: { route, pipes: job.pipes, structures: job.structures }, + out: { route: { line: snapCase.line, curves: snapCase.curves, chainage: [], lengthM: 0 } }, + before: {}, + prev: null, + }) + .map((s) => s.chainageM); +out.savedSnap = { + saved: savedRun(null, { station_snap: true, station_snap_step: null }), + savedOff: savedRun(null, { station_snap: false }), + noPlan: savedRun(null, null), + draftWins: savedRun({ stationSnap: false }, { station_snap: true }), +}; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_390_5_snap_step_ops.cjs b/resources/tester/helper_390_5_snap_step_ops.cjs index bc613af8c..da8e57ca5 100644 --- a/resources/tester/helper_390_5_snap_step_ops.cjs +++ b/resources/tester/helper_390_5_snap_step_ops.cjs @@ -128,6 +128,82 @@ const drag = Object.fromEntries( }), ); +// ④ 끌기 훑기(441) — 편집 창 끌기 차례(`_Pointer`) 그대로: 하한 막힘 · 미리보기 측점 밖이 누를 때보다 늘면 +// 그 걸음 되돌림 · 놓을 때 정본 맞춤(`applyEdit`). 측점 No. · IP 셋 × 12 방향 × 3 거리 · 고정 R(곡선표 반경 못박음). +const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts")); +const copy = (c) => ({ + ...c, + planned: c.planned.map(([x, y]) => [x, y]), + curveRadius: [...c.curveRadius], +}); +function sweep(fixedR) { + const start = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1)); + if (fixedR) + start.curveRadius = edits + .rebuildLine(copy(start), LIMITS) + .nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100)); + const floor = (built) => + edits.curveShortfalls(built.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM); + const off = (built) => fit.offStationCount(built.vertices, built.curves, INTERVAL); + const tally = { drags: 0, released: 0, offAfter: 0, strayed: 0, worst: [] }; + for (const K of [2, 3, 4]) + for (let deg = 0; deg < 360; deg += 30) + for (const reach of [9, 17, 26]) { + const hist = copy(start); + let st = copy(hist); + let built = edits.rebuildLine(st, LIMITS); + const offBefore = off(built); + let raw = null; + let movedOnce = false; + const preview = () => { + raw = st.planned.map(([x, y]) => [x, y]); + st = { ...st, planned: fit.fitDrag(st, hist.planned, K, LIMITS) }; + built = edits.rebuildLine(st, LIMITS); + }; + const p0 = hist.planned[K]; + for (let t = 0.5; t <= reach + 1e-9; t += 0.5) { + if (raw) raw.forEach((v, i) => (st.planned[i] = v)); + built = edits.rebuildLine(st, LIMITS); + const before = floor(built); + const previous = st.planned[K]; + st.planned[K] = [ + p0[0] + Math.cos((deg * Math.PI) / 180) * t, + p0[1] + Math.sin((deg * Math.PI) / 180) * t, + ]; + built = edits.rebuildLine(st, LIMITS); + if (edits.shortfallCrossed(before, floor(built))) { + st.planned[K] = previous; + preview(); + continue; + } + preview(); + if (off(built) > offBefore) { + raw.forEach((v, i) => (st.planned[i] = v)); + st.planned[K] = previous; + preview(); + tally.strayed += 1; + continue; + } + movedOnce = true; + } + tally.drags += 1; + if (!movedOnce) continue; + built = edits.rebuildLine(st, LIMITS); + if (edits.floorStop(hist, st.planned, built.nodes, LIMITS)) continue; + const from = fit.fitStart(hist, st); + st = { ...st, planned: fit.fitCurveStations(st, LIMITS, from) }; + built = edits.rebuildLine(st, LIMITS); + tally.released += 1; + const left = fit.offStationMcs(built.vertices, built.curves, INTERVAL); + if (left.length) { + tally.offAfter += 1; + if (tally.worst.length < 5) tally.worst.push({ K, deg, reach, mc: left }); + } + } + return tally; +} +const swept = { auto: sweep(false), fixed: sweep(true) }; + // ③ MC 측점 값 · 눈금 글 const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}'); const snapped = Object.fromEntries( @@ -147,5 +223,12 @@ const labels = { }; console.log( - JSON.stringify({ keys, drag, snapped, labels, scale: [0.1, 1, 10, null].map(frame.mcScale) }), + JSON.stringify({ + keys, + drag, + swept, + snapped, + labels, + scale: [0.1, 1, 10, null].map(frame.mcScale), + }), ); diff --git a/resources/tester/test_266_card_toggle_mismatch.py b/resources/tester/test_266_card_toggle_mismatch.py index 6d2d73835..30e03293d 100644 --- a/resources/tester/test_266_card_toggle_mismatch.py +++ b/resources/tester/test_266_card_toggle_mismatch.py @@ -4,6 +4,7 @@ - 저장 노선 cross 차례(`refreshSavedCross`) = 카드 자료(상세 설계)를 **늘** 다시 셈 — 복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜. - 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침. + - 사면 이동 한 걸음도 같음 · 걸음마다 받는 쪽(카드 · 종단 · 유토 다시 그림) 한 번(441 · 요구 268-2 · 4). - 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음. 짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` · 지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`. @@ -99,7 +100,8 @@ def _check_toggles(case: dict, returned) -> None: steps = {step["tag"]: step for step in case["steps"]} assert steps["open"]["cut"] > 0.5 # 깎기 측점 before = steps["open"] - for tag in ("two_stage", "ditch_off", "soil"): + assert all(step["calls"] == 1 for step in case["steps"]) # 걸음마다 다시 그림 한 번(441) + for tag in ("two_stage", "ditch_off", "soil", "slope"): step = steps[tag] assert case["target"] in step["updated"], tag # 카드 다시 그림 대상 assert step["line"] != before["line"], tag # 카드 설계선 바뀜 diff --git a/resources/tester/test_344_3_pipeline_stations_mirror.py b/resources/tester/test_344_3_pipeline_stations_mirror.py index 196fcfe58..3c634451d 100644 --- a/resources/tester/test_344_3_pipeline_stations_mirror.py +++ b/resources/tester/test_344_3_pipeline_stations_mirror.py @@ -264,6 +264,17 @@ def test_스냅_측점은_원래_격자_번호(r): assert moved and all(abs(a - b) <= 10 for a, b in moved.values()) +def test_초안_없으면_저장_스냅_따름(r): + """441 — 초안 없는 저장 노선 측점 단계 = 저장 station_snap · 초안이 있으면 초안 값.""" + snapped = [s["chainageM"] for s in r["cases"][1]] + plain = [s["chainageM"] for s in r["cases"][0]] + assert snapped != plain + got = r["savedSnap"] + assert got["saved"] == snapped + assert got["savedOff"] == plain and got["noPlan"] == plain + assert got["draftWins"] == plain + + def test_노선_한_점_옮김_앞_구간_열쇠_그대로(r): m = r["move"] from_m = _first_changed_m() diff --git a/resources/tester/test_390_5_snap_step_ops.py b/resources/tester/test_390_5_snap_step_ops.py index 3b7c3e9fe..e1813cbbe 100644 --- a/resources/tester/test_390_5_snap_step_ops.py +++ b/resources/tester/test_390_5_snap_step_ops.py @@ -80,7 +80,17 @@ def test_끄는_동안_MC_가_간격_배수(got, step) -> None: drag = got["drag"][step] assert drag["off"] == 0 assert max(drag["residues"]) <= drag["tol"] - assert drag["dragMoved"] <= float(step) / 2 + 1e-6 # 끈 IP 는 접선 따라 ±간격/2 안만 + assert drag["dragMoved"] <= float(step) + 1e-6 # 끈 IP 는 접선 따라 ±간격 안만(441) + + +@pytest.mark.parametrize("mode", ["auto", "fixed"]) +def test_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None: + """441 — 측점 No. 끌기(IP 셋 × 12 방향 × 3 거리) · 놓은 뒤 MC 가 다 측점 위 · 맞춤이 늘 닿음 + (반 간격 한도면 18+9 ~ 19+1 에서 못 맞춰 막힘 걸음이 생김).""" + swept = got["swept"][mode] + assert swept["drags"] == 108 and swept["released"] > 0 + assert swept["offAfter"] == 0, swept["worst"] + assert swept["strayed"] == 0 @pytest.mark.parametrize("step", ["0.1", "1", "10", "20"]) @@ -101,6 +111,12 @@ def test_끌기_방향키_편집기_이음() -> None: # 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림) assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex))" in pointer assert "now.raw.forEach((vertex, index) => (state.planned[index] = vertex))" in keys + # 미리보기 측점 밖이 누를 때보다 늘면 그 걸음 되돌림 · 막힘 검사 뒤에 옮김 표시(441 · 433-14) + assert "params.offStation() > dragOff" in pointer and "params.offStation() > now.off" in keys + move = pointer[pointer.index("const previous = state.planned[dragNode];") :] + assert move.index("shortfallCrossed(") < move.index("params.offStation() > dragOff") + assert move.index("params.offStation() > dragOff") < move.index("dragMoved = true;") + assert "offStation: () => (stationSnap ? fit.offStationCount(" in edit assert edit.count("\n") <= 695 @@ -193,6 +209,9 @@ def test_서버_측점_셈이_저장_간격을_씀() -> None: page = (design / "design_pipeline_page.ts").read_text(encoding="utf-8") assert "stationSnapStep: routePlan.station_snap_step" in page stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8") - assert "stationSnapStep: ctx.inputs.route?.stationSnapStep" in stage + assert "...snapOptions(ctx.inputs.route, saved?.())" in stage # 초안 · 없으면 저장 노선(441) + assert "stationSnapStep: draft.stationSnapStep" in stage + assert "stationSnapStep: saved?.station_snap_step" in stage + assert "saved: () => plan" in page and "...snapOptions(inputs.route, plan)" in page apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No. From 341e83114e89854396c74d62dd0a3ecde52bb0ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:42:21 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(446=20B06=20=ED=91=9C=EB=B3=B8=20?= =?UTF-8?q?=EB=84=93=ED=9E=98):=20=EC=B9=B4=EB=93=9C=20=ED=8F=AD=20?= =?UTF-8?q?=EB=84=98=EA=B9=80=20=EB=8B=A4=EC=8B=9C=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=EA=B0=80=20=EC=B8=A1=EC=A0=90=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=C2=B7=20=ED=91=9C=EC=A4=80=20=ED=9A=A1=EB=8B=A8=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EC=9C=A0=ED=86=A0=20=ED=92=8D=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EA=B3=A0=20=ED=8E=B8=EC=A7=91=20=EC=98=AE?= =?UTF-8?q?=EA=B2=A8=20=EB=8B=B4=EC=9D=8C=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=A0=84=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=EC=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 반폭 재생성 = 옛 횡단 행 data · 상태를 측점 열쇠(0.01 m)로 새 행에 · 옛 종단 data 사용자 키(표준 횡단면 · 유토 · 계획 입력 · 상태) — 같은 트랜잭션 - 계획고 편집 = 옛 계획선 통째(_carry_profile 같은 노선 갈래) · 실패면 종 · 횡단 파일 되돌림 - 넓힌 지반 위 [저장]과 같은 서버 재계산 - 화면 = 다시 만들기 전 확인창 · 취소면 재생성 없음 · 오버레이 셈 짝 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B06_Section/B06_Section_Router.py | 10 +- B06_Section/B06_Section_Router_Widen.py | 150 ++++++++++++++++++ B06_Section/B06_Section_UI_Page_Common.ts | 20 ++- resources/tester/helper_173_6_half_width.cjs | 1 + resources/tester/helper_446_widen_confirm.cjs | 57 +++++++ resources/tester/test_446_widen_carry.py | 106 +++++++++++++ ui_template/ui_template_locale_b3.ts | 6 + 7 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 B06_Section/B06_Section_Router_Widen.py create mode 100644 resources/tester/helper_446_widen_confirm.cjs create mode 100644 resources/tester/test_446_widen_carry.py diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 098660552..4ee4c3fcf 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -4,6 +4,7 @@ import asyncio import hashlib import json import logging +from collections.abc import Awaitable, Callable from pathlib import Path from typing import Any from uuid import UUID @@ -401,8 +402,10 @@ async def get_section_detail( async def regenerate_sections( project_id: UUID, route_id: int, request: SectionRegenerateRequest ) -> SectionDetailResponse | JSONResponse: - """표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다.""" - return await regenerate_route_sections(project_id, route_id, request.cross_half_width_m) + """표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장 · 상세 반환(사용자 값 유지 · 446).""" + from B06_Section.B06_Section_Router_Widen import widen_route_sections + + return await widen_route_sections(project_id, route_id, request.cross_half_width_m) async def regenerate_route_sections( @@ -411,6 +414,7 @@ async def regenerate_route_sections( cross_half_width_m: float, *, stored_options: dict[str, Any] | None = None, + carry: Callable[[aiomysql.Connection], Awaitable[None]] | None = None, ) -> SectionDetailResponse | JSONResponse: """`regenerate_sections` 본체 — `stored_options` 를 주면 DB 최신 종단 옵션 대신 그것을 쓴다. @@ -472,6 +476,8 @@ async def regenerate_route_sections( route_id=route_id, sections=sections["cross_sections"], ) + if carry: # 옛 사용자 값을 새 행에 — 같은 트랜잭션(446) + await carry(connection) await connection.commit() except Exception: await connection.rollback() diff --git a/B06_Section/B06_Section_Router_Widen.py b/B06_Section/B06_Section_Router_Widen.py new file mode 100644 index 000000000..1cfc01221 --- /dev/null +++ b/B06_Section/B06_Section_Router_Widen.py @@ -0,0 +1,150 @@ +"""B06 카드 폭 넘김 다시 만들기(446) — 지반 표본을 넓혀 종 · 횡단을 다시 만들되 사용자 값은 그대로. + +`regenerate_route_sections` 는 종 · 횡단 행을 지우고 새로 만든다. 같은 노선 · 같은 측점이므로 +측점 열쇠(누가거리 0.01 m 안)로 옛 횡단 행의 설계(지반 · 메모 · CAD · 상세 표 · 조절) · 상태를, +옛 종단 행의 사용자 키(표준 횡단면 · 유토 풍선 · 계획 입력 · 상태)를 같은 트랜잭션에서 새 행에 +옮겨 담고, 계획고 편집(종단 정본 `profile_alignment`)을 옛 계획선 통째로 되살린 뒤 [저장]과 같은 +서버 재계산으로 넓힌 지반 위에 다시 셈. 뒤 단계가 실패하면 종 · 횡단 파일을 옛것으로 되돌림. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B06_Section.B06_Section_Repository import get_longitudinal_section +from B06_Section.B06_Section_Schema import SectionDetailResponse +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + +_MATCH_M = 0.01 # 측점 열쇠 — 저장 길(`update_cross_section_design`)과 같은 근사 + + +def carried_longitudinal_data(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]: + """새 종단 data — 생성 요약 · 옵션은 새것 · 나머지(표준 횡단면 · 유토 · 계획 입력)는 옛것.""" + merged = {**old, **new, "options": {**(old.get("options") or {}), **(new.get("options") or {})}} + if "grade_overrides" in old: # 사용자 계획 입력 — 다시 만들기는 늘 빈 값으로 냄 + merged["grade_overrides"] = old["grade_overrides"] + return merged + + +def carried_cross_rows( + old_rows: list[dict[str, Any]], new_rows: list[dict[str, Any]] +) -> tuple[list[tuple[str, str, int]], int]: + """(새 행 UPDATE 값 [(data, status, id)], 못 옮긴 옛 행 수) — 새 요약 위에 옛 data · 상태.""" + updates: list[tuple[str, str, int]] = [] + used: set[int] = set() + for row in new_rows: + at = float(row["chainage_m"]) + near = [ + index + for index, old in enumerate(old_rows) + if abs(float(old["chainage_m"]) - at) < _MATCH_M + ] + if not near: + continue + index = max(near, key=lambda i: int(old_rows[i]["id"])) # 같은 측점 둘이면 최신 행 + used.update(near) + old = old_rows[index] + data = {**_as_dict(old.get("data")), **_as_dict(row.get("data"))} + updates.append((json.dumps(data, ensure_ascii=False), str(old["status"]), int(row["id"]))) + return updates, len(old_rows) - len(used) + + +def _as_dict(value: Any) -> dict[str, Any]: + if isinstance(value, (str, bytes)): + value = json.loads(value) + return value if isinstance(value, dict) else {} + + +async def _cross_rows(connection: aiomysql.Connection, route_id: int) -> list[dict[str, Any]]: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT id, chainage_m, data, status FROM cross_sections" + " WHERE route_id = %s ORDER BY id", + (route_id,), + ) + return list(await cursor.fetchall()) + + +async def widen_route_sections( + project_id: UUID, route_id: int, cross_half_width_m: float +) -> SectionDetailResponse | JSONResponse: + """표본 폭 넘김 다시 만들기 — 사용자 값을 옮겨 담은 뒤 새 상세를 돌려줌.""" + from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR + from B05_Profile.B05_Profile_Route_Save import _Backup, _carry_profile, _read_json + from B06_Section.B06_Section_Router import get_section_detail, regenerate_route_sections + from B06_Section.B06_Section_Router_Confirm import _recompute_stored_designs + + pool = get_db_pool() + async with pool.acquire() as connection: + old_long = await get_longitudinal_section(connection, project_id, route_id) + old_rows = await _cross_rows(connection, route_id) + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + relative = str((old_long or {}).get("longitudinal_file_path") or "") + old_alignment = (_read_json(project_root / relative) if relative else {}).get( + "profile_alignment" + ) or {} + stage_dir = project_root / _STAGE_SUBDIR + backup = _Backup(project_root, [], [stage_dir / "longitudinal", stage_dir / "cross_sections"]) + dropped = 0 + + async def carry(connection: aiomysql.Connection) -> None: + nonlocal dropped + updates, dropped = carried_cross_rows(old_rows, await _cross_rows(connection, route_id)) + async with connection.cursor() as cursor: + if updates: + await cursor.executemany( + "UPDATE cross_sections SET data = %s, status = %s WHERE id = %s", updates + ) + if old_long: + await cursor.execute( + "SELECT id, data FROM longitudinal_sections WHERE route_id = %s" + " ORDER BY id DESC LIMIT 1", + (route_id,), + ) + row = await cursor.fetchone() + data = carried_longitudinal_data(old_long.get("data") or {}, _as_dict(row[1])) + await cursor.execute( + "UPDATE longitudinal_sections SET data = %s, status = %s WHERE id = %s", + (json.dumps(data, ensure_ascii=False), old_long["status"], int(row[0])), + ) + + try: + made = await regenerate_route_sections( + project_id, route_id, cross_half_width_m, carry=carry + ) + if isinstance(made, JSONResponse): + await asyncio.to_thread(backup.restore) + return made + try: # 같은 노선 — `_carry_profile` 의 옛 계획선 통째 갈래(빈 정점열 = 바뀐 구간 없음) + async with pool.acquire() as connection: + new_long = await get_longitudinal_section(connection, project_id, route_id) + new_relative = str((new_long or {}).get("longitudinal_file_path") or "") + await _carry_profile(project_root, new_relative, route_id, old_alignment, [], []) + except Exception as exc: # noqa: BLE001 — 계획고 편집을 잃느니 파일을 옛것으로 + logger.exception("B06 표본 넓힘: 계획고 옮김 실패 project_id=%s", project_id) + await asyncio.to_thread(backup.restore) + return JSONResponse( + status_code=500, + content={"status": "error", "message": f"계획고 편집 옮김 실패: {exc}"}, + ) + finally: + backup.drop() + if dropped: + logger.warning( + "B06 표본 넓힘: 새 측점에 없는 옛 횡단 행 %d건 project_id=%s", dropped, project_id + ) + await _recompute_stored_designs(project_id, route_id) # [저장]과 같은 서버 재계산(실패 = 로그) + return await get_section_detail(project_id, route_id) diff --git a/B06_Section/B06_Section_UI_Page_Common.ts b/B06_Section/B06_Section_UI_Page_Common.ts index 5f4f7f01b..4356743bd 100644 --- a/B06_Section/B06_Section_UI_Page_Common.ts +++ b/B06_Section/B06_Section_UI_Page_Common.ts @@ -1,5 +1,10 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; import { regenerateSections, type SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { loadSectionDetail } from "./B06_Section_Section_Store"; @@ -21,7 +26,8 @@ export interface SampleWidenerDeps { * 요청 반폭까지 지반 샘플을 넓히는 함수를 만든다 — 보유 샘플로 충분하면 즉시 true * (재계산 없음). 모자라면 그 폭으로 B05부터 재생성(영구 저장)하고 상세를 다시 받는다. * 느려도 허용(2026-08-06 사용자 확정). 전역 [전체 측점 반영]과 카드 줌 버튼의 반폭 - * 확대가 같은 경로를 쓴다(2026-08-23 사용자 지시). + * 확대가 같은 경로를 쓴다(2026-08-23 사용자 지시). 다시 만들기 전 확인창 · 서버가 측점 설계 · + * 계획고 편집 · 유토 풍선을 옮겨 담음(446). */ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) => Promise { let running = false; @@ -31,8 +37,14 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) = if (widthM <= deps.sampledHalfWidth() + 1e-6) return true; if (running) return false; // 재생성 중 중복 요청은 무시(버튼 연타). running = true; - showLoadingOverlay(); + let shown = false; // 오버레이는 셈 수 — 취소면 안 내림(남의 오버레이까지 내려감) try { + const ask = L("B06_Profile_Regenerate_Confirm") + .replace("{width}", String(widthM)) + .replace("{sampled}", String(deps.sampledHalfWidth())); + if (!(await showConfirmDialog(ask, L("B06_Profile_Regenerate_Go")))) return false; + showLoadingOverlay(); + shown = true; await regenerateSections(target.projectId, target.routeId, widthM); // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도 // 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4) @@ -46,7 +58,7 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) = return false; } finally { running = false; - hideLoadingOverlay(); + if (shown) hideLoadingOverlay(); } }; } diff --git a/resources/tester/helper_173_6_half_width.cjs b/resources/tester/helper_173_6_half_width.cjs index bec0471b2..804d2f0bf 100644 --- a/resources/tester/helper_173_6_half_width.cjs +++ b/resources/tester/helper_173_6_half_width.cjs @@ -17,6 +17,7 @@ const STUBS = { showLoadingOverlay: () => calls.overlay++, hideLoadingOverlay: () => {}, showToast: () => {}, + showConfirmDialog: async () => true, // 다시 만들기 확인창(446) — 이 시험은 늘 확인 }, "./B06_Section_Api_Fetch": { regenerateSections: async (_p, _r, width) => calls.regenerate.push(width), diff --git a/resources/tester/helper_446_widen_confirm.cjs b/resources/tester/helper_446_widen_confirm.cjs new file mode 100644 index 000000000..9da187f60 --- /dev/null +++ b/resources/tester/helper_446_widen_confirm.cjs @@ -0,0 +1,57 @@ +/* 카드 폭 넘김 다시 만들기 확인창(PLAN 446) 단위검증 헬퍼 — `createSampleWidener` 를 트랜스파일해 돌린다. + * 취소 = 재생성 없음 · 오버레이 셈 그대로 · 확인 = 재생성 한 번 · 오버레이 올림 · 내림 짝. 결과는 JSON 한 벌. + * 짝 = `test_446_widen_carry.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 calls = { regenerate: [], overlay: 0, asked: [] }; +let answer = false; +const STUBS = { + "@ui/ui_template_locale": { + currentLanguageIndex: 0, + ui_locales: new Proxy({}, { get: (_t, key) => [`${String(key)} {width}/{sampled}`] }), + }, + "@ui/ui_template_elements": { + showLoadingOverlay: () => calls.overlay++, + hideLoadingOverlay: () => calls.overlay--, + showToast: () => {}, + showConfirmDialog: async (message) => { + calls.asked.push(message); + return answer; + }, + }, + "./B06_Section_Api_Fetch": { + regenerateSections: async (_p, _r, width) => calls.regenerate.push(width), + }, + "./B06_Section_Section_Store": { loadSectionDetail: async () => ({ cross_sections: [] }) }, +}; + +const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Common.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (r) => STUBS[r]); + +(async () => { + const widen = box.exports.createSampleWidener({ + target: () => ({ projectId: "p", routeId: 1 }), + sampledHalfWidth: () => 20, + applyDetail: () => {}, + }); + const out = {}; + out.within = await widen(15); // 표본 안 = 묻지 않음 + out.withinAsked = calls.asked.length; + out.cancel = await widen(21); + out.cancelCalls = calls.regenerate.length; + out.cancelOverlay = calls.overlay; + answer = true; + out.ok = await widen(21); + out.okCalls = [...calls.regenerate]; + out.okOverlay = calls.overlay; + out.asked = calls.asked; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_446_widen_carry.py b/resources/tester/test_446_widen_carry.py new file mode 100644 index 000000000..3e4c37821 --- /dev/null +++ b/resources/tester/test_446_widen_carry.py @@ -0,0 +1,106 @@ +"""446 카드 폭 넘김 다시 만들기 — 표본 폭을 넘겨 종 · 횡단을 다시 만들어도 사용자 값 그대로. + +- 횡단 행: 측점 열쇠(0.01 m 안)로 옛 data(설계 · 메모 · CAD · 상세 표 · 조절) · 상태 · 새 요약 이김 +- 종단 행: 생성 요약 · 생성 옵션은 새것 · 표준 횡단면 · 유토(풍선) · 계획 입력은 옛것 +- 화면: 다시 만들기 전 확인창 · 취소 = 재생성 없음(`helper_446_widen_confirm.cjs`) +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Router_Widen import carried_cross_rows, carried_longitudinal_data + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] + +DESIGN = { + "ground_type": "soil", + "section_mode": "right_cut", + "structure_note": "메모", + "structure_note_cad": {"lines": [1]}, + "detail_table": {"rows": 2}, + "display_half_width_m": 21, + "basin_adjust": {"d": 0.3}, +} + + +def _old(row_id: int, at: float, design: dict | None = None, status: str = "CONFIRMED") -> dict: + data = {"sample_count": 41, "center_z": 100.0, "design": design or DESIGN} + return {"id": row_id, "chainage_m": at, "data": json.dumps(data), "status": status} + + +def _new(row_id: int, at: float) -> dict: + return {"id": row_id, "chainage_m": at, "data": json.dumps({"sample_count": 43})} + + +def test_횡단_행은_측점_열쇠로_설계_상태_그대로() -> None: + old = [_old(1, 0.0), _old(2, 20.0), _old(3, 33.456)] + new = [_new(11, 0.0), _new(12, 20.0), _new(13, 33.46), _new(14, 40.0)] + updates, dropped = carried_cross_rows(old, new) + assert dropped == 0 + by_id = {row_id: (json.loads(data), status) for data, status, row_id in updates} + assert set(by_id) == {11, 12, 13} # 40 m = 새 측점 · 옛 값 없음 + data, status = by_id[13] # 0.004 m 어긋남도 같은 측점 + assert data["design"] == DESIGN and status == "CONFIRMED" + assert data["sample_count"] == 43 and data["center_z"] == 100.0 # 새 요약이 이김 · 옛 칸 남김 + + +def test_같은_측점_둘이면_최신_행_못_옮긴_행은_셈() -> None: + newer = {**DESIGN, "structure_note": "새 메모"} + old = [_old(1, 20.0), _old(5, 20.0, newer, "DRAFT"), _old(6, 55.0)] + updates, dropped = carried_cross_rows(old, [_new(12, 20.0)]) + data, status, _ = updates[0] + assert json.loads(data)["design"]["structure_note"] == "새 메모" and status == "DRAFT" + assert dropped == 1 # 55 m — 새 측점에 없음(로그로 알림) + + +def test_종단_data_는_사용자_키를_옛것으로() -> None: + old = { + "length_m": 500.0, + "options": {"cross_half_width_m": 20, "standard_cross_section": {"road_width_m": 3}}, + "grade_overrides": {"max_grade_pct": 12}, + "grade_summary": {"old": 1}, + "mass_haul": {"balloon_offsets": {"0": [3.0, -4.0]}}, + } + new = { + "length_m": 500.0, + "options": {"cross_half_width_m": 21, "station_interval_m": 20}, + "grade_overrides": {}, + "grade_summary": {"new": 1}, + } + merged = carried_longitudinal_data(old, new) + assert merged["options"] == { + "cross_half_width_m": 21, + "station_interval_m": 20, + "standard_cross_section": {"road_width_m": 3}, + } + assert merged["grade_overrides"] == {"max_grade_pct": 12} + assert merged["mass_haul"] == {"balloon_offsets": {"0": [3.0, -4.0]}} + assert merged["grade_summary"] == {"new": 1} # 계획고 옮김이 다시 씀 + + +def test_반폭_요청은_옮겨_담는_길로() -> None: + router = (ROOT / "B06_Section" / "B06_Section_Router.py").read_text(encoding="utf-8") + at = router.index("async def regenerate_sections(") + body = router[at : router.index("async def regenerate_route_sections(", at)] + assert "widen_route_sections(" in body and "regenerate_route_sections(" not in body + assert "await carry(connection)" in router # 같은 트랜잭션 안 + + +@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치") +def test_다시_만들기_전_확인창_취소면_재생성_없음() -> None: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_446_widen_confirm.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + out = json.loads(done.stdout) + assert out["within"] is True and out["withinAsked"] == 0 + assert out["cancel"] is False and out["cancelCalls"] == 0 and out["cancelOverlay"] == 0 + assert out["ok"] is True and out["okCalls"] == [21] and out["okOverlay"] == 0 + assert out["asked"][0].endswith("21/20") # 요청 폭 · 표본 폭이 글에 들어감 diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 2c4bb7c53..24e89a0f3 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -162,4 +162,10 @@ export const ui_locales_b3 = { "원가계산은 마스터를 쓰는 얼개로 다시 짓는 중입니다 — 표와 계산은 아직 없습니다.", "Cost estimation is being rebuilt on the master data — no tables or calculations yet.", ], + /* --- B06 카드 폭 넘김 다시 만들기(446) --- */ + B06_Profile_Regenerate_Confirm: [ + "카드 폭 {width} m 가 지반 표본 폭 {sampled} m 를 넘어 종·횡단을 다시 만듭니다. 측점 설계 · 계획고 편집 · 유토 풍선은 그대로 옮깁니다.", + "View width {width} m exceeds the sampled ground width {sampled} m — sections will be rebuilt. Station designs, grade edits and haul balloons are kept.", + ], + B06_Profile_Regenerate_Go: ["다시 만들기", "Rebuild"], } as const; From b7af85fa980f5e1553aaea0d2737471729330f64 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:54:13 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(443-2=20B05=20=EC=A0=80=EC=9E=A5):=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EB=B0=94=EA=BE=BC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=9D=98=20=EC=83=81=EB=8B=A8=EC=B8=A1=20=3D=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=97=B4=EC=87=A0=EB=A1=9C=20=EC=83=88=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=EC=97=90=20=EC=98=AE=EA=B2=A8=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=98=20=EC=95=9E=EC=97=90=20=EB=B3=B4=EB=83=84=20?= =?UTF-8?q?=C2=B7=20=EB=AA=BB=20=EB=A7=9E=EC=B6=98=20=EA=B2=83=EC=9D=80=20?= =?UTF-8?q?=EB=B9=BC=EA=B3=A0=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옮김 목록(ROUTE_CARRY_STATES)에 uphill 줄 — 노선 저장 · 측점 갈아끼움 때 새 측점으로 - 노선을 저장했으면 상단측 차례 건너뜀 → carryAfterRouteSave 가 옮긴 뒤 보냄(옛 누가거리로 엉뚱한 측점에 안 얹힘) - 새 노선에 같은 측점이 없으면 뺌 · 「상단측 N곳 … 뺐습니다」 알림 · 노선 밖 고침 수에도 - 시험 test_443_2_uphill_carry.py · 346-3 · 저장 차례 시험 기대값 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- A00_Common/design/design_save.ts | 2 + B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 11 ++- B06_Section/B06_Section_Station_Keys.ts | 1 + resources/tester/helper_346_3_route_save.cjs | 5 +- resources/tester/helper_443_2_entry.ts | 5 ++ .../tester/helper_443_2_uphill_carry.mjs | 61 +++++++++++++++ .../tester/test_346_3_route_save_screen.py | 1 + resources/tester/test_443_2_uphill_carry.py | 77 +++++++++++++++++++ resources/tester/test_design_store_save.py | 3 +- 9 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_443_2_entry.ts create mode 100644 resources/tester/helper_443_2_uphill_carry.mjs create mode 100644 resources/tester/test_443_2_uphill_carry.py diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index b5dd3183b..2edfd617b 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -220,6 +220,8 @@ export async function saveDesign( } continue; } + // 상단측은 옛 누가거리 열쇠 — 노선이 바뀌면 횡단 차례(`carryAfterRouteSave`)가 새 측점으로 옮긴 뒤 보냄(443-2) + if (step === "uphill" && routeSaved) continue; // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. const own = routeSaved ? undefined : ctx.steps?.[step]; const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 0a78128db..e1dedb512 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -11,7 +11,7 @@ * 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽). * ========================================================================== */ -import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; +import { clearDrafts, clearResults, readState } from "../A00_Common/b_page_state"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; import { useDesignStore } from "../A00_Common/design/design_store"; import { currentRoute, remountRoute } from "../A00_Common/router"; @@ -29,7 +29,7 @@ import { import { withCarriedEdits, withDraftAdjusts } from "../B06_Section/B06_Section_Section_Adjusts"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids"; -import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch"; +import { clearRouteLatestCache, flushUphillOverrides } from "./B05_Profile_Api_Fetch"; import { replanRoute, type RouteReplanResponse, @@ -180,9 +180,16 @@ export async function carryAfterRouteSave( // 옛 저장 관 자리에 남은 고침(초안으로 안 따라감) = 같은 관 id 의 새 자리 const movedAt = (chainageM: number): number | null => newAt(before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id); + const uphillCount = (): number => Object.keys(readState("uphill", projectId) ?? {}).length; + const uphillBefore = uphillCount(); const outside = carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true }) + carryChainageStates(projectId, fromRouteId, toRouteId, { keyAt: pipeAt, movedAt }); + // 상단측 = 새 측점으로 옮긴 것만 횡단 패치 앞에 보냄(443-2 · 옛 누가거리로 엉뚱한 측점에 안 얹힘) + const uphillDropped = uphillBefore - uphillCount(); + if (uphillDropped > 0) + showToast(`상단측 ${uphillDropped}곳 — 새 노선에 같은 측점이 없어 뺐습니다.`, "info"); + await flushUphillOverrides(projectId); // 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417) withDraftAdjusts(detail, projectId, toRouteId); withCarriedEdits(detail, projectId, toRouteId); diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index e63b1f823..3ca4309c7 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -68,6 +68,7 @@ export const ROUTE_CARRY_STATES = { "detail-table": ["detail_table"], shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"], bermdepth: ["berm_depth_left_m", "berm_depth_right_m"], + uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 저장 때 새 측점으로(443-2) } as const; /** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */ const STRUCTURE_STATES = new Set([ diff --git a/resources/tester/helper_346_3_route_save.cjs b/resources/tester/helper_346_3_route_save.cjs index 43d6c9a6c..f23f3cafe 100644 --- a/resources/tester/helper_346_3_route_save.cjs +++ b/resources/tester/helper_346_3_route_save.cjs @@ -61,7 +61,10 @@ const STUBS = { fetchDetailPipePoints: async () => (calls.push("pipes"), { pipe_points: [] }), }, "../A00_Common/design/design_pipe_ids": { pipeChainageOf: () => null }, - "./B05_Profile_Api_Fetch": { clearRouteLatestCache: () => calls.push("latest:clear") }, + "./B05_Profile_Api_Fetch": { + clearRouteLatestCache: () => calls.push("latest:clear"), + flushUphillOverrides: async () => calls.push("uphill"), // 옮긴 뒤 · 횡단 패치 앞(443-2) + }, "./B05_Profile_Api_Replan": { replanRoute: async (_p, body) => { bodies.push(JSON.parse(JSON.stringify(body))); diff --git a/resources/tester/helper_443_2_entry.ts b/resources/tester/helper_443_2_entry.ts new file mode 100644 index 000000000..80b1803b4 --- /dev/null +++ b/resources/tester/helper_443_2_entry.ts @@ -0,0 +1,5 @@ +/* 443-2 시험 묶음 진입점 — 노선 바뀐 [저장] 때 상단측(uphill)을 측점 열쇠로 새 측점에 옮김. + * Node 헬퍼 = helper_443_2_uphill_carry.mjs · 짝 = test_443_2_uphill_carry.py. + */ +export { carryChainageStates, useStationKeyList } from "../../B06_Section/B06_Section_Station_Keys"; +export { readState, writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/helper_443_2_uphill_carry.mjs b/resources/tester/helper_443_2_uphill_carry.mjs new file mode 100644 index 000000000..aa1c00f4f --- /dev/null +++ b/resources/tester/helper_443_2_uphill_carry.mjs @@ -0,0 +1,61 @@ +/* 443-2 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_443_2_entry.ts)을 부르고 결과를 JSON 한 벌로. + * node helper_443_2_uphill_carry.mjs <묶음.mjs> + */ +import { pathToFileURL } from "node:url"; + +const store = () => { + 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 = store(); +globalThis.localStorage = store(); +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); + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p443"; +const grid = (pairs) => pairs.map(([key, chainageM]) => ({ key, chainageM })); +// 옛 노선 = 20 m 일반측점 넷 · 새 노선 = 같은 번호가 밀림(21.5 · 43) · grid:3(60 m) 사라짐 +m.useStationKeyList( + P, + 7, + grid([ + ["end:BP", 0], + ["grid:1", 20], + ["grid:2", 40], + ["grid:3", 60], + ]), +); +m.useStationKeyList( + P, + 8, + grid([ + ["end:BP", 0], + ["grid:1", 21.5], + ["grid:2", 43], + ]), +); +m.writeState("uphill", { "20.000": "right", "40.000": "left", "60.000": "right" }, P); +const outside = m.carryChainageStates(P, 7, 8); +const moved = m.readState("uphill", P); +// 세션이 없으면 그대로(빈 칸 안 만듦) +m.writeState("uphill", null, P); +const outsideEmpty = m.carryChainageStates(P, 7, 8); +process.stdout.write( + JSON.stringify({ outside, moved, outsideEmpty, empty: m.readState("uphill", P) }), +); diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index ece8870a4..527e91883 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -82,6 +82,7 @@ def test_초안_넘김_차례(out): "pipes", # 관 열쇠 → 새 자리(서버가 옮긴 관) "carry:7->9:quiet", "carryAt:7->9", # 417 — 조절 · 측점 폭 · 상세 표도 새 노선 측점으로 + "uphill", # 443-2 — 상단측도 새 측점으로 옮긴 뒤 · 횡단 패치 앞 "adjusts:9", "edits:9", # 옮긴 고침을 새 상세 설계 칸에 → 같은 [저장] 횡단 패치 "crossSave:9", diff --git a/resources/tester/test_443_2_uphill_carry.py b/resources/tester/test_443_2_uphill_carry.py new file mode 100644 index 000000000..098311b45 --- /dev/null +++ b/resources/tester/test_443_2_uphill_carry.py @@ -0,0 +1,77 @@ +"""PLAN 443-2 — 노선 바꾼 [저장]의 상단측(uphill). + +- 옛 누가거리 열쇠 상단측 = 측점 열쇠(옮김 목록 `ROUTE_CARRY_STATES` · 415 · 417 규칙)로 새 노선 측점에. +- 새 노선에 같은 측점이 없으면 뺌 · 수를 돌려줌(「노선 밖 고침」 · 상단측 알림). +- 저장 한 줄기: 노선을 저장했으면 상단측 차례는 건너뛰고 `carryAfterRouteSave` 가 옮긴 뒤 · 횡단 패치 앞에 보냄. +Node 헬퍼 `helper_443_2_uphill_carry.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_443_2_entry.ts`)을 부름. + +./venv/Scripts/python.exe -m pytest resources/tester/test_443_2_uphill_carry.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b443_2") + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_443_2_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 = next(work.glob("*.js"), None) or next(work.glob("*.mjs")) + proc = subprocess.run( + ["node", str(HERE / "helper_443_2_uphill_carry.mjs"), str(bundle)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_상단측이_새_노선_같은_측점으로(out): + assert out["moved"] == {"21.50": "right", "43.00": "left"} + + +def test_못_맞춘_상단측은_빼고_수를_돌려줌(out): + assert out["outside"] == 1 + + +def test_세션_없으면_그대로(out): + assert out["outsideEmpty"] == 0 and out["empty"] is None + + +def test_노선_저장이면_상단측_차례는_옮긴_뒤(): + save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8") + assert 'if (step === "uphill" && routeSaved) continue;' in save + apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text("utf-8") + body = apply.split("export async function carryAfterRouteSave(")[1].split("\n}\n")[0] + carry = body.index("carryChainageStates(") + flush = body.index("await flushUphillOverrides(projectId)") + assert carry < flush < body.index("saveCachedCrossPatches(") # 옮긴 뒤 · 횡단 패치 앞 + assert "상단측 ${uphillDropped}곳" in body # 못 맞춤 알림 diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index ba80df0fa..900248884 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -102,8 +102,7 @@ def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out): "plusStations", 'alignment:9:{"30.000":0.2}', "culvert:aislo:draft:culvertopt:p1:7", - "uphill", - "carry:7->9", + "carry:7->9", # 상단측은 넘김(carryAfterRouteSave)이 새 측점으로 옮긴 뒤 보냄(443-2) "summary:9:2", "toast:info", "done:false", From a59259ef21b6dcbb5a210d6bc9eb1e171710624f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:54:30 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(444=20B06=20=EA=B4=80=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=EA=B0=92):=20=EC=84=B8=EC=9B=94=EA=B5=90=20=C2=B7=20B?= =?UTF-8?q?OX=20=ED=8F=BC=20=EA=B0=92=20=3D=20=EA=B4=80=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=ED=95=9C=20=EB=B2=8C=20=C2=B7=20=EC=98=9B=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EC=9D=80=20=EA=B4=80=20=EC=98=AE=EA=B9=80=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=EA=B0=90=20=C2=B7=20=EB=AA=BB=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=20=EA=B0=92=20=EB=B2=84=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세월교 · BOX 조정 제어가 폼 값을 조정 예약(culvertopt)에 또 담지 않음 — 관 초안(가게 pipes)이 관 id 로 옮김 · 노선 바뀜을 따라감(H4a) - 측점 갈아끼움 옮김 목록에 culvertopt 추가 — 옛 세션 잔값도 관 새 자리로 - 내보내기 주석 — 못 맞춘 값은 다른 관에 안 얹고 버림 - 시험 test_444_culvertopt_single_draft — 옮김 · 두 번째 [저장] 값 그대로(H4b) · 못 맞춘 값 버림 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- .../B06_Section_Api_Culvert_Options.ts | 2 + B06_Section/B06_Section_Station_Keys.ts | 3 +- .../B06_Section_UI_Page_Ford_Controls.ts | 10 +- .../B06_Section_UI_Page_Station_Controls.ts | 1 - resources/tester/helper_444_culvertopt.mjs | 145 ++++++++++++++++++ resources/tester/helper_444_entry.ts | 3 + .../test_444_culvertopt_single_draft.py | 88 +++++++++++ 7 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 resources/tester/helper_444_culvertopt.mjs create mode 100644 resources/tester/helper_444_entry.ts create mode 100644 resources/tester/test_444_culvertopt_single_draft.py diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 89bd7be8c..ac80fd1dd 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -67,6 +67,8 @@ function writePending(sessionKey: string | null, pending: PendingMap): void { * 세션에 쌓인 구간값을 `pipe_points.json`에 한 번에 쓴다. 예약분이 없으면 아무것도 * 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서 * 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다. + * 지금 관에 못 맞춘 값(옮긴 · 지운 관 옛 자리)은 버림 — 남기면 그 자리에 온 다른 관에 얹힘(444 · + * 세월교 · BOX 폼 값은 관 초안 한 벌로 나가므로 잃지 않음). * 옛 「관 이동 예약」(culvertmove)은 읽지 않음 — 관 위치는 B05·B06 이 같은 관 목록 스냅샷으로만 * 옮김. 옛 세션 잔값을 저장 때 적용하면 관이 이웃 측점으로 튀어 시설 속성이 넘어감. */ diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index e63b1f823..123cd4017 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -491,7 +491,8 @@ function moveIdleStates(moves: ReadonlyMap, owned: ReadonlySet) => Object.fromEntries(renameChainageKeys(new Map(Object.entries(cells)), moves)); - for (const state of Object.keys(ROUTE_CARRY_STATES)) { + // 관 구간값 예약(culvertopt · 설계 칸 아님 = 옮김 목록 짝 밖)도 관 새 자리로(444) + for (const state of [...Object.keys(ROUTE_CARRY_STATES), "culvertopt"]) { if ((KEYED_STATES as readonly string[]).includes(state)) continue; const name = state as StateName; const key = stateKey(name, projectId, routeId); diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 8b5c997b4..495de821e 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -4,8 +4,8 @@ * (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25). * * 데이터 흐름은 집수정과 같다: 세션 사본 + 캐시 `design.ford_adjust`에 함께 실어 - * 3D(코리도)가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본(`pipe_points`)이 - * 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다. + * 3D(코리도)가 같은 값으로 다시 그리게 한다. 관경·수량·날개벽은 폼이 관 초안(가게 pipes) + * 한 벌로 냄 — 여기는 캐시만 고침(444 · 조정 예약 culvertopt 에 또 담으면 옮긴 관 옛 자리에 남음). * ========================================================================== */ import { @@ -106,8 +106,6 @@ export interface FordControlDeps { sectionAt: (chainageM: number) => CrossSection | undefined; patchCachedDesign: (chainageM: number, patch: Partial) => void; refreshCard: (chainageM: number) => void; - /** 관경·수량·날개벽 제원을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */ - queuePipeOptions: (chainageM: number, patch: Record) => void; round1: (value: number) => number; clampMove: (value: number) => number; /** 같은 측점 벽 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1). */ @@ -203,7 +201,6 @@ export function createFordControls(deps: FordControlDeps): FordControls { spec.overflow_depth_m = Math.max(patch.ford_height_m, 0); } dropDefaulted(spec, patch); - deps.queuePipeOptions(chainageM, patch); deps.refreshCard(chainageM); }, setWing: (chainageM, role, patch) => { @@ -218,7 +215,6 @@ export function createFordControls(deps: FordControlDeps): FordControls { wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg); } dropDefaulted(spec, wingOptions(role, patch)); - deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); }, selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null, @@ -400,7 +396,6 @@ export function createBoxControls(deps: FordControlDeps): { if (patch.body_height_m) spec.inner_height_m = patch.body_height_m; } dropDefaulted(spec, patch); - deps.queuePipeOptions(chainageM, patch as Record); deps.refreshCard(chainageM); }, setWing: (chainageM, role, patch) => { @@ -416,7 +411,6 @@ export function createBoxControls(deps: FordControlDeps): { : 0; } dropDefaulted(spec, wingOptions(role, patch)); - deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); }, selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null, diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 7a6ebe3c7..53d7bc8d3 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -580,7 +580,6 @@ export function createStationControls(deps: StationControlDeps): StationControls sectionAt, patchCachedDesign, refreshCard: deps.refreshCard, - queuePipeOptions: culvertOptions.queue, round1, clampMove, releaseWall: (chainageM) => { diff --git a/resources/tester/helper_444_culvertopt.mjs b/resources/tester/helper_444_culvertopt.mjs new file mode 100644 index 000000000..271337537 --- /dev/null +++ b/resources/tester/helper_444_culvertopt.mjs @@ -0,0 +1,145 @@ +/* 444 헬퍼 — 관 구간값 예약(culvertopt)이 관 옮김을 따라가고 · 못 맞춘 값은 버리고 · 두 번째 [저장]이 안 덮음. + * node helper_444_culvertopt.mjs <묶음.mjs> + * 묶음 = 444 진입점(`helper_444_entry.ts` · 346-2 묶음 + 내보내기 · vite ssr 진짜 모듈) · 표준출력 JSON 한 벌. + * 짝 = test_444_culvertopt_single_draft.py · 틀 = helper_415_swap.mjs(415-3). + */ +import { pathToFileURL } from "node:url"; + +const store = () => { + 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 = store(); +globalThis.localStorage = store(); +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.__toasts = []; + +/* 관 정본(서버) — GET 은 지금 관 · PUT 은 받은 몸통으로 바꿈 */ +let saved = []; +const puts = []; +globalThis.fetch = async (url, init = {}) => { + if (String(url).includes("/drainage/pipe-points") && init.method === "PUT") { + const points = JSON.parse(init.body).points; + puts.push(points); + saved = points.map((point) => ({ ...point, lonlat: [0, 0] })); + } + return { ok: true, status: 200, json: async () => ({ pipe_points: saved, basins: [] }) }; +}; + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p444"; +const R = 3; +const out = {}; + +const section = (chainage_m, kind, extra = {}) => ({ + station_id: `station_${String(Math.round(chainage_m * 1000)).padStart(12, "0")}`, + chainage_m, + label: "", + kind, + center_z: 100, + azimuth_deg: 90, + center_x: chainage_m, + center_y: 0, + frame: { left_xy: [0, 1] }, + samples: [{ offset_m: 0, elevation_m: 100, valid: true }], + design: { design_elevation_m: 101 }, + ...extra, +}); +const detail = () => ({ + longitudinal: { + length_m: 100, + stations: [], + profile_alignment: { policy: { station_interval_m: 20 } }, + }, + cross_sections: [ + section(0, "bp"), + section(20, "regular"), + section(40, "regular"), + section(45.054, "irregular", { structure: "세월교", culvert: {} }), + section(60, "regular"), + section(80, "regular"), + section(100, "ep"), + ], +}); +const station = (key, chainageM, kind, id = null) => ({ + key, + chainageM, + x: chainageM, + y: 0, + azimuthDeg: 90, + left: [0, 1], + kind, + id, + ground: { centerZ: 99, left: [{ offsetM: 0.5, z: 99.1 }], right: [{ offsetM: 0.5, z: 98.9 }] }, + planRadiusM: null, + curveOuterSide: null, + curveWideningM: null, +}); +const list = (pipeAt) => + [ + station("end:BP", 0, "end"), + station("grid:1", 20, "grid"), + station("grid:2", 40, "grid"), + station("pipe:pipe-s1", pipeAt, "pipe", "pipe-s1"), + station("grid:3", 60, "grid"), + station("grid:4", 80, "grid"), + station("end:EP", 100, "end"), + ].sort((a, b) => a.chainageM - b.chainageM); +const key = `aislo:draft:culvertopt:${P}:${R}`; + +/* 1. 관 5 m 옮김 — 옛 세션 구간값 예약도 관 새 자리로(조정창 맵 없이 · 415-3 과 같은 길) */ +m.writeState("section-detail", detail(), P, R); +m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 45.054 }] }, P); +await m.loadSectionDetail(P, R); +m.writeState("culvertopt", { 45.05: { ford_width_m: 4 } }, P, R); +const stop = m.followSwaps( + P, + () => R, + { visible: () => true }, + async () => undefined, +); +m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 50 }] }, P); +m.useDesignStore(P, null).set("stations", list(50)); +await new Promise((resolve) => setTimeout(resolve, 30)); +out.moved = m.readState("culvertopt", P, R); +stop(); + +/* 2. [저장] 두 번 — 첫 번째는 관 새 자리에 얹고 세션 비움 · 두 번째는 안 보냄(값 그대로) */ +saved = [ + { id: "p5000", chainage_m: 50, source: "stream", facility: "ford", options: { ford_width_m: 3 } }, +]; +await m.flushCulvertOptions(P, key); +out.firstSave = { + puts: puts.length, + width: saved[0].options.ford_width_m, + left: sessionStorage.getItem(key), +}; +await m.flushCulvertOptions(P, key); +out.secondSave = { puts: puts.length, width: saved[0].options.ford_width_m }; + +/* 3. 못 맞춘 값(지운 · 옮긴 관 옛 자리) — 다른 관에 안 얹고 버림 */ +m.writeState("culvertopt", { "30.00": { ford_width_m: 9 } }, P, R); +await m.flushCulvertOptions(P, key); +out.unmatched = { + puts: puts.length, + width: saved[0].options.ford_width_m, + left: sessionStorage.getItem(key), +}; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_444_entry.ts b/resources/tester/helper_444_entry.ts new file mode 100644 index 000000000..f943accb4 --- /dev/null +++ b/resources/tester/helper_444_entry.ts @@ -0,0 +1,3 @@ +/* 444 시험 묶음 진입점 — 346-2 갈아끼움 묶음 + 관 구간값 예약 내보내기(진짜 모듈 · vite ssr). */ +export * from "./helper_346_2_swap_entry"; +export { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; diff --git a/resources/tester/test_444_culvertopt_single_draft.py b/resources/tester/test_444_culvertopt_single_draft.py new file mode 100644 index 000000000..778c1230e --- /dev/null +++ b/resources/tester/test_444_culvertopt_single_draft.py @@ -0,0 +1,88 @@ +"""444 관 구간값(H4a · H4b) — 세월교 · BOX 폼 값은 관 초안 한 벌 · 옛 예약은 관을 따라가고 못 맞추면 버림. + +- 폼이 낸 세월교 · BOX 값(관경 · 수량 · 월류 폭 · 날개벽)은 관 초안(가게 pipes)으로만 나감 — 조정 예약 + (culvertopt)에 또 담지 않음(test_b06_adjust_single_form 규칙 · 관 id 로 옮김 · 노선 바뀜을 따라감). +- 옛 세션 예약(culvertopt)은 관 옮김(측점 갈아끼움)을 따라 새 자리로 · [저장] 때 못 맞춘 값은 버림(지운 관 + 자리에 온 다른 관에 안 얹힘) · 두 번째 [저장]은 다시 안 보냄(값 그대로). + +짝: `helper_444_culvertopt.mjs`(444 진입점 묶음 · 진짜 상세 가게 · 내보내기). +""" + +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(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import test_365_page_pipeline as t365 # noqa: E402 + +pytestmark = t365.pytestmark + + +@pytest.fixture(scope="module") +def out(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("b444") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_444_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_444_entry.js", bundle) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_444_culvertopt.mjs"), str(bundle)], + 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_세월교_BOX_폼_값은_조정_예약에_안_쌓음() -> None: + ford = (ROOT / "B06_Section" / "B06_Section_UI_Page_Ford_Controls.ts").read_text( + encoding="utf-8" + ) + controls = (ROOT / "B06_Section" / "B06_Section_UI_Page_Station_Controls.ts").read_text( + encoding="utf-8" + ) + assert "queuePipeOptions" not in ford + assert "queuePipeOptions" not in controls + + +def test_관_옮김_예약도_새_자리로(out): + assert out["moved"] == {"50.00": {"ford_width_m": 4}} + + +def test_두_번째_저장은_값_그대로(out): + assert out["firstSave"] == {"puts": 1, "width": 4, "left": None} + assert out["secondSave"] == {"puts": 1, "width": 4} + + +def test_못_맞춘_값은_다른_관에_안_얹고_버림(out): + assert out["unmatched"] == {"puts": 1, "width": 4, "left": None}