diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a8339783d..7a7aed022 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 87dc69dc4..6cace6057 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -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(".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). */ diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts b/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts index ed76e01d0..37756e05f 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts @@ -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, + after: ReadonlyArray, + picked: number, + offMcs: ReadonlyArray, + 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}`; +} diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index 23f1aa63e..aee19d122 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -66,15 +66,25 @@ export function offStationCount( curves: ReadonlyArray, 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, + curves: ReadonlyArray, + intervalM: number, +): number[] { + if (!(intervalM > 0)) return []; const hits = new Set(); - 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. */ diff --git a/resources/tester/helper_398_2_curve_input.cjs b/resources/tester/helper_398_2_curve_input.cjs index 9a0f40ceb..1d6beeccd 100644 --- a/resources/tester/helper_398_2_curve_input.cjs +++ b/resources/tester/helper_398_2_curve_input.cjs @@ -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); diff --git a/resources/tester/helper_408_radius_downstream.cjs b/resources/tester/helper_408_radius_downstream.cjs new file mode 100644 index 000000000..72c8e9e68 --- /dev/null +++ b/resources/tester/helper_408_radius_downstream.cjs @@ -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)); diff --git a/resources/tester/test_398_2_curve_input.py b/resources/tester/test_398_2_curve_input.py index 4d3cce911..23294f93a 100644 --- a/resources/tester/test_398_2_curve_input.py +++ b/resources/tester/test_398_2_curve_input.py @@ -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: diff --git a/resources/tester/test_408_radius_downstream.py b/resources/tester/test_408_radius_downstream.py new file mode 100644 index 000000000..b19896c13 --- /dev/null +++ b/resources/tester/test_408_radius_downstream.py @@ -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)) # 고정 곡선 뒤도 맞춤