diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a10dba7c8..520474528 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -436,8 +436,7 @@ export async function mountRouteEditor( markEdited(); const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : ""; if (stop && before) return restoreSnapshot(before, stop, false); - const from = fit.fitStart(before, snapshotNow()); - if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from); + if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits()); markEdited(); writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); @@ -457,7 +456,7 @@ export async function mountRouteEditor( /** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */ function fitLimits(): fit.FitLimits { - return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() }; + return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap(), stationM: stationIntervalM }; } /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ @@ -542,8 +541,8 @@ export async function mountRouteEditor( }, snapStepM: snap, fitDrag: (index) => { - const base = stationSnap ? history?.current?.()?.planned : undefined; - if (base) planned = fit.fitDrag(snapshotNow(), base, index, fitLimits()); + const base = stationSnap ? history?.current?.() : undefined; // 누르기 전 노선 — 280-4 유지 + if (base) planned = fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base); if (base) markEdited(); return base !== undefined; }, diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index 5351b86cd..038d95855 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -7,7 +7,10 @@ * 밀어 MC(BC · EC 누가거리 가운데)를 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠 — 내림 · 올림 후보 중 뒤 곡선도 서는 쪽(454). * · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로. * · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음. - * · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`). + * · 간격 = 스냅 간격(390-2 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`). + * · 유지(280-4) — 고치기 전 노선(`before`)에서 MC 가 측점 · 10 m 배수(No.N+10)였던 곡선은 그 IP · 곡선 칸이 + * 그대로면 옛 꼴(측점 간격 배수 + 0 · 10)로만 맞춤(밀 거리 = 측점 간격) · 손으로 고친 곡선만 고른 간격. + * MC 가 고른 간격 배수 · 10 m 배수면 「위」(No.N+10 특별측점 · 저장 칸 없음 = 노선 모양으로 기억). * · 끄는 동안 · 방향키 걸음마다 미리보기(`fitDrag` · 390-5) = 누르기 전 노선 + 옮긴 IP 하나로 매번 새로 * 맞춤(앞 프레임 맞춤이 쌓이지 않음) · 놓을 때 `fitCurveStations` 정본 맞춤이 같은 값. * 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로. @@ -29,14 +32,21 @@ export const MC_TOL_M = 0.05; /** 간격별 허용 오차 — 0.1 m 간격도 실제로 맞게 간격의 1/20 이하(390-2 · 1 m 이상은 `MC_TOL_M`). */ export const mcTolM = (intervalM: number): number => Math.min(MC_TOL_M, intervalM / 20); -/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */ +/** 특별측점 간격(m) — MC 가 일반측점 사이 10 m 배수(No.N+10)면 측점 위로 봄(280-4). */ +export const SPECIAL_STATION_M = 10; + +/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격(스냅) · 측점 간격(없으면 스냅 간격). */ export interface FitLimits { minRadiusM: number; limitRadiusM: number; limitArcM: number; intervalM: number; + stationM?: number; } +/** MC 를 놓을 격자 — 간격 · 어긋남(N+10 꼴 = 10) · 밀 거리. */ +type Grid = { step: number; offset: number; reach: number }; + type Columns = Pick; /** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */ @@ -60,24 +70,56 @@ export function offStationCount( return offStationMcs(line, curves, intervalM).length; } -/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정. */ +/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정 · + * 간격 배수 · 10 m 배수(No.N+10 · 280-4) 둘 다 「위」. */ export function offStationMcs( line: ReadonlyArray, curves: ReadonlyArray, intervalM: number, ): number[] { if (!(intervalM > 0)) return []; + const tol = mcTolM(intervalM); const hits = new Set(); 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.has(n)) hits.add(n); + const at = [intervalM, SPECIAL_STATION_M] + .map((step) => Math.round(mc / step) * step) + .find((c) => c > tol && Math.abs(mc - c) <= tol && !hits.has(Math.round(c * 1000))); + if (at !== undefined) hits.add(Math.round(at * 1000)); else off.push(mc); } return off; } +/** 꺾임점 `k` 의 IP 자리 + 곡선 칸 — 고치기 전 · 뒤에 같으면 손대지 않은 곡선. */ +const nodeKey = (cols: Columns & Pick, k: number): string => + JSON.stringify([ + cols.planned[k], + cols.curveOn[k], + cols.curveRadius[k], + cols.curveLock[k], + cols.curveArc[k], + ]); + +/** 고치기 전 노선의 곡선 꼴 — MC 가 10 m 배수인 곡선의 `nodeKey` → 측점 간격 안 어긋남(0 · 10). */ +function keptForms( + before: Columns & Pick, + limits: FitLimits, + stationM: number, +): Map { + const built = build(before, before.planned, limits); + const out = new Map(); + for (let k = 1; k < before.planned.length - 1; k += 1) { + const mc = mcOf(built, k); + if (mc === null) continue; + const at = Math.round(mc / SPECIAL_STATION_M) * SPECIAL_STATION_M; + if (at <= 0 || Math.abs(mc - at) > mcTolM(SPECIAL_STATION_M)) continue; + out.set(nodeKey(before, k), ((at % stationM) + stationM) % stationM); + } + return out; +} + /** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */ export function fitStart( before: (Columns & Pick) | null | undefined, @@ -98,7 +140,8 @@ export function fitStart( const round4 = (value: number): number => Math.round(value * 1e4) / 1e4; -/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 d 밀어 MC = target(할선법 · 기울기 ≈ 1). */ +/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 ±reach 안 d 밀어 MC = target(할선법 · 기울기 ≈ 1) · + * reach = 간격(한가운데 18+9 ~ 19+1 도 가까운 측점까지 · 441 · 반 간격이면 못 닿음). */ function solve( cols: Columns, limits: FitLimits, @@ -106,6 +149,7 @@ function solve( k: number, target: number, mc: number, + reach: number, ): { planned: Vertex[]; built: EditedPolyline; mc: number } | null { const base = planned[k]; const before = planned[k - 1]; @@ -113,7 +157,6 @@ 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; // 한가운데(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(); @@ -141,11 +184,13 @@ function solve( /** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). * 곡선마다 후보 측점 둘(내림 · 올림 · 가까운 차례) · 뒤 곡선 하나를 미리 맞춰 보고 둘 다 서는 쪽(433-21 ②) - * — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐. */ + * — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐. + * `before` = 고치기 전 노선 — 손대지 않은 곡선은 그 MC 꼴(N+0 · N+10) 유지(280-4). */ export function fitCurveStations( cols: Columns & Pick, limits: FitLimits, from: number, + before?: (Columns & Pick) | null, ): Vertex[] { let planned = cols.planned.map((vertex): Vertex => [vertex[0], vertex[1]]); const interval = limits.intervalM; @@ -153,19 +198,33 @@ export function fitCurveStations( let built = build(cols, planned, limits); const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM); const tol = mcTolM(interval); - type Fit = { planned: Vertex[]; built: EditedPolyline }; - /** 곡선을 놓을 측점 — 내림 · 올림 중 앞 측점 + 1 이상 · 가까운 차례. */ - const near = (mc: number, last: number): number[] => { - const lo = Math.floor(mc / interval); - return [lo, lo + 1] - .filter((n) => n > last) - .sort((a, b) => Math.abs(a * interval - mc) - Math.abs(b * interval - mc)); + const stationM = limits.stationM ?? interval; + const forms = before ? keptForms(before, limits, stationM) : new Map(); + const gridOf = (k: number): Grid => { + const offset = forms.get(nodeKey(cols, k)); + return offset === undefined + ? { step: interval, offset: 0, reach: interval } + : { step: stationM, offset, reach: stationM }; }; - const onAt = (mc: number, last: number) => - near(mc, last).find((n) => Math.abs(mc - n * interval) <= tol); - /** 곡선 `k` 를 측점 `n` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */ - const attempt = (at: Fit, k: number, mc: number, n: number, previous: number): Fit | null => { - const fitted = solve(cols, limits, at.planned, k, n * interval, mc); + type Fit = { planned: Vertex[]; built: EditedPolyline }; + /** 곡선을 놓을 측점(누가거리) — 내림 · 올림 중 앞 측점 뒤 · 가까운 차례. */ + const near = (mc: number, last: number, grid: Grid): number[] => { + const lo = Math.floor((mc - grid.offset) / grid.step); + return [lo, lo + 1] + .map((n) => n * grid.step + grid.offset) + .filter((c) => c > last + tol) + .sort((a, b) => Math.abs(a - mc) - Math.abs(b - mc)); + }; + /** 이미 측점 위 — 그 곡선 격자 · 고른 간격 · 10 m 배수(N+10) 중 하나. */ + const loose: Grid[] = [ + { step: interval, offset: 0, reach: 0 }, + { step: SPECIAL_STATION_M, offset: 0, reach: 0 }, + ]; + const onAt = (mc: number, last: number, grid: Grid) => + [grid, ...loose].flatMap((g) => near(mc, last, g)).find((c) => Math.abs(mc - c) <= tol); + /** 곡선 `k` 를 측점 `c` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */ + const attempt = (at: Fit, k: number, mc: number, c: number, previous: number): Fit | null => { + const fitted = solve(cols, limits, at.planned, k, c, mc, gridOf(k).reach); if ( !fitted || shortfallCrossed( @@ -185,46 +244,60 @@ export function fitCurveStations( for (let j = k + 1; j < at.planned.length - 1; j += 1) { const mc = mcOf(at.built, j); if (mc === null) continue; - if (onAt(mc, last) !== undefined) return true; - return !cols.apexLock[j] && near(mc, last).some((n) => attempt(at, j, mc, n, k) !== null); + if (onAt(mc, last, gridOf(j)) !== undefined) return true; + const grid = gridOf(j); + return ( + !cols.apexLock[j] && near(mc, last, grid).some((c) => attempt(at, j, mc, c, k) !== null) + ); } return true; }; - let lastStation = 0; // 앞 곡선이 놓인 측점 번호(없으면 0 = 시점) + let lastStation = 0; // 앞 곡선이 놓인 측점 누가거리(없으면 0 = 시점 · 서로 다른 측점) let previous = -1; // 앞 곡선 꺾임점 for (let k = 1; k < planned.length - 1; k += 1) { const mc = mcOf(built, k); if (mc === null) continue; - const on = onAt(mc, lastStation); + const on = onAt(mc, lastStation, gridOf(k)); if (on !== undefined) lastStation = on; else if (k >= from && !cols.apexLock[k]) { - let pick: { fit: Fit; n: number } | null = null; - for (const n of near(mc, lastStation)) { - const fit = attempt({ planned, built }, k, mc, n, previous); + let pick: { fit: Fit; c: number } | null = null; + for (const c of near(mc, lastStation, gridOf(k))) { + const fit = attempt({ planned, built }, k, mc, c, previous); if (!fit) continue; - pick ??= { fit, n }; - if (nextSettles(fit, k, n)) { - pick = { fit, n }; + pick ??= { fit, c }; + if (nextSettles(fit, k, c)) { + pick = { fit, c }; break; } } - if (pick) (({ planned, built } = pick.fit), (lastStation = pick.n)); + if (pick) (({ planned, built } = pick.fit), (lastStation = pick.c)); } previous = k; } return planned; } +/** 편집 한 걸음 뒤 맞춤 — 고친 자리(`fitStart`)부터 · 손대지 않은 곡선은 `before` 꼴 유지(280-4). */ +export function fitAfter( + before: (Columns & Pick) | null | undefined, + now: Columns & Pick, + limits: FitLimits, +): Vertex[] { + return fitCurveStations(now, limits, fitStart(before, now), before); +} + /** 끄는 중 미리보기(390-5) — `base`(누르기 전 IP) 에 IP `k` 만 지금 자리(`cols.planned[k]`)로 두고 - * `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작). */ + * `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작) · `before` = 누르기 전 노선(없으면 `base` + 지금 칸 · 280-4 유지). */ export function fitDrag( cols: Columns & Pick, base: ReadonlyArray, k: number, limits: FitLimits, + before?: (Columns & Pick) | null, ): Vertex[] { if (base.length !== cols.planned.length) - return fitCurveStations(cols, limits, Math.max(1, k - 1)); + return fitCurveStations(cols, limits, Math.max(1, k - 1), before); const planned = base.map((vertex, i): Vertex => (i === k ? cols.planned[k] : vertex)); - return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1)); + const kept = before ?? { ...cols, planned: base.map((v): Vertex => [v[0], v[1]]) }; + return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1), kept); } diff --git a/resources/tester/helper_280_4_fit.cjs b/resources/tester/helper_280_4_fit.cjs new file mode 100644 index 000000000..c5acd1a52 --- /dev/null +++ b/resources/tester/helper_280_4_fit.cjs @@ -0,0 +1,136 @@ +/* PLAN 483-3(요구 280-4) 「유지」 — `logic/B05_Profile_RouteEdit_StationFit.ts` 를 Node 로 돌려 JSON 한 줄 + * (`test_280_4_fit.py` 가 판정). 분석 D 재현 2 노선(helper_366_3 와 같은 지그재그). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +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) => + 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 LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts")); +const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts")); +const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts")); + +const STATION = 20; +const BASE = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, stationM: STATION }; +const STEP10 = { ...BASE, intervalM: 10 }; +const STEPNO = { ...BASE, intervalM: STATION }; +const ROUTE = [ + [0, 0], + [93, 7], + [171, 61], + [262, 48], + [337, 112], + [431, 101], + [503, 160], +]; + +function columns(planned) { + const n = planned.length; + return { + planned: planned.map(([x, y]) => [x, y]), + curveOn: planned.map(() => true), + curveRadius: planned.map(() => null), + curveLock: planned.map(() => null), + curveArc: planned.map(() => null), + apexLock: planned.map((_, i) => i === 0 || i === n - 1), + }; +} +const withPlanned = (cols, planned) => ({ ...cols, planned: planned.map(([x, y]) => [x, y]) }); +const round2 = (v) => Math.round(v * 100) / 100; +function mcs(cols) { + const built = edits.rebuildLine({ ...cols, curveRadius: [...cols.curveRadius] }, STEPNO); + return frame.curveSpans(built.vertices, built.curves).map(([a, b]) => round2((a + b) / 2)); +} +function off(cols, step) { + const built = edits.rebuildLine({ ...cols, curveRadius: [...cols.curveRadius] }, STEPNO); + return fit.offStationCount(built.vertices, built.curves, step); +} +/** IP `k` 를 들어오는 접선 따라 d 밀기 — 손 편집 흉내. */ +function nudge(cols, k, d) { + const p = cols.planned; + const len = Math.hypot(p[k][0] - p[k - 1][0], p[k][1] - p[k - 1][1]); + const ux = (p[k][0] - p[k - 1][0]) / len; + const uy = (p[k][1] - p[k - 1][1]) / len; + const next = p.map(([x, y]) => [x, y]); + next[k] = [p[k][0] + ux * d, p[k][1] + uy * d]; + return withPlanned(cols, next); +} +/** IP `k` 를 화면 좌표로 (dx, dy) 옮김 — 앞 교점 손 편집(접선 밖 · 맞춤이 되밀어도 제자리로 안 돌아감). */ +function shift(cols, k, dx, dy) { + const next = cols.planned.map(([x, y]) => [x, y]); + next[k] = [next[k][0] + dx, next[k][1] + dy]; + return withPlanned(cols, next); +} +/** 손 편집 한 걸음 = 편집기 applyEdit(`fitAfter(before, now, limits)`). */ +const step = (before, now, limits) => withPlanned(now, fit.fitAfter(before, now, limits)); + +const FRONT = [0, 3]; // 앞 IP1 을 y +3 m + +// ① 측점 No. 로 맞춘 노선 +const s1 = withPlanned(columns(ROUTE), fit.fitCurveStations(columns(ROUTE), STEPNO, 1)); +// ② 10 m · IP2 를 손으로 밀어 그 곡선 MC = N+10(밀 거리 훑어 첫 N+10) +let s2 = null; +let pushed = null; +for (let d = 1; d <= 12 && !s2; d += 0.5) { + const now = step(s1, nudge(s1, 2, d), STEP10); + const mc = mcs(now)[1]; + if (Math.abs(mc / 10 - Math.round(mc / 10)) < 0.005 && Math.round(mc / 10) % 2 === 1) + ((s2 = now), (pushed = d)); +} +// ③ 간격만 측점 No. 로(편집기 = 간격 바꿈 → applyEdit · 노선 그대로) +const s3 = step(s2, s2, STEPNO); +// ④ 측점 No. 로 돌린 뒤 앞 IP1 +3 m +const s4 = step(s3, shift(s3, 1, FRONT[0], FRONT[1]), STEPNO); +// ⑤ 10 m 그대로 앞 IP1 +3 m +const s5 = step(s2, shift(s2, 1, FRONT[0], FRONT[1]), STEP10); +// ⑥ 그 곡선 IP2 를 손으로 다시 밀기(측점 No.) → N+0 +const s6 = step(s3, nudge(s3, 2, 4), STEPNO); +// ⑦ before 없이(옛 동작) 측점 No. 로 다시 맞춤 = N+10 이 흔들림(기준이 고침을 재는지) +const s7 = withPlanned(s4, fit.fitCurveStations(shift(s3, 1, FRONT[0], FRONT[1]), STEPNO, 1)); +// ⑧ 끄는 중 미리보기(fitDrag · before) = 놓을 때(fitAfter) +const dragNow = shift(s3, 1, FRONT[0], FRONT[1]); +const preview = withPlanned(dragNow, fit.fitDrag(dragNow, s3.planned, 1, STEPNO, s3)); +const release = step(s3, preview, STEPNO); +const gap = Math.max( + ...preview.planned.map((v, i) => + Math.hypot(v[0] - release.planned[i][0], v[1] - release.planned[i][1]), + ), +); + +console.log( + JSON.stringify({ + pushed, + mc: { + s1: mcs(s1), + s2: mcs(s2), + s3: mcs(s3), + s4: mcs(s4), + s5: mcs(s5), + s6: mcs(s6), + s7: mcs(s7), + }, + off: { s2no: off(s2, STATION), s2ten: off(s2, 10), s4: off(s4, STATION) }, + dragGap: gap, + previewMc: mcs(preview), + }), +); diff --git a/resources/tester/test_280_4_fit.py b/resources/tester/test_280_4_fit.py new file mode 100644 index 000000000..1c2f821cd --- /dev/null +++ b/resources/tester/test_280_4_fit.py @@ -0,0 +1,83 @@ +"""PLAN 483-3(요구 280-4) 「유지」 — 손대지 않은 곡선은 MC 꼴(N+0 · N+10) 그대로 · 손으로 고친 곡선만 고른 간격. + +`logic/B05_Profile_RouteEdit_StationFit.ts`(`fitAfter` · `fitDrag` · `offStationCount`)를 +`helper_280_4_fit.cjs` 로 돌림 — 분석 D 재현 2 노선. 파이썬 짝 아님(서버는 저장 IP 그대로). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +B05 = ROOT / "B05_Profile" + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_280_4_fit.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def _near(values: list[float], want: list[float]) -> bool: + return values == pytest.approx(want, abs=0.05) + + +def test_측점_No_맞춤(got) -> None: + assert _near(got["mc"]["s1"], [100, 180, 280, 380, 460]) + + +def test_10m_손으로_N_10(got) -> None: + assert got["pushed"] is not None + assert _near(got["mc"]["s2"], [100, 190, 280, 380, 460]) + + +def test_간격만_측점_No_로_바꿔도_남음(got) -> None: + assert _near(got["mc"]["s3"], [100, 190, 280, 380, 460]) + + +def test_앞_교점_옮긴_뒤에도_남음(got) -> None: + assert _near(got["mc"]["s4"], [100, 190, 280, 380, 460]) # 측점 No. + assert _near(got["mc"]["s5"], [100, 190, 280, 380, 460]) # 10 m · N+0 곡선은 N+10 으로 안 감 + + +def test_기준이_고침을_잼(got) -> None: + """before 없이(고치기 전 동작) 같은 편집 = 특별측점 사라짐 → 위 시험이 새 길을 거침.""" + assert got["mc"]["s7"][1] == pytest.approx(180, abs=0.05) + + +def test_그_곡선을_손으로_밀면_고른_간격(got) -> None: + assert _near(got["mc"]["s6"], [100, 200, 280, 380, 460]) # N+0 = 특별측점 없어짐 + + +def test_N_10_은_측점_밖이_아님(got) -> None: + assert got["off"] == {"s2no": 0, "s2ten": 0, "s4": 0} + + +def test_끄는_중_미리보기_놓을_때_같음(got) -> None: + assert got["dragGap"] < 1e-6 + assert _near(got["previewMc"], [100, 190, 280, 380, 460]) + + +def test_편집기_이음() -> None: + edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit + assert "fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base)" in edit + assert "stationM: stationIntervalM" in edit + assert len(edit.splitlines()) <= 695 diff --git a/resources/tester/test_366_3_station_fit.py b/resources/tester/test_366_3_station_fit.py index 9ee08198f..0154535bc 100644 --- a/resources/tester/test_366_3_station_fit.py +++ b/resources/tester/test_366_3_station_fit.py @@ -89,7 +89,7 @@ def test_시작_자리는_고친_꺾임점_앞(got) -> None: def test_편집기_이음() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") - assert "fitCurveStations(" in edit + assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit # 280-4 # 되돌리기 · 추천 [적용] · 초안 되살림이 지나는 restoreSnapshot 은 안 맞춤 at = edit.index("function restoreSnapshot(") assert "applyEdit(message, record, false);" in edit[at : at + 700] diff --git a/resources/tester/test_390_5_snap_step_ops.py b/resources/tester/test_390_5_snap_step_ops.py index c3b7f2bc9..8baffd77c 100644 --- a/resources/tester/test_390_5_snap_step_ops.py +++ b/resources/tester/test_390_5_snap_step_ops.py @@ -117,7 +117,7 @@ def test_끌기_방향키_편집기_이음() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") assert "params.fitDrag(index)" in pointer and "fitPreview(after.planned, node);" in pointer assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys - assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit + assert "fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base)" in edit # 하한 검사는 맞추기 전 노선으로(맞춤이 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