feat(B05 발자국): 346-2 평면 발자국 계산(순수) — B07 평면 비탈의 ① 짝
- B05_Profile/logic/B05_Profile_PlanFootprint.ts — 측점(중심 · 진행 방향) + 횡단 설계 결과 → 도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금 - 짝 = B07_DesignDetail_Engine_Cad_PlanSlope.py(route_frame · side_is_cut · slope_end · slope_pieces) · 머리에 짝 표시 - 거울 시험 test_346_2_plan_footprint_mirror.py · 그리기는 2파(sub2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_PlanFootprint.ts
|
||||
* 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2)
|
||||
*
|
||||
* ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py`
|
||||
* (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면
|
||||
* 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다.
|
||||
* `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄.
|
||||
* 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
|
||||
*
|
||||
* 순수 계산 — 화면 · three · fetch · sessionStorage 없음 · 좌표 = 사업지 m · 오프셋 좌(+) · 우(-).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 횡단 설계선 · 지반 표본 한 점(`CrossDesignEdge` · `CrossGroundSample` 꼴). */
|
||||
export interface FootprintPoint {
|
||||
offset_m?: number | null;
|
||||
elevation_m?: number | null;
|
||||
valid?: boolean;
|
||||
}
|
||||
|
||||
/** 측점 한 자리 — 중심 · 진행 방향 · 그 측점 횡단 설계 결과 · 지반 표본. */
|
||||
export interface FootprintStation {
|
||||
chainage_m: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** 진행 방향(크기 무관) — 좌측 법선 = (−ty, tx). */
|
||||
tx: number;
|
||||
ty: number;
|
||||
design: {
|
||||
road_edges?: {
|
||||
left?: FootprintPoint | null;
|
||||
right?: FootprintPoint | null;
|
||||
} | null;
|
||||
design_line?: FootprintPoint[] | null;
|
||||
} | null;
|
||||
samples: FootprintPoint[];
|
||||
}
|
||||
|
||||
/** `slope_pieces` 의 측점 줄. */
|
||||
export interface FootprintRecord {
|
||||
chainage_m: number;
|
||||
left_edge_m: number;
|
||||
left_end_m: number;
|
||||
right_edge_m: number;
|
||||
right_end_m: number;
|
||||
left_cut?: boolean;
|
||||
right_cut?: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
nx?: number;
|
||||
ny?: number;
|
||||
}
|
||||
|
||||
type Line = number[][];
|
||||
|
||||
export interface PlanFootprint {
|
||||
/** 도로 끝 [좌, 우]. */
|
||||
edge: Line[];
|
||||
/** 비탈 끝 — 쌓기(성토) 구간 · 깎기(절토) 구간. */
|
||||
fill: Line[];
|
||||
cut: Line[];
|
||||
/** 비탈 눈금 — 도로 끝에서 비탈 끝으로(긴 · 짧은 번갈아). */
|
||||
ticks: Line[];
|
||||
}
|
||||
|
||||
/** 비탈 눈금 간격(1/1,200 실거리 m). 짝: `TICK_STEP_M`. */
|
||||
export const TICK_STEP_M = 2.0;
|
||||
|
||||
type Side = "left" | "right";
|
||||
const SIDES: Side[] = ["left", "right"];
|
||||
const KEYS = ["left_edge_m", "left_end_m", "right_edge_m", "right_end_m"] as const;
|
||||
|
||||
/** 파이썬 `round(v, n)` 대용 — 반올림 경계(…5)만 다를 수 있음(1e-n 안). */
|
||||
const roundTo = (value: number, digits: number): number => {
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
};
|
||||
|
||||
function bisectLeft(values: number[], target: number): number {
|
||||
let low = 0;
|
||||
let high = values.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1;
|
||||
if (values[middle] < target) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/** 노선 [누가거리, x, y] 위 그 자리와 좌측 법선 [x, y, nx, ny]. 짝: `route_frame`. */
|
||||
export function routeFrame(route: Array<[number, number, number]>, chainage: number): number[] {
|
||||
const chainages = route.map((item) => item[0]);
|
||||
const index = Math.min(Math.max(bisectLeft(chainages, chainage), 1), route.length - 1);
|
||||
const [c0, x0, y0] = route[index - 1];
|
||||
const [c1, x1, y1] = route[index];
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const length = Math.hypot(dx, dy) || 1.0;
|
||||
const ratio = c1 > c0 ? (chainage - c0) / (c1 - c0) : 0.0;
|
||||
return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length];
|
||||
}
|
||||
|
||||
function interp(records: Array<Record<string, number>>, chainage: number, key: string): number {
|
||||
const chainages = records.map((record) => record.chainage_m);
|
||||
const index = bisectLeft(chainages, chainage);
|
||||
if (index <= 0) return records[0][key];
|
||||
if (index >= records.length) return records[records.length - 1][key];
|
||||
const before = records[index - 1];
|
||||
const after = records[index];
|
||||
const span = after.chainage_m - before.chainage_m;
|
||||
const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0;
|
||||
return before[key] + (after[key] - before[key]) * ratio;
|
||||
}
|
||||
|
||||
function groundOf(samples: FootprintPoint[]): Array<[number, number]> {
|
||||
return samples
|
||||
.filter(
|
||||
(item) =>
|
||||
("valid" in item ? Boolean(item.valid) : true) && typeof item.elevation_m === "number",
|
||||
)
|
||||
.map((item): [number, number] => [Number(item.offset_m), Number(item.elevation_m)])
|
||||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
}
|
||||
|
||||
function groundAt(ground: Array<[number, number]>, offset: number): number {
|
||||
const offsets = ground.map(([x]) => x);
|
||||
const index = Math.min(Math.max(bisectLeft(offsets, offset), 1), ground.length - 1);
|
||||
const [x0, z0] = ground[index - 1] ?? ground[ground.length - 1];
|
||||
const [x1, z1] = ground[index];
|
||||
return z0 + (z1 - z0) * (x1 > x0 ? (offset - x0) / (x1 - x0) : 0.0);
|
||||
}
|
||||
|
||||
/** 도로 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 깎기. 짝: `side_is_cut`. */
|
||||
export function sideIsCut(samples: FootprintPoint[], edge: number, elevation: number): boolean {
|
||||
const ground = groundOf(samples);
|
||||
return ground.length >= 2 && groundAt(ground, edge) > elevation;
|
||||
}
|
||||
|
||||
/** 도로 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 짝: `slope_end`. */
|
||||
export function slopeEnd(
|
||||
designLine: FootprintPoint[],
|
||||
samples: FootprintPoint[],
|
||||
edge: number,
|
||||
side: Side,
|
||||
): number {
|
||||
const ground = groundOf(samples);
|
||||
const points = designLine
|
||||
.map((p): [number, number] => [Number(p.offset_m), Number(p.elevation_m)])
|
||||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const outward =
|
||||
side === "left"
|
||||
? points.filter((p) => p[0] > edge)
|
||||
: [...points].reverse().filter((p) => p[0] < edge);
|
||||
if (!ground.length) return outward.length ? outward[outward.length - 1][0] : edge;
|
||||
let end = edge;
|
||||
let differs = false;
|
||||
for (const [offset, elevation] of outward) {
|
||||
if (Math.abs(elevation - groundAt(ground, offset)) <= 1e-3) return differs ? offset : end;
|
||||
differs = true;
|
||||
end = offset;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/** 측점 한 자리 → `slope_pieces` 줄 · 도로 끝이 없으면 null. 짝: `slope_records` 한 줄. */
|
||||
export function footprintRecord(station: FootprintStation): FootprintRecord | null {
|
||||
const edges = station.design?.road_edges;
|
||||
const left = Number(edges?.left?.offset_m);
|
||||
const right = Number(edges?.right?.offset_m);
|
||||
if (edges?.left?.offset_m == null || edges?.right?.offset_m == null) return null;
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
|
||||
const line = station.design?.design_line ?? [];
|
||||
const samples = station.samples ?? [];
|
||||
const record: FootprintRecord = {
|
||||
chainage_m: station.chainage_m,
|
||||
left_edge_m: left,
|
||||
left_end_m: slopeEnd(line, samples, left, "left"),
|
||||
right_edge_m: right,
|
||||
right_end_m: slopeEnd(line, samples, right, "right"),
|
||||
};
|
||||
for (const side of SIDES) {
|
||||
const elevation = edges[side]?.elevation_m;
|
||||
if (typeof elevation === "number") {
|
||||
record[`${side}_cut`] = sideIsCut(samples, record[`${side}_edge_m`], elevation);
|
||||
}
|
||||
}
|
||||
const length = Math.hypot(station.tx, station.ty) || 1.0;
|
||||
record.x = station.x;
|
||||
record.y = station.y;
|
||||
record.nx = -station.ty / length;
|
||||
record.ny = station.tx / length;
|
||||
return record;
|
||||
}
|
||||
|
||||
/** 한 선을 참 · 거짓 구간으로 끊음 — 바뀌는 자리 점은 앞뒤가 나눠 가져 이어짐. */
|
||||
function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] {
|
||||
const runs: { true: Line[]; false: Line[] } = { true: [], false: [] };
|
||||
let current: Line = [];
|
||||
let previous: boolean | null = null;
|
||||
points.forEach((point, index) => {
|
||||
const flag = flags[index];
|
||||
if (flag !== previous) {
|
||||
if (current.length) current.push(point);
|
||||
current = [point];
|
||||
runs[`${flag}`].push(current);
|
||||
previous = flag;
|
||||
} else {
|
||||
current.push(point);
|
||||
}
|
||||
});
|
||||
return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)];
|
||||
}
|
||||
|
||||
/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */
|
||||
export function slopePieces(
|
||||
route: Array<[number, number, number]>,
|
||||
input: FootprintRecord[],
|
||||
startM: number,
|
||||
endM: number,
|
||||
stepM: number = TICK_STEP_M,
|
||||
): PlanFootprint {
|
||||
const records = input
|
||||
.map((record) => ({
|
||||
...record,
|
||||
left_cut: Number(Boolean(record.left_cut)),
|
||||
right_cut: Number(Boolean(record.right_cut)),
|
||||
}))
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array<Record<string, number>>;
|
||||
if (route.length < 2 || !records.length) return { edge: [], fill: [], cut: [], ticks: [] };
|
||||
const byChainage = new Map<number, Record<string, number>>();
|
||||
for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record);
|
||||
const samples = new Set<number>();
|
||||
for (const record of records) {
|
||||
if (startM <= record.chainage_m && record.chainage_m <= endM)
|
||||
samples.add(roundTo(record.chainage_m, 3));
|
||||
}
|
||||
for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3));
|
||||
const lines: Record<string, Line> = Object.fromEntries(KEYS.map((key) => [key, []]));
|
||||
const cut: Record<Side, boolean[]> = { left: [], right: [] };
|
||||
const ticks: Line[] = [];
|
||||
[...samples]
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((chainage, index) => {
|
||||
const record = byChainage.get(chainage);
|
||||
const [x, y, nx, ny] =
|
||||
record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined)
|
||||
? [record.x, record.y, record.nx, record.ny]
|
||||
: routeFrame(route, chainage);
|
||||
const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)]));
|
||||
const points = Object.fromEntries(
|
||||
KEYS.map((key) => [
|
||||
key,
|
||||
[roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)],
|
||||
]),
|
||||
);
|
||||
for (const key of KEYS) lines[key].push(points[key]);
|
||||
for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5);
|
||||
// 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례).
|
||||
for (const side of SIDES) {
|
||||
const edge = offsets[`${side}_edge_m`];
|
||||
const end = offsets[`${side}_end_m`];
|
||||
if (Math.abs(end - edge) < 0.05) continue;
|
||||
const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0;
|
||||
ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]);
|
||||
}
|
||||
});
|
||||
const cutLines: Line[] = [];
|
||||
const fillLines: Line[] = [];
|
||||
for (const side of SIDES) {
|
||||
const [cutRuns, fillRuns] = splitRuns(lines[`${side}_end_m`], cut[side]);
|
||||
cutLines.push(...cutRuns);
|
||||
fillLines.push(...fillRuns);
|
||||
}
|
||||
return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks };
|
||||
}
|
||||
|
||||
/** 측점들 → 평면 발자국(도로 끝이 없는 측점은 뺌). */
|
||||
export function planFootprint(
|
||||
route: Array<[number, number, number]>,
|
||||
stations: FootprintStation[],
|
||||
startM: number,
|
||||
endM: number,
|
||||
stepM: number = TICK_STEP_M,
|
||||
): PlanFootprint {
|
||||
const records = stations
|
||||
.map(footprintRecord)
|
||||
.filter((record): record is FootprintRecord => record !== null);
|
||||
return slopePieces(route, records, startM, endM, stepM);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장).
|
||||
|
||||
`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m.
|
||||
|
||||
⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end ·
|
||||
slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
|
||||
거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""346-2 평면 발자국 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬
|
||||
(`B07_DesignDetail_Engine_Cad_PlanSlope.py` · `slope_records` 한 줄) 거울 시험.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_346_2_plan_footprint_mirror.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402
|
||||
route_frame,
|
||||
side_is_cut,
|
||||
slope_end,
|
||||
slope_pieces,
|
||||
)
|
||||
|
||||
TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
TOL = 2e-4 # 점 좌표 round(·, 4) — 반올림 경계만 1e-4 갈릴 수 있음
|
||||
|
||||
VERTICES = [(0.0, 0.0), (100.0, 0.0), (160.0, 60.0), (170.0, 140.0)]
|
||||
|
||||
|
||||
def _route() -> list[tuple[float, float, float]]:
|
||||
route, chain = [], 0.0
|
||||
for index, (x, y) in enumerate(VERTICES):
|
||||
if index:
|
||||
chain += math.hypot(x - VERTICES[index - 1][0], y - VERTICES[index - 1][1])
|
||||
route.append((chain, x, y))
|
||||
return route
|
||||
|
||||
|
||||
def _section(chainage: float) -> tuple[dict, list[dict]]:
|
||||
"""측점 횡단 — 지반 기울기가 측점마다 바뀌어 깎기 · 쌓기가 뒤집힘 · 노면 ±2.5 m."""
|
||||
tilt = 0.4 * math.sin(chainage / 37.0)
|
||||
road = 100.0 + 0.6 * math.cos(chainage / 23.0)
|
||||
offsets = [-20.0 + 0.5 * i for i in range(81)]
|
||||
|
||||
def ground(o: float) -> float:
|
||||
return 100.0 + tilt * o + 0.3 * math.sin(o / 3.0)
|
||||
|
||||
samples = [
|
||||
{"offset_m": o, "elevation_m": ground(o), "valid": abs(o) < 18.5 or chainage < 100}
|
||||
for o in offsets
|
||||
]
|
||||
line = []
|
||||
for o in offsets:
|
||||
if abs(o) <= 2.5:
|
||||
line.append({"offset_m": o, "elevation_m": road})
|
||||
continue
|
||||
run = abs(o) - 2.5
|
||||
g = ground(o)
|
||||
edge_g = ground(2.5 if o > 0 else -2.5)
|
||||
z = min(g, road + run) if edge_g > road else max(g, road - run / 1.5)
|
||||
line.append({"offset_m": o, "elevation_m": z})
|
||||
design = {
|
||||
"road_edges": {
|
||||
"left": {"offset_m": 2.5, "elevation_m": road},
|
||||
"right": {"offset_m": -2.5, "elevation_m": road},
|
||||
},
|
||||
"design_line": line,
|
||||
}
|
||||
return design, samples
|
||||
|
||||
|
||||
def _stations() -> list[dict]:
|
||||
route = _route()
|
||||
total = route[-1][0]
|
||||
stations = []
|
||||
chainages = [20.0 * i for i in range(int(total // 20) + 1)] + [total, 60.0] # 겹친 측점 하나
|
||||
for chainage in chainages:
|
||||
x, y, nx, ny = route_frame(route, chainage)
|
||||
design, samples = _section(chainage)
|
||||
stations.append(
|
||||
{
|
||||
"chainage_m": chainage,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"tx": ny,
|
||||
"ty": -nx,
|
||||
"design": design,
|
||||
"samples": samples,
|
||||
}
|
||||
)
|
||||
stations.append({**stations[3], "chainage_m": 70.0, "design": {"road_edges": None}})
|
||||
stations.append({**stations[4], "chainage_m": 90.0, "samples": []}) # 지반 없음
|
||||
return stations
|
||||
|
||||
|
||||
def _py_record(station: dict) -> dict | None:
|
||||
"""`B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄(파일 읽기 뺌)."""
|
||||
design = station["design"] or {}
|
||||
edges = design.get("road_edges") or {}
|
||||
try:
|
||||
left_edge = float(edges["left"]["offset_m"])
|
||||
right_edge = float(edges["right"]["offset_m"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
line, samples = design.get("design_line") or [], station["samples"] or []
|
||||
record = {
|
||||
"chainage_m": float(station["chainage_m"]),
|
||||
"left_edge_m": left_edge,
|
||||
"left_end_m": slope_end(line, samples, left_edge, "left"),
|
||||
"right_edge_m": right_edge,
|
||||
"right_end_m": slope_end(line, samples, right_edge, "right"),
|
||||
}
|
||||
for side in ("left", "right"):
|
||||
elevation = edges[side].get("elevation_m")
|
||||
if isinstance(elevation, (int, float)):
|
||||
record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation)
|
||||
length = math.hypot(station["tx"], station["ty"]) or 1.0
|
||||
record.update(
|
||||
x=station["x"], y=station["y"], nx=-station["ty"] / length, ny=station["tx"] / length
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
DRIVER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { planFootprint } from "./B05_Profile_PlanFootprint.js";
|
||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf-8"));
|
||||
writeFileSync(process.argv[3], JSON.stringify(cases.map((c) =>
|
||||
planFootprint(c.route, c.stations, c.start, c.end, c.step))));
|
||||
"""
|
||||
|
||||
CASES = [(0.0, 400.0, 2.0), (35.0, 130.0, 2.0), (0.0, 400.0, 10.0)]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_result(tmp_path_factory: pytest.TempPathFactory) -> list[dict]:
|
||||
out = tmp_path_factory.mktemp("ts_footprint")
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext",
|
||||
"--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig"],
|
||||
cwd=str(ROOT), check=True, capture_output=True,
|
||||
) # fmt: skip
|
||||
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
|
||||
payload = [
|
||||
{"route": _route(), "stations": _stations(), "start": s, "end": e, "step": t}
|
||||
for s, e, t in CASES
|
||||
]
|
||||
(out / "in.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
run = subprocess.run( # noqa: S603
|
||||
["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")],
|
||||
capture_output=True, text=True, cwd=str(out),
|
||||
) # fmt: skip
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads((out / "out.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _same_lines(want: list, got: list, name: str) -> None:
|
||||
assert len(want) == len(got), f"{name} 선 수"
|
||||
for line_w, line_g in zip(want, got):
|
||||
assert len(line_w) == len(line_g), f"{name} 점 수"
|
||||
for pw, pg in zip(line_w, line_g):
|
||||
assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, f"{name} {pw} {pg}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(CASES)))
|
||||
def test_발자국이_두_쪽에서_같다(ts_result, index: int) -> None:
|
||||
start, end, step = CASES[index]
|
||||
records = [r for r in map(_py_record, _stations()) if r is not None]
|
||||
edge, fill, cut, ticks = slope_pieces(_route(), records, start, end, step)
|
||||
got = ts_result[index]
|
||||
_same_lines(edge["lines"], got["edge"], "도로 끝")
|
||||
_same_lines(fill["lines"], got["fill"], "쌓기")
|
||||
_same_lines(cut["lines"], got["cut"], "깎기")
|
||||
_same_lines(ticks["ticks"], got["ticks"], "눈금")
|
||||
|
||||
|
||||
def test_깎기_쌓기가_함께_나옴(ts_result) -> None:
|
||||
got = ts_result[0]
|
||||
assert len(got["edge"]) == 2 and got["fill"] and got["cut"] and got["ticks"]
|
||||
Reference in New Issue
Block a user