Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-28 22:03:38 +09:00
10 changed files with 453 additions and 20 deletions
@@ -0,0 +1,144 @@
/* 498-2 노선 바뀐 [저장] 계획고 손댐 — 진짜 TS(`logic/B05_Profile_Pipeline_Grade` follow · `gradeTouch`)를
* Node 로 돌림. 표준출력 JSON 한 벌.
*/
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file, stubs = {}) {
return makeLoader({
root: ROOT,
cache: false,
requireFor: (full) => (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request} (${file})`);
},
}).loadTs(path.join(ROOT, file));
}
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const curveLaw = loadTs("B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
"./B05_Profile_Profile_CurveLaw": curveLaw,
});
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
});
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", {
"./B05_Profile_Profile_CurveLaw": curveLaw,
"../../common_util/common_util_route_follow": {},
"../../common_util/common_util_structure_station": {
PLUS_OWNER_PREFIX: "plus-",
SAME_STATION_M: 0.005,
},
"./B05_Profile_Grade_Seat": loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
}),
"./B05_Profile_UI_Profile_Alignment": alignment,
"./B05_Profile_Grade_Carry": carry,
"./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 },
"./B05_Profile_Grade_Ground": loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
"./B05_Profile_Profile_CurveLaw": curveLaw,
"./B05_Profile_UI_Profile_Alignment": alignment,
}),
});
const routeOf = (line) => {
const chainage = [0];
for (let i = 1; i < line.length; i += 1)
chainage.push(
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
);
return { line, curves: [], chainage, lengthM: chainage[chainage.length - 1] };
};
/** 10 m 격자 + 끝 — 묶음 stations 단계 자리. */
const gridOf = (route) => {
const out = [];
for (let s = 0; s < route.lengthM - 1e-6; s += 10) out.push(Math.round(s * 1000) / 1000);
return [...out, Math.round(route.lengthM * 1000) / 1000];
};
const SAVED_LINE = [
[0, 0],
[100, 0],
[100, 100],
[100, 200],
];
const STEPS = [
[
[0, 0],
[95, 0],
[100, 100],
[100, 200],
],
[
[0, 0],
[90, 0],
[100, 100],
[100, 200],
],
];
const BASE = [0, 100, 200, 300].map((chainage_m) => ({ chainage_m, elevation_m: 500 }));
const SAVED = { station_offsets: { "250.000": 0.5 }, curve_radii: {} };
/** 저장 노선에서 노선 걸음 둘 — 걸음 사이 `between` 이 사용자 계획고 조작(묶음 입력 고침). */
function run(between, after) {
const stage = grade.createFollowStage({
savedLine: () => null,
stationsAt: (route) => gridOf(route),
});
const planline = { policy: { station_interval_m: 20 }, basePvi: BASE };
let now = SAVED;
let before = routeOf(SAVED_LINE);
let route = before;
STEPS.forEach((line, index) => {
route = routeOf(line);
const patch = stage({
inputs: { route: { nodes: [] }, pipes: null, structures: null, alignment: now },
out: { route },
before: { route: before, planline },
});
if (patch.alignment) now = patch.alignment;
if (index === 0 && between) now = between(now);
before = route;
});
if (after) now = after(now);
const reader = {
input: (key) => ({ route: { nodes: [] }, alignment: now })[key] ?? null,
output: (key) => (key === "route" ? route : null),
};
return { now, touch: stage.touch(SAVED, reader) };
}
const bump = (key, by) => (edits) => ({
...edits,
station_offsets: { ...edits.station_offsets, [key]: (edits.station_offsets[key] ?? 0) + by },
});
const drop = (edits) => {
const offsets = { ...edits.station_offsets };
delete offsets[Object.keys(offsets).sort()[0]];
return { ...edits, station_offsets: offsets };
};
process.stdout.write(
JSON.stringify({
plain: run(null, null),
lateBump: run(null, bump("60.000", 0.3)),
midBump: run(bump("40.000", 0.2), null),
removed: run(null, drop),
noFirst: grade.createFollowStage({ savedLine: () => null }).touch(SAVED, {
input: () => null,
output: () => null,
}),
pure: grade.gradeTouch(
{ station_offsets: { "10.000": 1, "20.000": 2.5 }, curve_radii: { "30.000": 80 } },
{ station_offsets: { "10.000": 1, "20.000": 2, "40.000": 3 }, curve_radii: {} },
),
}),
);
@@ -119,6 +119,8 @@ const STUBS = {
// 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림
"./design_save_api": saveApiStub,
"./design_leave": { designSignature: () => "", markDesignClean: () => undefined },
// 498-2 노선 바뀐 [저장] 계획고 손댐 — 묶음 없음 = 노선 0 칸 통째(옛 길)
"./design_pipeline_page": { routeGradeTouch: async () => null },
};
const { loadTs } = makeLoader({
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""498-2 노선 바뀐 [저장] 계획고 — 브라우저는 손댐(입력)만 · 서버가 자기 옮김 위에 한 번.
- 브라우저(`B05_Profile_Pipeline_Grade` follow `touch` · `gradeTouch`) — 노선만 끌었으면 손댐 0 ·
끈 뒤 ▲ = 그 열쇠만 · 끄는 중 ▲ = 그 열쇠 포함 묶음 값 그대로 · 지운 열쇠 = `cleared` ·
첫 기준 없으면 null(옛 길 · 0 칸 통째).
- 서버(`B06_Section_Router_DesignSave_Route.overlay_edits` · `touched_alignment`) — 손대지 않은 옮김
열쇠는 서버 측점 그대로 · 고친 · 지운 열쇠 자리(0.006 m 안)만 걷고 손댐 값을 얹음 · 브라우저 측점
열쇠로 서버 열쇠를 덮지 않음(옛 흠 = 두 쪽 측점이 다르면 어긋난 편집을 지움).
"""
import asyncio
import json
import subprocess
from pathlib import Path
import pytest
from B06_Section import B06_Section_Router_DesignSave_Route as moving
HERE = Path(__file__).resolve().parent
@pytest.fixture(scope="module")
def ts() -> dict:
proc = subprocess.run(
["node", str(HERE / "helper_498_2_grade_touch.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _empty(touch: dict) -> bool:
return not any(touch["edits"].values()) and not any(touch["cleared"].values())
def test_노선만_끌면_손댐_0(ts):
assert ts["plain"]["now"]["station_offsets"], "옮긴 편집이 있어야 시험이 뜻을 가짐"
assert _empty(ts["plain"]["touch"])
def test_끈_뒤_올림은_그_열쇠만(ts):
touch = ts["lateBump"]["touch"]
assert touch["edits"]["station_offsets"] == {"60.000": 0.3}
assert not any(touch["cleared"].values())
def test_끄는_중_올림은_묶음_값_그대로(ts):
now, touch = ts["midBump"]["now"], ts["midBump"]["touch"]
assert touch["edits"]["station_offsets"]["40.000"] == pytest.approx(0.2)
for key, value in touch["edits"]["station_offsets"].items():
assert now["station_offsets"][key] == value
def test_지운_열쇠는_cleared(ts):
touch = ts["removed"]["touch"]
assert touch["cleared"]["station_offsets"] == ["200.000"]
assert not any(touch["edits"].values())
def test_첫_기준_없으면_null_옛_길(ts):
assert ts["noFirst"] is None
def test_gradeTouch_순수(ts):
assert ts["pure"] == {
"edits": {"station_offsets": {"20.000": 2.5}, "curve_radii": {"30.000": 80}},
"cleared": {"station_offsets": ["40.000"], "curve_radii": []},
}
def test_서버_옮김_위에_손댐만():
carried = {
"station_offsets": {"960.000": 0.005, "970.000": 0.64, "980.000": 0.014},
"curve_radii": {"500.000": 120.0},
}
touch = {"station_offsets": {"970.003": 0.7, "1000.000": 0.2}, "curve_radii": {}}
cleared = moving.AlignmentCleared(station_offsets=["980.000"], curve_radii=["500.004"])
assert moving.overlay_edits(carried, touch, cleared) == {
# 960 = 손 안 댐(서버 값) · 970 = 0.006 m 안 손댐이 갈음 · 980 지움 · 1000 새 손댐
"station_offsets": {"960.000": 0.005, "970.003": 0.7, "1000.000": 0.2},
"curve_radii": {},
}
def test_손댐_0이면_서버_옮김_그대로():
carried = {"station_offsets": {"960.000": 0.005, "970.000": 0.64}, "curve_radii": {}}
out = moving.overlay_edits(carried, {}, moving.AlignmentCleared())
assert out == {"station_offsets": carried["station_offsets"], "curve_radii": {}}
def test_계획고_본문은_새_종단_편집에서(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
from B03_FileInput import B03_FileInput_Repository as files
from B05_Profile.B05_Profile_Schema import ProfileAlignmentSaveRequest
from B06_Section import B06_Section_Repository as repo
from common_util import common_util_storage as storage
from config import config_db
(tmp_path / "long.json").write_text(
json.dumps({"profile_alignment": {"edits": {"station_offsets": {"970.000": 0.64}}}}),
encoding="utf-8",
)
class Pool:
def acquire(self):
return self
async def __aenter__(self):
return None
async def __aexit__(self, *_):
return False
async def longitudinal(_connection, _project, route_id):
assert route_id == 820, "새 노선 번호로 읽어야 함"
return {"longitudinal_file_path": "long.json"}
async def stored(_connection, _project):
return "p"
monkeypatch.setattr(config_db, "get_db_pool", lambda: Pool())
monkeypatch.setattr(repo, "get_longitudinal_section", longitudinal)
monkeypatch.setattr(files, "get_project_storage_relative_path", stored)
monkeypatch.setattr(storage, "resolve_stored_project_path", lambda _p: str(tmp_path))
touch = ProfileAlignmentSaveRequest(route_id=819, station_offsets={"1000.000": 0.2})
body = asyncio.run(moving.touched_alignment("pid", 820, touch, moving.AlignmentCleared()))
assert body.route_id == 820
assert body.station_offsets == {"970.000": 0.64, "1000.000": 0.2}