feat(B05 노선 편집 R 입력): 반지름 · 곡선 길이 뒤 상태줄 함께 밀린 교점 · 못 맞춘 곡선 보고 + 뒤쪽 MC 시험 (408-1)
- 맞춤 함수 그대로(fitCurveStations) · 스냅 켬일 때만 꼬리 — KeyNudge nudgeReport 재사용 fitNote - StationFit offStationMcs(측점 밖 MC 목록) · offStationCount 는 그 길이 - 시험 test_408_radius_downstream 새로 · test_398_2 뒤쪽 곡선 MC 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -418,6 +418,8 @@ export async function mountRouteEditor(
|
||||
apexLock,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
line: plannedLine,
|
||||
snap: stationSnap ? { stepM: snap(), stationM: stationIntervalM } : null,
|
||||
}),
|
||||
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
|
||||
applyEdit: (message) => applyEdit(message),
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
createCurveLabel,
|
||||
type CurveLabel,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import { fitNote } from "./logic/B05_Profile_RouteEdit_KeyNudge";
|
||||
import { offStationMcs } from "./logic/B05_Profile_RouteEdit_StationFit";
|
||||
|
||||
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
|
||||
export interface CurveBarState {
|
||||
@@ -38,6 +40,10 @@ export interface CurveBarState {
|
||||
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||
limitRadiusM: number;
|
||||
limitArcM: number;
|
||||
/** 그린 계획노선(곡선 편 정점) — 측점 밖 곡선 MC 셈(408-1). */
|
||||
line: Vertex[];
|
||||
/** 측점 스냅 켬이면 맞춤 간격 · 측점 간격(m) · 끔이면 null(맞춤 없음 = 보고 없음). */
|
||||
snap: { stepM: number; stationM: number } | null;
|
||||
}
|
||||
|
||||
export interface CurveBarParams {
|
||||
@@ -64,16 +70,16 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
{
|
||||
onClose: () => params.onUnselect(),
|
||||
onRadius: (value) => {
|
||||
const { picked, curveRadius } = params.state();
|
||||
const { picked, curveRadius, planned } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.";
|
||||
params.applyEdit(message);
|
||||
noteShortfall(message, "반지름");
|
||||
noteResult(message, "반지름", planned);
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
|
||||
const { picked, nodeInfo, curveArc, curveRadius, planned } = params.state();
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
@@ -83,7 +89,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
const message =
|
||||
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.";
|
||||
params.applyEdit(message);
|
||||
noteShortfall(message, "곡선 길이");
|
||||
noteResult(message, "곡선 길이", planned);
|
||||
},
|
||||
onLock: (lock) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
|
||||
@@ -124,20 +130,31 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
);
|
||||
|
||||
/** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 → 그린 값 · 까닭으로(398-2 · 상태줄 두 줄 안) — 접선은
|
||||
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. */
|
||||
function noteShortfall(message: string, name: "반지름" | "곡선 길이"): void {
|
||||
const { picked, curveRadius, curveInfo, nodeInfo } = params.state();
|
||||
const asked = curveRadius[picked];
|
||||
const drawn = curveInfo.find((entry) => entry.node_first === picked);
|
||||
if (asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05) return;
|
||||
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
|
||||
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
|
||||
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다.
|
||||
* 스냅 켬이면 맞춤이 민 교점 · 못 맞춘 곡선을 덧붙임(408-1 · `before` = 입력 전 교점). */
|
||||
function noteResult(message: string, name: "반지름" | "곡선 길이", before: Vertex[]): void {
|
||||
const { picked, curveRadius, curveInfo, nodeInfo, planned, line, snap } = params.state();
|
||||
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
|
||||
if (!status) return;
|
||||
status.textContent = (status.textContent ?? "").replace(
|
||||
message,
|
||||
`${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`,
|
||||
);
|
||||
const asked = curveRadius[picked];
|
||||
const drawn = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
|
||||
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
|
||||
const head =
|
||||
asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05
|
||||
? message.replace(/\.$/, "")
|
||||
: `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`;
|
||||
const tail = snap
|
||||
? fitNote(
|
||||
before,
|
||||
planned,
|
||||
picked,
|
||||
offStationMcs(line, curveInfo, snap.stepM),
|
||||
snap.stationM,
|
||||
snap.stepM,
|
||||
)
|
||||
: "";
|
||||
status.textContent = (status.textContent ?? "").replace(message, `${head}${tail}.`);
|
||||
}
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
|
||||
* · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다
|
||||
* MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
|
||||
* · 반지름 · 곡선 길이 입력 뒤(408-1)도 같은 셈으로 맞춤이 민 교점 · 못 맞춘 곡선을 알림(`fitNote`).
|
||||
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { stationLabel } from "../../common_util/common_util_station";
|
||||
import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
|
||||
/** 방향키 한 번(m) · Shift 배수. */
|
||||
@@ -94,3 +96,22 @@ export function nudgeMessage(report: NudgeReport, snapping: boolean): string {
|
||||
if (report.movedM < STAY_M) return "같은 측점 — 스냅 끄고 미세 조정하세요.";
|
||||
return `측점에 맞춰 실제 이동 ${report.movedM.toFixed(1)} m · 함께 밀린 IP ${report.pushed}곳.`;
|
||||
}
|
||||
|
||||
/** 반지름 · 곡선 길이 입력 뒤 맞춤 결과 꼬리(408-1) — 손으로 옮긴 교점이 없으니 고른 IP 도 「밀림」에 셈 ·
|
||||
* 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」). */
|
||||
export function fitNote(
|
||||
before: ReadonlyArray<Vertex>,
|
||||
after: ReadonlyArray<Vertex>,
|
||||
picked: number,
|
||||
offMcs: ReadonlyArray<number>,
|
||||
stationM: number,
|
||||
stepM: number,
|
||||
): string {
|
||||
const start = before[picked] ?? [0, 0];
|
||||
const report = nudgeReport(before, after, picked, start, start);
|
||||
const pushed = report.pushed + (report.movedM > SAME_M ? 1 : 0);
|
||||
const names = offMcs.map((mc) => `No.${stationLabel(mc, stationM, stepM < 1 ? 1 : 0)}`);
|
||||
const more = names.length > 3 ? ` 외 ${names.length - 3}` : "";
|
||||
const off = names.length ? ` · 못 맞춘 곡선 ${names.slice(0, 3).join(", ")}${more}` : "";
|
||||
return ` · 함께 밀린 교점 ${pushed}곳${off}`;
|
||||
}
|
||||
|
||||
@@ -66,15 +66,25 @@ export function offStationCount(
|
||||
curves: ReadonlyArray<EditedCurve>,
|
||||
intervalM: number,
|
||||
): number {
|
||||
if (!(intervalM > 0)) return 0;
|
||||
const spans = curveSpans(line, curves);
|
||||
return offStationMcs(line, curves, intervalM).length;
|
||||
}
|
||||
|
||||
/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정. */
|
||||
export function offStationMcs(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<EditedCurve>,
|
||||
intervalM: number,
|
||||
): number[] {
|
||||
if (!(intervalM > 0)) return [];
|
||||
const hits = new Set<number>();
|
||||
for (const [start, end] of spans) {
|
||||
const off: number[] = [];
|
||||
for (const [start, end] of curveSpans(line, curves)) {
|
||||
const mc = (start + end) / 2;
|
||||
const n = Math.round(mc / intervalM);
|
||||
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM)) hits.add(n);
|
||||
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM) && !hits.has(n)) hits.add(n);
|
||||
else off.push(mc);
|
||||
}
|
||||
return spans.length - hits.size;
|
||||
return off;
|
||||
}
|
||||
|
||||
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
|
||||
|
||||
@@ -69,6 +69,8 @@ function editor(snap) {
|
||||
apexLock: Array(n).fill(false),
|
||||
limitRadiusM: LIMITS.limitRadiusM,
|
||||
limitArcM: LIMITS.limitArcM,
|
||||
line: [],
|
||||
snap: snap ? { stepM: LIMITS.intervalM, stationM: LIMITS.intervalM } : null,
|
||||
};
|
||||
const status = { textContent: "" };
|
||||
const markEdited = () => {
|
||||
@@ -139,11 +141,14 @@ for (const snap of [false, true]) {
|
||||
ed.pick(1);
|
||||
h.onRadius(40);
|
||||
row.r40 = round(ed.drawn(1).radius_m);
|
||||
const later = () => [2, 3].map((k) => round(ed.mc(k))); // 뒤쪽 곡선 MC(408-1)
|
||||
row.r40Later = later();
|
||||
h.onRadius(60);
|
||||
row.r60 = round(ed.drawn(1).radius_m);
|
||||
row.r60Shown = round(ed.lastShow().radiusShown);
|
||||
row.r60Status = ed.status.textContent;
|
||||
row.r60Mc = round(ed.mc(1));
|
||||
row.r60Later = later();
|
||||
h.onArcLength(18);
|
||||
row.l18 = round(ed.arcOf(1));
|
||||
h.onArcLength(25);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/* 408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — `_CurveBar` 배선을 Node 로 돌려 JSON 한 줄
|
||||
* (`test_408_radius_downstream.py` 가 판정). 창(`_Label`)은 손잡이만 잡는 가짜 · 편집 창 `applyEdit` 는
|
||||
* 같은 길(스냅 켬 = `fitStart` → `fitCurveStations` → 길이 고정 R · 하한 → `buildEditedPolyline`)로 흉내.
|
||||
* node helper_408_radius_downstream.cjs
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B05 = path.join(ROOT, "B05_Profile");
|
||||
const cache = new Map();
|
||||
let handlers = null;
|
||||
const STUBS = {
|
||||
"./B05_Profile_UI_RouteEdit_Label": {
|
||||
createCurveLabel: (given) => {
|
||||
handlers = given;
|
||||
return { show: () => {}, hide: () => {}, destroy: () => {} };
|
||||
},
|
||||
centerDirectionOf: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
STUBS[request] ??
|
||||
(request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
|
||||
const edits = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Edits.ts"));
|
||||
const frame = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Frame.ts"));
|
||||
const fit = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StationFit.ts"));
|
||||
const bar = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_CurveBar.ts"));
|
||||
|
||||
// 곡선 넷(IP1~4) · 직선이 길어 맞춤이 접선 절반에 안 막힘
|
||||
const ROUTE = [
|
||||
[0, 0],
|
||||
[110, 0],
|
||||
[210, 60],
|
||||
[320, 60],
|
||||
[420, -10],
|
||||
[540, -10],
|
||||
];
|
||||
const LIMITS = { minRadiusM: 12, limitRadiusM: 12, limitArcM: 5, intervalM: 20 };
|
||||
const KEYS = ["planned", "curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"];
|
||||
const copy = (state) =>
|
||||
Object.fromEntries(
|
||||
KEYS.map((key) => [key, state[key].map((v) => (Array.isArray(v) ? [...v] : v))]),
|
||||
);
|
||||
|
||||
function editor(snap, apexLock = []) {
|
||||
const n = ROUTE.length;
|
||||
const state = {
|
||||
picked: -1,
|
||||
planned: ROUTE.map((v) => [v[0], v[1]]),
|
||||
nodeInfo: [],
|
||||
curveInfo: [],
|
||||
curveOn: Array(n).fill(true),
|
||||
curveRadius: Array(n).fill(30),
|
||||
curveLock: Array(n).fill(null),
|
||||
curveArc: Array(n).fill(null),
|
||||
apexLock: Array.from({ length: n }, (_, i) => apexLock.includes(i)),
|
||||
limitRadiusM: LIMITS.limitRadiusM,
|
||||
limitArcM: LIMITS.limitArcM,
|
||||
line: [],
|
||||
snap: snap ? { stepM: LIMITS.intervalM, stationM: 20 } : null,
|
||||
};
|
||||
const status = { textContent: "" };
|
||||
const markEdited = () => {
|
||||
edits.applyArcLocks(state.planned, state.curveLock, state.curveArc, state.curveRadius);
|
||||
edits.applyCurveLimits(
|
||||
state.planned,
|
||||
state.curveOn,
|
||||
state.curveRadius,
|
||||
LIMITS.limitRadiusM,
|
||||
LIMITS.limitArcM,
|
||||
);
|
||||
const built = curve.buildEditedPolyline(
|
||||
state.planned,
|
||||
state.curveOn,
|
||||
state.curveRadius,
|
||||
LIMITS.minRadiusM,
|
||||
);
|
||||
state.line = built.vertices;
|
||||
state.curveInfo = built.curves;
|
||||
state.nodeInfo = built.nodes;
|
||||
};
|
||||
let committed = null;
|
||||
const applyEdit = (message) => {
|
||||
const from = fit.fitStart(committed, state);
|
||||
if (snap) state.planned = fit.fitCurveStations(state, LIMITS, from);
|
||||
markEdited();
|
||||
status.textContent = `예상노선 — ${message} 꼬리`;
|
||||
committed = copy(state);
|
||||
};
|
||||
bar.createCurveBar({
|
||||
canvas: { getBoundingClientRect: () => ({ left: 0, top: 0, right: 800, bottom: 600 }) },
|
||||
container: { querySelector: (sel) => (sel === ".b05-routeedit__status" ? status : null) },
|
||||
state: () => state,
|
||||
toScreen: (v) => [v[0], v[1]],
|
||||
applyEdit,
|
||||
onUnselect: () => {},
|
||||
});
|
||||
// 처음 = 스냅 켤 때처럼 전체 맞춤 한 걸음
|
||||
markEdited();
|
||||
applyEdit("측점 스냅을 켰습니다.");
|
||||
const mcs = () =>
|
||||
frame
|
||||
.curveSpans(state.line, state.curveInfo)
|
||||
.map(([a, b]) => Math.round(((a + b) / 2) * 1000) / 1000);
|
||||
const off = () => fit.offStationCount(state.line, state.curveInfo, LIMITS.intervalM);
|
||||
const pick = (k) => {
|
||||
state.picked = k;
|
||||
};
|
||||
return { state, status, h: () => handlers, mcs, off, pick };
|
||||
}
|
||||
|
||||
const out = {};
|
||||
const row = (ed) => ({
|
||||
mcs: ed.mcs(),
|
||||
off: ed.off(),
|
||||
status: ed.status.textContent,
|
||||
planned: ed.state.planned.map((v) => [...v]),
|
||||
});
|
||||
|
||||
for (const snap of [true, false]) {
|
||||
const key = snap ? "on" : "off";
|
||||
const ed = editor(snap);
|
||||
out[`${key}Start`] = row(ed);
|
||||
ed.pick(1);
|
||||
ed.h().onRadius(60); // 앞쪽 곡선 R 키움
|
||||
out[`${key}R60`] = row(ed);
|
||||
ed.h().onArcLength(50); // 곡선 길이 입력도
|
||||
out[`${key}L50`] = row(ed);
|
||||
ed.h().onRadius(null); // 빈 값 = 자동
|
||||
out[`${key}Auto`] = row(ed);
|
||||
}
|
||||
|
||||
// IP3 고정 = 그 곡선은 못 맞춤 → 상태줄 「못 맞춘 곡선 No.…」
|
||||
{
|
||||
const ed = editor(true, [3]);
|
||||
out.lockStart = row(ed);
|
||||
ed.pick(1);
|
||||
ed.h().onRadius(60);
|
||||
out.lockR60 = row(ed);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -38,7 +38,7 @@ def test_반지름_40_60_곡선이_바뀜(got, snap) -> None:
|
||||
row = got[snap]
|
||||
assert row["r40"] == pytest.approx(40) and row["r60"] == pytest.approx(60)
|
||||
assert row["r60Shown"] == pytest.approx(60)
|
||||
assert "반지름을 바꿨습니다." in row["r60Status"]
|
||||
assert "반지름을 바꿨습니다" in row["r60Status"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
|
||||
@@ -47,7 +47,7 @@ def test_곡선_길이_18_25_곡선이_바뀜(got, snap) -> None:
|
||||
tol = 0.1 if snap == "snapOn" else 1e-6 # 스냅 켬 = IP 맞춤으로 교각이 조금 바뀐 뒤 값
|
||||
assert row["l18"] == pytest.approx(18, abs=tol) and row["l25"] == pytest.approx(25, abs=tol)
|
||||
assert row["l25Shown"] == pytest.approx(row["l25"])
|
||||
assert "곡선 길이를 바꿨습니다." in row["l25Status"]
|
||||
assert "곡선 길이를 바꿨습니다" in row["l25Status"]
|
||||
|
||||
|
||||
def test_스냅_켬_MC_가_측점에(got) -> None:
|
||||
@@ -55,6 +55,18 @@ def test_스냅_켬_MC_가_측점에(got) -> None:
|
||||
assert got["snapOff"]["r60Mc"] != pytest.approx(120, abs=0.05)
|
||||
|
||||
|
||||
def test_스냅_켬_R_바꾼_뒤_뒤쪽_곡선_MC(got) -> None:
|
||||
"""408-1 — 고친 곡선 뒤 곡선도 맞춤 · 측점 위였던 곡선은 R 바꾼 뒤에도 측점 위."""
|
||||
on = got["snapOn"]
|
||||
|
||||
def hit(mc: float) -> bool:
|
||||
return abs(mc - round(mc / 20) * 20) <= 0.05
|
||||
|
||||
assert [hit(mc) for mc in on["r60Later"]] >= [hit(mc) for mc in on["r40Later"]]
|
||||
assert all(hit(mc) for mc in on["r60Later"])
|
||||
assert "함께 밀린 교점 " in on["r60Status"] and "함께 밀린" not in got["snapOff"]["r60Status"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
|
||||
def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -> None:
|
||||
row = got[snap]
|
||||
@@ -62,7 +74,7 @@ def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -
|
||||
want = f"반지름 60m → {round(row['capR'], 1)}m(앞뒤 직선이 짧아 접선 자리 모자람)"
|
||||
assert want in row["capStatus"]
|
||||
assert "곡선 길이 40m → " in row["capArcStatus"]
|
||||
assert "반지름을 자동으로 되돌렸습니다." in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
|
||||
assert "반지름을 자동으로 되돌렸습니다" in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
|
||||
|
||||
|
||||
def test_고정(got) -> None:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — 반지름 · 곡선 길이 입력 = 교점 끌기와 같은
|
||||
`fitCurveStations` 로 고친 곡선부터 끝 곡선까지 MC 를 측점에 다시 맞춤 · 상태줄 「함께 밀린 교점 N곳 ·
|
||||
못 맞춘 곡선 No.…」(`KeyNudge.fitNote`) · 스냅 끔 = 맞춤 · 보고 없음.
|
||||
|
||||
TS 셈은 `helper_408_radius_downstream.cjs`(Node · `_CurveBar` 배선 그대로).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_408_radius_downstream.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HELPER = Path(__file__).with_name("helper_408_radius_downstream.cjs")
|
||||
TOL = 0.05 # MC_TOL_M
|
||||
STEPS = ["R60", "L50", "Auto"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def _on_station(mc: float) -> bool:
|
||||
return abs(mc - round(mc / 20) * 20) <= TOL
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", STEPS)
|
||||
def test_스냅_켬_뒤쪽_곡선_MC_가_측점_위(got, step) -> None:
|
||||
row = got[f"on{step}"]
|
||||
assert len(row["mcs"]) == 4 and all(_on_station(mc) for mc in row["mcs"])
|
||||
assert row["off"] <= got["onStart"]["off"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", STEPS)
|
||||
def test_스냅_켬_뒤_교점이_밀림_시점은_그대로(got, step) -> None:
|
||||
before = got["onStart"]["planned"]
|
||||
after = got[f"on{step}"]["planned"]
|
||||
assert after[0] == before[0] and after[-1] == before[-1]
|
||||
assert all(after[k] != before[k] for k in (2, 3, 4)) # 고친 곡선 뒤 교점이 맞춤으로 밀림
|
||||
|
||||
|
||||
def test_상태줄_보고(got) -> None:
|
||||
assert "반지름을 바꿨습니다 · 함께 밀린 교점 4곳." in got["onR60"]["status"]
|
||||
assert "곡선 길이를 바꿨습니다 · 함께 밀린 교점 4곳." in got["onL50"]["status"]
|
||||
assert "반지름을 자동으로 되돌렸습니다 · 함께 밀린 교점 " in got["onAuto"]["status"]
|
||||
assert all("못 맞춘 곡선" not in got[f"on{step}"]["status"] for step in STEPS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", STEPS)
|
||||
def test_스냅_끔_맞춤_보고_없음(got, step) -> None:
|
||||
row = got[f"off{step}"]
|
||||
assert "함께 밀린" not in row["status"]
|
||||
assert row["planned"] == got["offStart"]["planned"] # 교점 그대로
|
||||
assert not all(_on_station(mc) for mc in row["mcs"])
|
||||
|
||||
|
||||
def test_고정_교점_곡선은_못_맞춤으로(got) -> None:
|
||||
row = got["lockR60"]
|
||||
assert row["off"] == got["lockStart"]["off"] == 1
|
||||
assert row["planned"][3] == got["lockStart"]["planned"][3] # 고정 IP3 그대로
|
||||
mc = row["mcs"][2]
|
||||
n, rest = int(mc // 20), round(mc - int(mc // 20) * 20)
|
||||
assert f"함께 밀린 교점 3곳 · 못 맞춘 곡선 No.{n}+{rest}." in row["status"]
|
||||
assert all(_on_station(row["mcs"][i]) for i in (0, 1, 3)) # 고정 곡선 뒤도 맞춤
|
||||
Reference in New Issue
Block a user