feat(계산묶음): 346-1 묶음을 B05 페이지 · 노선 편집에 붙임 — 노선 초안 캐시 칸 · 노선 고칠 때마다 종단 따라옴 · ▲▼ = grade · 단계 붙임
- design_pipeline_page.ts 새 — 프로젝트마다 묶음 하나(종단도 dispose 때 버림) · route · ground(sub8) · follow · planline · profile · stations · footprint(sub6) 붙임 · cross · masshaul · haul · drainage 는 붙으면 한 줄 · 첫 계산은 처음 고칠 때 · 가게 stations · profile · footprint · generation - B05_Profile_UI_RouteEdit_Draft.ts 새 — 사진첩 감싸기(쌓기 · 되돌리기 · 다시하기 · 열 때로 = 초안 넘김 · 같으면 null) · 저장 안 한 초안 되살림 · RouteEdit.ts 는 줄 셋(700줄 안) - logic/B05_Profile_Pipeline_Grade.ts 새 — follow(관 · 구조물 좌표 · 계획고 옮김) · planline(저장 기준 · 초안이면 바뀐 구간만 새 지반 첫 계획선) · profile(buildAlignment) · routeRange · gradeRange — sub1 짝 부르기만 - design_profile — listed 를 design_profile_base.ts 로 나눔 · rebuild = pipelineGrade · render · 구조물 측점 = 다시 셈 · adoptPipeline(초안 동안 편집은 메모리만 · [저장]이 안 보냄) · pipelineSource - design_pipeline — input() · dispose() - 시험 test_346_1_pipeline_page 12 건(실제 단계 · 노선 1,078 m 점 옮김 23 ms · 늦은 응답 버림 · 초안 캐시 칸 살아남음) · popout 시험 문맥에 pipelineGrade 자리 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
@@ -96,6 +96,8 @@ out.viewOf = {
|
||||
alignment: null,
|
||||
buildAlignment: () => null,
|
||||
store: { edits: () => ({}) },
|
||||
projectId: "p",
|
||||
pipelineGrade: () => undefined, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
|
||||
scheduleCrossPreview: () => s.previews++,
|
||||
draw: () => s.draws++,
|
||||
root: inPopup(),
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/* PLAN 346-1 단위검증 헬퍼 — 계산 묶음을 페이지 · 노선 편집에 붙인 것.
|
||||
* 진짜 TS(`design_pipeline` · `design_pipeline_page` · `design_pipeline_stations` · `logic/*Pipeline*` ·
|
||||
* `_RouteEdit_Draft` · sub1 · sub8 · sub6 짝)를 그 자리에서 트랜스파일해 Node 로 돌림. 서버 창구
|
||||
* (`Api_Replan` · `Api_Structures` · `Api_TerrainGrid`)만 가짜. 입력 argv[2] JSON · argv[3] 격자 이진.
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_346_1_pipeline_page.py`. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { sessionStorage: session, localStorage: memoryStorage() };
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
|
||||
const STUBS = {};
|
||||
const cache = new Map();
|
||||
function loadTs(full) {
|
||||
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) => {
|
||||
if (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
const key = Object.keys(STUBS).find((name) => request.endsWith(name));
|
||||
if (key) return STUBS[key];
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const load = (...parts) => loadTs(path.join(ROOT, ...parts));
|
||||
|
||||
const sampler = load("common_util", "common_util_surface_sampler.ts");
|
||||
const bytes = fs.readFileSync(process.argv[3]);
|
||||
const grid = sampler.parseDtmGrid(
|
||||
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length),
|
||||
);
|
||||
const local = { grid, design: "las" };
|
||||
|
||||
let remoteCalls = 0;
|
||||
STUBS["B05_Profile_Api_Replan"] = {
|
||||
analysisTerrain: () => "las",
|
||||
fetchRoutePlan: async () => ({ planned: job.line, curves: [], min_radius_m: job.minRadius }),
|
||||
};
|
||||
STUBS["B05_Profile_Api_Structures"] = {
|
||||
fetchStructures: async () => ({ structures: [] }),
|
||||
fetchStructureTypes: async () => job.types,
|
||||
};
|
||||
STUBS["B05_Profile_Api_TerrainGrid"] = {
|
||||
designTerrainGridNow: () => local,
|
||||
groundStageDeps: () => ({ grid: () => local, remote: async () => (remoteCalls++, []) }),
|
||||
};
|
||||
|
||||
const pipeMod = load("A00_Common", "design", "design_pipeline.ts");
|
||||
const pageMod = load("A00_Common", "design", "design_pipeline_page.ts");
|
||||
const stationsMod = load("A00_Common", "design", "design_pipeline_stations.ts");
|
||||
const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts");
|
||||
const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts");
|
||||
const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts");
|
||||
const footprint = load("B05_Profile", "logic", "B05_Profile_Footprint_Stage.ts");
|
||||
const alignment = load("B05_Profile", "B05_Profile_UI_Profile_Alignment.ts");
|
||||
const draftLink = load("B05_Profile", "B05_Profile_UI_RouteEdit_Draft.ts");
|
||||
const page = load("A00_Common", "b_page_state.ts");
|
||||
|
||||
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const n = job.nodes.length;
|
||||
const draftOf = (nodes) => ({
|
||||
rev: 1,
|
||||
nodes,
|
||||
curveOn: Array(n).fill(true),
|
||||
curveRadius: Array(n).fill(null),
|
||||
curveLock: Array(n).fill(null),
|
||||
curveArc: Array(n).fill(null),
|
||||
stationSnap: false,
|
||||
terrain: null,
|
||||
});
|
||||
const movedNodes = job.nodes.map((node, i) => (i === n - 1 ? [node[0] + 6, node[1] - 4] : node));
|
||||
const inputs = (route = null, edits = alignment.emptyEdits()) => ({
|
||||
route,
|
||||
alignment: edits,
|
||||
cross: null,
|
||||
pipes: [],
|
||||
structures: [],
|
||||
});
|
||||
|
||||
/** 단계 한 벌(실제) — 저장 기준은 부르는 쪽. */
|
||||
function realStages(savedBase, remote = null) {
|
||||
return {
|
||||
route: routeGround.createRouteStage({
|
||||
minRadiusM: () => job.minRadius,
|
||||
savedLine: () => ({ line: job.line, curves: [] }),
|
||||
}),
|
||||
follow: grade.followStage,
|
||||
stations: stationsMod.createStationsStage({ types: () => job.types }),
|
||||
ground: routeGround.createGroundStage(
|
||||
remote ? { grid: () => null, remote } : { grid: () => local, remote: async () => [] },
|
||||
),
|
||||
planline: grade.createPlanlineStage({ savedBase: () => savedBase, savedLine: () => job.line }),
|
||||
profile: grade.profileStage,
|
||||
footprint: footprint.footprintStage,
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const out = { n };
|
||||
|
||||
// ⓪ 저장 기준 — 저장 노선 지반으로 첫 계획선(서버 정책 그대로)
|
||||
const seed = pipeMod.createDesignPipeline({
|
||||
stages: { ...realStages(null), planline: undefined, profile: undefined, footprint: undefined },
|
||||
});
|
||||
await seed.start(inputs());
|
||||
const ground0 = seed.output("ground");
|
||||
const samples = ground0.longitudinal.chainageM.map((c, i) => ({
|
||||
chainage_m: c,
|
||||
elevation_m: ground0.longitudinal.z[i],
|
||||
}));
|
||||
const profile0 = gradeGround.groundProfile(samples);
|
||||
const savedBase = {
|
||||
policy: job.policy,
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: gradeGround.groundFollowingBasePvi({
|
||||
samples,
|
||||
stations: ground0.stations.map((s) => s.chainageM),
|
||||
policy: job.policy,
|
||||
}),
|
||||
chainage: profile0.chainage,
|
||||
ground: profile0.ground,
|
||||
stations: ground0.stations.map((s) => ({
|
||||
station_id: grade.stationIdOf(s.chainageM),
|
||||
chainage_m: s.chainageM,
|
||||
})),
|
||||
};
|
||||
|
||||
// ① 실제 단계 한 번 — 저장 노선 · 노선 한 점 옮김 · 계획고 한 점
|
||||
{
|
||||
const pipe = pipeMod.createDesignPipeline({ stages: realStages(savedBase) });
|
||||
const first = await pipe.start(inputs());
|
||||
const savedPlanline = pipe.output("planline") === savedBase;
|
||||
const beforeLine = pipe.output("route").line;
|
||||
const beforeKeys = pipe.output("ground").stations.map((s) => [s.key, s.chainageM]);
|
||||
const draft = draftOf(movedNodes);
|
||||
const after = routeGround.draftLine(draft, job.minRadius).line;
|
||||
const range = grade.routeRange(beforeLine, after);
|
||||
const routeRun = await pipe.update("route", draft, range);
|
||||
const moved = {
|
||||
basePvi: pipe.output("planline").basePvi,
|
||||
stations: pipe.output("ground").stations.map((s) => [s.key, s.chainageM]),
|
||||
profileStations: pipe.output("profile").stations.length,
|
||||
footprint: pipe.output("footprint") !== null,
|
||||
};
|
||||
const nodes = pipe.output("planline").basePvi.map((p) => p.chainage_m);
|
||||
const target = nodes[Math.floor(nodes.length / 2)];
|
||||
const edits = { station_offsets: { [alignment.chainageKey(target)]: 0.8 }, curve_radii: {} };
|
||||
const gRange = grade.gradeRange(alignment.emptyEdits(), edits, nodes);
|
||||
const gradeRun = await pipe.update("grade", edits, gRange);
|
||||
// 꼭짓점 높이(종단곡선은 그 아래로 둥글게 지나감)
|
||||
const pviAt = (profile) =>
|
||||
profile.pvi.find((p) => Math.abs(p.chainage_m - target) < 1e-6).elevation_m;
|
||||
const raised = pviAt(pipe.output("profile"));
|
||||
const plain = pviAt(alignment.buildAlignment(pipe.output("planline"), alignment.emptyEdits()));
|
||||
out.real = {
|
||||
first: { outcome: first.outcome, totalMs: first.totalMs, stages: first.stages },
|
||||
savedPlanline,
|
||||
range,
|
||||
routeRun: {
|
||||
outcome: routeRun.outcome,
|
||||
totalMs: routeRun.totalMs,
|
||||
stages: routeRun.stages,
|
||||
skipped: routeRun.skipped,
|
||||
},
|
||||
savedBasePvi: savedBase.basePvi,
|
||||
moved,
|
||||
beforeKeys,
|
||||
gradeRange: gRange,
|
||||
target,
|
||||
gradeRun: {
|
||||
outcome: gradeRun.outcome,
|
||||
stages: Object.keys(gradeRun.stages),
|
||||
skipped: gradeRun.skipped,
|
||||
totalMs: gradeRun.totalMs,
|
||||
},
|
||||
raised: raised - plain,
|
||||
log: globalThis.__aisloPipeline.runs.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ② 늦은 응답 버림 — 격자 없음(서버 길) · 첫 지반 응답이 늦는 사이 노선을 또 옮김
|
||||
{
|
||||
const waits = [];
|
||||
const remote = (points, _terrain, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
waits.push(() => resolve(points.map(([x, y]) => sampler.heightAt(grid, x, y))));
|
||||
signal.addEventListener("abort", () => reject(new Error("aborted")));
|
||||
});
|
||||
const pipe = pipeMod.createDesignPipeline({ stages: realStages(savedBase, remote) });
|
||||
const commits = [];
|
||||
pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`));
|
||||
const a = pipe.start(inputs(draftOf(movedNodes)));
|
||||
await tick(5);
|
||||
const b = pipe.update("route", draftOf(job.nodes), { fromM: 0, toM: Infinity });
|
||||
await tick(5);
|
||||
waits.forEach((go) => go());
|
||||
const ra = await a;
|
||||
await tick(5);
|
||||
waits.slice(1).forEach((go) => go());
|
||||
const rb = await b;
|
||||
out.late = {
|
||||
a: ra.outcome,
|
||||
b: rb.outcome,
|
||||
commits,
|
||||
profileGen: commits.filter((c) => c.startsWith("profile")),
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 편집기 다리 — 사진첩 감싸기 · 초안 되살림
|
||||
{
|
||||
const told = [];
|
||||
let dirty = true;
|
||||
const fake = {
|
||||
commit: () => undefined,
|
||||
undo: () => ({
|
||||
planned: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
curveOn: [true, true],
|
||||
curveRadius: [null, null],
|
||||
curveLock: [null, null],
|
||||
curveArc: [null, null],
|
||||
apexLock: [false, false],
|
||||
picked: -1,
|
||||
}),
|
||||
redo: () => null,
|
||||
reset: () => ({
|
||||
planned: [[0, 0]],
|
||||
curveOn: [true],
|
||||
curveRadius: [null],
|
||||
curveLock: [null],
|
||||
curveArc: [null],
|
||||
apexLock: [false],
|
||||
picked: -1,
|
||||
}),
|
||||
canUndo: () => true,
|
||||
canRedo: () => false,
|
||||
isDirty: () => dirty,
|
||||
};
|
||||
const bridge = {
|
||||
edited: (d) => told.push(d && d.nodes.length),
|
||||
saved: () => draftOf(job.nodes),
|
||||
};
|
||||
const watched = draftLink.watch(fake, bridge, () => true);
|
||||
watched.commit({
|
||||
...fake.undo(),
|
||||
planned: [
|
||||
[0, 0],
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
});
|
||||
watched.undo();
|
||||
watched.redo();
|
||||
dirty = false;
|
||||
watched.reset();
|
||||
const restored = [];
|
||||
draftLink.restore(bridge, (snap, message, record) =>
|
||||
restored.push([snap.planned.length, record]),
|
||||
);
|
||||
draftLink.restore({ saved: () => null }, () => restored.push("none"));
|
||||
out.draftLink = {
|
||||
told,
|
||||
restored,
|
||||
unwatched: draftLink.watch(fake, undefined, () => false) === fake,
|
||||
};
|
||||
}
|
||||
|
||||
// ④ 페이지 묶음 — 다리 → 노선 초안 캐시 칸 · 종단도 받기 · 떠남 · 다시 엶(초안 살아남음) · ▲▼
|
||||
{
|
||||
const P = "proj-3461";
|
||||
const adopted = [];
|
||||
let edits = alignment.emptyEdits();
|
||||
const link = {
|
||||
pipelineSource: () => ({ base: savedBase, edits }),
|
||||
adoptPipeline: (base, nextEdits, draft) =>
|
||||
adopted.push({ draft, saved: base === savedBase, stations: base.stations.length }),
|
||||
};
|
||||
const bridge = pageMod.routeDraftBridge(P, link, { stationIntervalM: 20 });
|
||||
await tick(10);
|
||||
const before = { saved: bridge.saved(), pipe: pageMod.pagePipeline(P) !== null };
|
||||
const edit = { ...draftOf(movedNodes) };
|
||||
delete edit.rev;
|
||||
delete edit.terrain;
|
||||
bridge.edited(edit);
|
||||
await tick(50);
|
||||
const key = `aislo:draft:route-draft:${P}`;
|
||||
const cached = JSON.parse(session.getItem(key));
|
||||
bridge.edited(edit); // 같은 노선 — 판 번호 그대로 · 다시 안 셈
|
||||
const sameRev = JSON.parse(session.getItem(key)).rev;
|
||||
await tick(50);
|
||||
const adoptedDraft = adopted.slice();
|
||||
bridge.edited(null); // 열 때로(되돌리기 끝)
|
||||
await tick(50);
|
||||
const afterRevert = { session: session.getItem(key), last: adopted[adopted.length - 1] };
|
||||
bridge.edited(edit);
|
||||
await tick(50);
|
||||
pageMod.disposePagePipeline(P);
|
||||
const disposed = pageMod.pagePipeline(P);
|
||||
adopted.length = 0;
|
||||
const again = pageMod.routeDraftBridge(P, link, { stationIntervalM: 20 });
|
||||
const survived = again.saved();
|
||||
await tick(80); // 받기 끝 → 저장 안 한 초안이 있으면 그 노선으로 셈
|
||||
const reopened = adopted.slice();
|
||||
edits = { station_offsets: { "100.000": 0.5 }, curve_radii: {} };
|
||||
pageMod.pipelineGrade(P, edits);
|
||||
await tick(30);
|
||||
const gradeRuns = globalThis.__aisloPipeline.runs.slice(-1)[0];
|
||||
page.clearDrafts(P);
|
||||
out.page = {
|
||||
before,
|
||||
cached: { rev: cached.rev, terrain: cached.terrain, nodes: cached.nodes.length },
|
||||
sameRev,
|
||||
adoptedDraft,
|
||||
afterRevert,
|
||||
disposed,
|
||||
survived: survived && survived.nodes.length,
|
||||
reopened,
|
||||
grade: { sources: gradeRuns.sources, stages: Object.keys(gradeRuns.stages) },
|
||||
clearedDraft: session.getItem(key),
|
||||
remoteCalls,
|
||||
};
|
||||
pageMod.disposePagePipeline(P);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
run()
|
||||
.then((out) => {
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(String(error && error.stack ? error.stack : error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
"""PLAN 346-1 — 계산 묶음을 B05 페이지 · 노선 편집에 붙임.
|
||||
|
||||
· 실제 단계 한 번(route · follow · stations · ground · planline · profile · footprint) — 노선 한 점 옮김
|
||||
뒤 종단까지 0.3 s 안 · 바뀐 구간 앞 첫 계획선 꼭짓점 · 측점 열쇠 그대로 · 계획고 한 점은 측점 · 지반 안 셈
|
||||
· 늦은 응답 버림(격자 없음 · 서버 길 지반이 늦는 사이 노선을 또 옮김)
|
||||
· 편집기 다리(사진첩 감싸기 · 열 때와 같으면 null · 초안 되살림)
|
||||
· 페이지 묶음 — 노선 초안 캐시 칸 · 종단도가 받음 · 떠났다 다시 열어도 초안 살아남음 · ▲▼ = grade
|
||||
짝: `helper_346_1_pipeline_page.cjs`(실제 TS 를 Node 로 돌림 · 서버 창구만 가짜).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
|
||||
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
|
||||
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
PROJECT = ROOT / "storage/1/3/1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
|
||||
REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
|
||||
REAL_NODES = PROJECT / "B05_Profile/route/planned_route_initial_nodes.csv"
|
||||
MIN_RADIUS_M = 12.0
|
||||
BUDGET_MS = 300.0
|
||||
|
||||
|
||||
def _types() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"type_id": t.type_id,
|
||||
"group": t.group,
|
||||
"placement": t.placement,
|
||||
"managed_by": t.managed_by,
|
||||
}
|
||||
for t in structure_type_map().values()
|
||||
]
|
||||
|
||||
|
||||
def _line(nodes: list[list[float]]) -> list[list[float]]:
|
||||
built = build_planned_polyline(
|
||||
[tuple(n) for n in nodes],
|
||||
min_radius_m=MIN_RADIUS_M,
|
||||
simplify=False,
|
||||
curve_flags=[True] * len(nodes),
|
||||
radii=[None] * len(nodes),
|
||||
)
|
||||
return [list(v) for v in built.vertices]
|
||||
|
||||
|
||||
def _fake(folder: Path) -> tuple[Path, list[list[float]]]:
|
||||
"""가짜 격자 — 칸 폭 고르지 않음 · 빈칸 띠."""
|
||||
rng = np.random.default_rng(3461)
|
||||
x = (np.cumsum(rng.uniform(0.95, 1.1, 260)) + 1000.0).astype(np.float32)
|
||||
y = (np.cumsum(rng.uniform(0.95, 1.1, 160)) + 2000.0).astype(np.float32)
|
||||
xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
|
||||
z = 500 + 0.08 * (xx - 1000) + 0.3 * (yy - 2000) + 2 * np.sin(xx / 13.0)
|
||||
mask = np.ones(z.shape, dtype=bool)
|
||||
mask[60:70, 100:115] = False
|
||||
npz = folder / "dtm_fake.npz"
|
||||
np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=mask)
|
||||
return npz, [[1010.0, 2020.0], [1090.0, 2040.0], [1150.0, 2110.0], [1250.0, 2135.0]]
|
||||
|
||||
|
||||
def _real(_folder: Path) -> tuple[Path, list[list[float]]]:
|
||||
if not (REAL_GRID.is_file() and REAL_NODES.is_file()):
|
||||
pytest.skip("검증_sub8 없음")
|
||||
with REAL_NODES.open(encoding="utf-8") as handle:
|
||||
return REAL_GRID, [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)]
|
||||
|
||||
|
||||
def _run(case) -> dict:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
npz, nodes = case(Path(folder))
|
||||
line = _line(nodes)
|
||||
longitudinal = core.generate_sections(
|
||||
np.asarray(line, dtype=np.float64), DtmGridSampler.from_npz(npz)
|
||||
)["longitudinal"]
|
||||
options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None)
|
||||
policy = design_ground_following_profile(longitudinal, options)[0]["policy"]
|
||||
job = {
|
||||
"nodes": nodes,
|
||||
"line": line,
|
||||
"minRadius": MIN_RADIUS_M,
|
||||
"policy": policy,
|
||||
"types": _types(),
|
||||
}
|
||||
path = Path(folder) / "job.json"
|
||||
path.write_text(json.dumps(job), encoding="utf-8")
|
||||
grid = Path(folder) / "grid.bin"
|
||||
grid.write_bytes(dtm_grid_bytes(npz))
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_346_1_pipeline_page.cjs"), str(path), str(grid)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub8"])
|
||||
def r(request) -> dict:
|
||||
return _run(request.param)
|
||||
|
||||
|
||||
def test_실제_단계_한_번_노선_옮김_종단까지_시간(r):
|
||||
real = r["real"]
|
||||
assert real["first"]["outcome"] == "done" and real["savedPlanline"] is True
|
||||
run = real["routeRun"]
|
||||
assert run["outcome"] == "done"
|
||||
for stage in ("route", "follow", "stations", "ground", "planline", "profile"):
|
||||
assert stage in run["stages"], stage
|
||||
to_profile = sum(
|
||||
run["stages"][s] for s in ("route", "follow", "stations", "ground", "planline", "profile")
|
||||
)
|
||||
assert to_profile < BUDGET_MS, run["stages"]
|
||||
assert run["totalMs"] < BUDGET_MS
|
||||
assert real["moved"]["profileStations"] > 2 and real["moved"]["footprint"] is True
|
||||
|
||||
|
||||
def test_바뀐_구간_앞은_그대로(r):
|
||||
real = r["real"]
|
||||
from_m = real["range"]["fromM"]
|
||||
assert 0 < from_m and real["range"]["toM"] is None # JSON 의 Infinity = null
|
||||
saved = [p for p in real["savedBasePvi"] if p["chainage_m"] < from_m - 1e-6]
|
||||
moved = [p for p in real["moved"]["basePvi"] if p["chainage_m"] < from_m - 1e-6]
|
||||
assert saved and moved == saved
|
||||
before = {k: c for k, c in real["beforeKeys"] if c < from_m - 20}
|
||||
after = dict(real["moved"]["stations"])
|
||||
assert before and all(after.get(k) == c for k, c in before.items())
|
||||
|
||||
|
||||
def test_계획고_한_점은_측점_지반을_안_셈(r):
|
||||
real = r["real"]
|
||||
g = real["gradeRun"]
|
||||
assert g["outcome"] == "done"
|
||||
assert {"route", "follow", "stations", "ground", "planline"} <= set(g["skipped"])
|
||||
assert "profile" in g["stages"]
|
||||
rng = real["gradeRange"]
|
||||
assert rng["fromM"] < real["target"] < (rng["toM"] if rng["toM"] is not None else float("inf"))
|
||||
assert abs(real["raised"] - 0.8) < 1e-6
|
||||
|
||||
|
||||
def test_늦은_응답_버림(r):
|
||||
late = r["late"]
|
||||
assert late["a"] == "dropped" and late["b"] == "done"
|
||||
assert late["profileGen"] == ["profile@2"]
|
||||
assert not [c for c in late["commits"] if c.startswith(("ground@1", "planline@1"))]
|
||||
|
||||
|
||||
def test_편집기_다리(r):
|
||||
d = r["draftLink"]
|
||||
assert d["told"] == [3, 2, None] # 쌓기 · 되돌리기 · 열 때로(null) — 다시하기 없음은 안 알림
|
||||
assert d["restored"] == [
|
||||
[r["n"], True]
|
||||
] # 저장 안 한 초안 = 새 걸음으로 되살림 · 없으면 안 부름
|
||||
assert d["unwatched"] is True
|
||||
|
||||
|
||||
def test_페이지_묶음_노선_초안_캐시_칸(r):
|
||||
p = r["page"]
|
||||
assert p["before"] == {"saved": None, "pipe": True}
|
||||
assert (
|
||||
p["cached"]["rev"] == 1
|
||||
and p["cached"]["terrain"] == "las"
|
||||
and p["cached"]["nodes"] == r["n"]
|
||||
)
|
||||
assert p["sameRev"] == 1 # 같은 노선 = 판 번호 그대로
|
||||
assert p["adoptedDraft"] and all(a["draft"] and not a["saved"] for a in p["adoptedDraft"])
|
||||
assert p["afterRevert"]["session"] is None and p["afterRevert"]["last"]["draft"] is False
|
||||
assert p["disposed"] is None
|
||||
assert p["survived"] == r["n"] # 떠났다 다시 열어도 초안 살아남음
|
||||
assert p["reopened"] and p["reopened"][-1]["draft"] is True
|
||||
assert p["grade"]["sources"] == ["grade"] and "stations" not in p["grade"]["stages"]
|
||||
assert p["clearedDraft"] is None # clearDrafts 가 함께 비움
|
||||
assert p["remoteCalls"] == 0 # 격자 있으면 서버 길 안 탐
|
||||
Reference in New Issue
Block a user