Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7

This commit is contained in:
2026-10-18 22:04:53 +09:00
101 changed files with 3364 additions and 424 deletions
@@ -89,6 +89,7 @@ tools.createPanelTools({
base: () => ({}),
alignment: () => null,
edits: () => ({}),
savedRadii: () => ({}),
applyEdits: () => undefined,
selectedStationId: () => "irregular:p1",
irregularStations: () => [pipe],
+1 -3
View File
@@ -1,5 +1,5 @@
/* 344-3 · 346-2 2파 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
* 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross · masshaul · haul 단계를 화면 · 서버 없이 잰다.
* 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross 단계를 화면 · 서버 없이 잰다.
*/
export {
carryStationDrafts,
@@ -20,8 +20,6 @@ export {
readRockBoundarySession,
} from "../../B06_Section/B06_Section_UI_Page_Persist";
export { createCrossStage, groundSamples } from "../../B06_Section/logic/B06_Section_Cross_Stage";
export { createMassHaulStage } from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage";
export { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
export {
buildAlignment,
emptyEdits,
@@ -202,39 +202,4 @@ const edited = m.buildAlignment(base, { station_offsets: { "40.000": 0.4 }, curv
stage(ctxOf(touched, { sources: ["grade"], fromM: 20, toM: 60, keys: [] }, third, edited));
out.crossGrade = counts();
/* 6. masshaul 단계 — 계열 · 흙 배분 입력 */
const conversion = {
soil: { loose: 1.25, compacted: 0.9 },
ripping_rock: { loose: 1.5, compacted: 1.2 },
blasting_rock: { loose: 1.65, compacted: 1.35 },
};
const massStage = m.createMassHaulStage(() => ({ conversion }));
const mass = massStage({ ...ctxOf(crossInput, null, null), out: { profile, cross: first } });
out.massSeries = mass.series.map((s) => s.basis);
out.massHaulInput = Array.isArray(mass.haulInput?.points);
/* 7. haul 단계 — 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로 */
const posts = [];
globalThis.fetch = async (_url, init) => {
if (init.signal?.aborted) throw new Error("aborted");
posts.push(1);
return { ok: true, json: async () => ({ haul_plan: { n: posts.length } }) };
};
const haul = m.createHaulStage(P, () => R);
const haulCtx = (masshaul, prev, signal = new AbortController().signal) => ({
...ctxOf(crossInput, null, prev),
signal,
out: { masshaul },
});
const plan1 = await haul(haulCtx(mass, null));
const plan2 = await haul(haulCtx(mass, plan1));
const other = massStage({
...ctxOf(crossInput, null, null),
out: { profile: edited, cross: third },
});
const aborted = new AbortController();
aborted.abort();
const plan3 = await haul(haulCtx(other, plan1, aborted.signal));
out.haul = { posts: posts.length, plan1, plan2, plan3 };
process.stdout.write(JSON.stringify(out));
@@ -133,7 +133,7 @@ async function run() {
};
}
// ② 늦은 응답 버림 — 브라우저 단계(ground 늦음) · 뒤 단계(haul 늦음 · 끊김)
// ② 늦은 응답 버림 — 브라우저 단계(ground 늦음) · 뒤 단계(corridor 늦음 · 끊김)
{
const log = [];
let releaseGround = null;
@@ -161,25 +161,27 @@ async function run() {
commits,
};
// 뒤 단계 — haul 1 이 늦게 옴 · 그 사이 새 조작 → 끊김 · 버림
let releaseHaul = [];
const hold2 = { haul: () => new Promise((resolve) => releaseHaul.push(resolve)) };
// 뒤 단계 — corridor 1 이 늦게 옴 · 그 사이 새 조작 → 끊김 · 버림
let releaseAfter = [];
const hold2 = { corridor: () => new Promise((resolve) => releaseAfter.push(resolve)) };
const log2 = [];
const pipe2 = pipeMod.createDesignPipeline({ stages: fakeStages(log2, hold2) });
const commits2 = [];
pipe2.subscribe((e) => e.kind === "stage" && e.stage === "haul" && commits2.push(e.generation));
pipe2.subscribe(
(e) => e.kind === "stage" && e.stage === "corridor" && commits2.push(e.generation),
);
await pipe2.start(BASE);
const haul1 = log2.find((e) => e.name === "haul");
const after1 = log2.find((e) => e.name === "corridor");
await pipe2.update("cross", { x: 1 }, { fromM: 0, toM: 0 });
const haul1Aborted = haul1.aborted();
releaseHaul[0]();
const after1Aborted = after1.aborted();
releaseAfter[0]();
await tick(1);
releaseHaul[1]();
releaseAfter[1]();
await pipe2.settled();
out.lateAfter = {
haul1Aborted,
after1Aborted,
commits: commits2,
haulGen: pipe2.output("haul").gen,
afterGen: pipe2.output("corridor").gen,
generation: pipe2.generation,
};
}
@@ -1,4 +1,4 @@
/* PLAN 346-1 · 346-2 붙임 단위검증 헬퍼 — 페이지 묶음에 cross · masshaul · haul · drainage · corridor 를
/* PLAN 346-1 · 346-2 붙임 단위검증 헬퍼 — 페이지 묶음에 cross · drainage · corridor 를
* 붙인 것. 진짜 TS(`design_pipeline_page` · `_cross` · 단계 파일 전부)를 그 자리에서 트랜스파일해 Node 로
* 돌림. 서버 창구(노선 · 구조물 · 관 목록 · 지형 격자 · 표준단면 · 흙 배분 fetch)만 가짜.
* 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_346_2_pipeline_cross_page.py`. */
@@ -6,6 +6,7 @@ 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 finalCumulative = require("./helper_454_final_cumulative.cjs");
const ROOT = path.join(__dirname, "..", "..");
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
@@ -29,7 +30,7 @@ globalThis.window = {
clearTimeout,
};
// 흙 배분 서버 — 늦게 답함 · 끊기면 버림
// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 · 묶음은 안 부름(454 masshaul · haul 단계 걷음)
const haul = { calls: 0, aborted: 0, answered: 0 };
globalThis.fetch = (url, init) =>
new Promise((resolve, reject) => {
@@ -205,7 +206,7 @@ async function run() {
stationIntervalM: job.interval,
onCorridorStale: () => (corridor += 1),
});
pageMod.pipelineEarthwork(P, { conversion: job.conversion });
pageMod.pipelineEarthwork(P);
const pipe = pageMod.pagePipeline(P);
const commits = [];
pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`));
@@ -242,7 +243,6 @@ async function run() {
const ground = pipe.output("ground");
const profile = pipe.output("profile");
const shown = pageMod.pipelineDetail(P);
const mass = pipe.output("masshaul");
const moved = {
run: {
outcome: runB.outcome,
@@ -264,7 +264,7 @@ async function run() {
cut: pipe.output("cross").byKey[s.key]?.cutAreaM2 ?? null,
fill: pipe.output("cross").byKey[s.key]?.fillAreaM2 ?? null,
})),
finalCumulative: mass?.haulInput?.final_cumulative_m3 ?? null,
finalCumulative: finalCumulative(load, pipe, job.conversion),
footprint: pipe.output("footprint")?.records.length ?? 0,
detail: shown && {
sections: shown.cross_sections.length,
@@ -113,6 +113,7 @@ const NEW = (dy) => [
kinds: swap.detail.cross_sections.map((s) => s.kind),
labels: [swap.detail.cross_sections[2].label, swap.detail.cross_sections[7].label],
samples: swap.detail.cross_sections[1].samples.map((s) => s.offset_m),
movedSamples: swap.detail.cross_sections[2].samples.map((s) => s.offset_m),
oldUntouched:
before.cross_sections.length === 7 && before.cross_sections[3].chainage_m === 45.054,
length: swap.detail.longitudinal.length_m,
@@ -6,6 +6,7 @@
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const finalCumulative = require("./helper_454_final_cumulative.cjs");
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
@@ -167,7 +168,7 @@ async function run() {
drawn = next;
void Promise.resolve().then(renderB06);
});
const final = () => pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null;
const final = () => finalCumulative(load, pipe, job.conversion);
const cut = () =>
Object.values(pipe.output("cross")?.byKey ?? {}).reduce((s, c) => s + (c.cutAreaM2 ?? 0), 0);
@@ -5,6 +5,7 @@
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const finalCumulative = require("./helper_454_final_cumulative.cjs");
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
@@ -249,7 +250,7 @@ async function draftRun(P, grid) {
totalMs: run.totalMs,
line: pipe.output("route").line,
curves: pipe.output("route").curves,
final: pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null,
final: finalCumulative(load, pipe, job.conversion),
stations,
copied,
};
@@ -6,6 +6,7 @@
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const finalCumulative = require("./helper_454_final_cumulative.cjs");
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
@@ -198,7 +199,7 @@ async function run() {
stations: chainagesOf(stations),
saved: job.detail.cross_sections.map((s) => s.chainage_m),
pipeChainages: (pipe.input("pipes") ?? []).map((p) => p.chainage_m),
final: pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null,
final: finalCumulative(load, pipe, job.conversion),
footprint: pipe.output("footprint")?.records.length ?? 0,
};
@@ -239,7 +240,7 @@ async function run() {
const again = await openOnce("proj-349b");
const reopen = {
runs: runs().slice(again.before).length,
final: again.pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null,
final: finalCumulative(load, again.pipe, job.conversion),
stations: chainagesOf(again.pipe.output("ground")?.stations),
};
again.stop();
@@ -146,7 +146,15 @@ m.followEditorPipes(fresh2, m.createFacilityStore(), [
]);
out.fresh = [fresh.pipes(), fresh2.pipes()].map((list) => list.map((p) => [p.id, p.chainage_m]));
/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */
/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) · 맨 앞 · 맨 끝 = 시종점(452)이라 BP · EP 사이에 둠 */
const keysOf = (project, list) =>
m
.stationKeysOf(
project,
[{ chainage_m: 0, kind: "bp" }, ...list, { chainage_m: 400, kind: "ep" }],
20,
)
.slice(1, -1);
const planned = [
{ chainage_m: 80.17, owners: ["p8017"], role: "anchor" },
{ chainage_m: 52.5, owners: ["st-1"], role: "anchor" },
@@ -156,9 +164,9 @@ const sections = [
{ chainage_m: 80.17, kind: "irregular" },
];
m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]);
out.keys = m.stationKeysOf(P, sections, 20).map((k) => k.key);
out.keys = keysOf(P, sections).map((k) => k.key);
m.useStationOwners(P, planned);
out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key);
out.keysNoPipes = keysOf(P, sections).map((k) => k.key);
out.pipelineKey = m.pipelineStationKey("pipe", "p8017");
/* 7. 411-E — 관 계획을 아는 동안 옛 자리 관 측점(옮겨 간 자리)은 세션 id 를 안 지음 · 헛 id 정리 */
@@ -171,10 +179,10 @@ const staleSections = [
{ chainage_m: 80, kind: "irregular", structure: "파형강관 D800", culvert: {} },
{ chainage_m: 150, kind: "irregular", structure: "파형강관 D800", culvert: {} }, // 옛 자리
];
out.staleKeys = m.stationKeysOf(E, staleSections, 20).map((k) => k.key);
out.staleKeys = keysOf(E, staleSections).map((k) => k.key);
out.staleBook = bookSize();
m.useStationOwners(E, []); // 관 계획 모름(초안 없음) = 옛 갈래 그대로 지음
out.legacyKey = m.stationKeysOf(E, [staleSections[1]], 20)[0].key;
out.legacyKey = keysOf(E, [staleSections[1]])[0].key;
out.legacyBook = bookSize();
m.prunePipeIds([80], E); // 지금 관 자리만 남김
out.pruned = { size: bookSize(), at80: m.pipeIdAt(80, E), e1 };
@@ -28,6 +28,6 @@ const station = loadTs("common_util/common_util_structure_station.ts");
const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts");
const out = cases.map((item) => {
const moved = station.followMovedNodes(item.base.basePvi, item.gone, item.born, item.edited);
return seat.seatBornNodes(item.base, moved.nodes, moved.seat);
return seat.seatBornNodes(item.base, moved.nodes, moved.seat, moved.held);
});
process.stdout.write(JSON.stringify(out));
+215
View File
@@ -0,0 +1,215 @@
// 433-4 · 433-11 — 측점 갈아끼움(`swapCrossSections` · 진짜) · 횡단 엔진(`computeCrossDesign` · 진짜) ·
// 유토(`computeMassHaul` · 진짜). 인자 = 표준단면 json · 토량환산계수 json.
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
const STUBS = {
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) },
};
const cache = new Map();
function load(full) {
if (cache.has(full)) return cache.get(full).exports;
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);
const req = (r) => {
const name = r.split("/").pop();
if (STUBS[name]) return STUBS[name];
if (r.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${r.slice(6)}.ts`));
if (r.startsWith(".")) return load(path.resolve(path.dirname(full), `${r}.ts`));
throw new Error(`stub 없음: ${r}`);
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const swap = load(path.join(ROOT, "B06_Section/logic/B06_Section_Station_Swap.ts"));
const { computeCrossDesign } = load(path.join(ROOT, "common_util/common_util_cross_design.ts"));
const { computeMassHaul } = load(path.join(ROOT, "common_util/common_util_mass_haul.ts"));
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const conversion = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
const st = (key, m, kind, ground = null) => ({
key,
chainageM: m,
kind,
x: m,
y: 0,
left: [0, 1],
azimuthDeg: 90,
planRadiusM: null,
curveOuterSide: null,
curveWideningM: 0,
ground,
});
/* ── 433-4: R 바꿔 MC 측점이 움직임(curve:47.35 → curve:49.10) ── */
const mcSec = (m, kind) => ({
station_id: idOf(m),
chainage_m: m,
kind,
center_x: m,
center_y: 0,
frame: { left_xy: [0, 1] },
samples: [],
uphill_side: "left",
design: { memo: `MY-EDIT@${m}` },
});
const mcDetail = {
longitudinal: { stations: [], length_m: 100 },
cross_sections: [
mcSec(0, "bp"),
mcSec(20, "regular"),
mcSec(47.35, "curve"),
mcSec(60, "regular"),
mcSec(100, "ep"),
],
};
const mcOut = swap.swapCrossSections(
mcDetail,
[
st("end:BP", 0, "end"),
st("grid:1", 20, "grid"),
st("curve:49.10", 49.1, "curve"),
st("grid:3", 60, "grid"),
st("end:EP", 101, "end"),
],
["end:BP", "grid:1", "curve:47.35", "grid:3", "end:EP"],
20,
);
// 멀리(> CURVE_NEAR_M) 간 MC = 못 이음(새 측점 · 노선 밖)
const farOut = swap.swapCrossSections(
mcDetail,
[
st("end:BP", 0, "end"),
st("grid:1", 20, "grid"),
st("curve:62.00", 62, "curve"),
st("grid:3", 60, "grid"),
st("end:EP", 100, "end"),
],
["end:BP", "grid:1", "curve:47.35", "grid:3", "end:EP"],
20,
);
/* ── 433-11: 덧 측점 넣기 · 빼기 — 서버 표본(A) 대 브라우저 지형(B) ── */
const OFFSETS = [-12, -9, -6, -4, -2, 0, 2, 4, 6, 9, 12];
const groundA = (m, o) => 100 + 0.02 * m + 0.35 * o; // 서버 샘플링(저장 상세)
const groundB = (m, o) => groundA(m, o) + 0.6 * Math.sin(m / 7 + o / 3); // 브라우저 설계 지형
const planZ = (m) => 100.2 + 0.02 * m;
const samplesOf = (fn, m) =>
OFFSETS.map((o) => ({ offset_m: o, elevation_m: fn(m, o), valid: true }));
const groundOf = (fn, m) => ({
centerZ: fn(m, 0),
left: OFFSETS.filter((o) => o > 0).map((o) => ({ offsetM: o, z: fn(m, o) })),
right: OFFSETS.filter((o) => o < 0).map((o) => ({ offsetM: -o, z: fn(m, o) })),
});
const designOf = (samples, m) => ({
...computeCrossDesign(samples, planZ(m), {
standard,
groundType: "soil",
sectionMode: "left_cut",
}),
design_elevation_m: planZ(m),
});
const baseKeys = [
["end:BP", 0, "end"],
["grid:1", 20, "grid"],
["grid:2", 40, "grid"],
["grid:3", 60, "grid"],
["end:EP", 80, "end"],
];
const kindOf = (kind, key) =>
kind === "end" ? (key.endsWith("EP") ? "ep" : "bp") : kind === "grid" ? "regular" : "irregular";
const savedSec = ([key, m, kind]) => {
const samples = samplesOf(groundA, m);
return {
station_id: idOf(m),
chainage_m: m,
kind: kindOf(kind, key),
center_x: m,
center_y: 0,
center_z: groundA(m, 0),
frame: { left_xy: [0, 1] },
uphill_side: "left",
samples,
design: designOf(samples, m),
};
};
const saved = {
longitudinal: { stations: [], length_m: 80 },
cross_sections: baseKeys.map(savedSec),
};
const savedKeys = baseKeys.map(([key]) => key);
// 브라우저 다시 셈 = 상세 표본으로 면적(Cross_Refresh 꼴)
const recross = (detail) =>
detail.cross_sections.map((s) => ({ ...s, design: designOf(s.samples, s.chainage_m) }));
const cumulative = (sections) =>
computeMassHaul(sections, conversion).points.map((p) => [
p.chainage_m,
Math.round(p.cumulative_volume_m3 * 1000) / 1000,
]);
const plusList = [...baseKeys.slice(0, 2), ["pipe:p30", 30, "pipe"], ...baseKeys.slice(2)];
const plusStations = plusList.map(([key, m, kind]) => st(key, m, kind, groundOf(groundB, m)));
const added = swap.swapCrossSections(saved, plusStations, savedKeys, 20);
const addedSections = recross(added.detail);
// 서버 기준 = 저장 상세(표본 A) + 덧 측점(브라우저 표본 B · 새 측점은 표본이 그것뿐)
const serverPlus = [...saved.cross_sections];
serverPlus.splice(2, 0, addedSections[2]);
const backStations = baseKeys.map(([key, m, kind]) => st(key, m, kind, groundOf(groundB, m)));
const removed = swap.swapCrossSections(
added.detail,
backStations,
plusList.map(([key]) => key),
20,
);
// 노선이 옮겨 간 측점(좌표 다름)은 새 지형 표본
const shifted = swap.swapCrossSections(
saved,
baseKeys.map(([key, m, kind]) => ({ ...st(key, m, kind, groundOf(groundB, m)), y: 1.5 })),
savedKeys,
20,
);
console.log(
JSON.stringify({
mc: {
added: mcOut.added,
removed: mcOut.removed,
moves: [...mcOut.moves],
memo: mcOut.detail.cross_sections[2].design.memo ?? null,
stationId: mcOut.detail.cross_sections[2].station_id,
far: { added: farOut.added, removed: farOut.removed },
},
plus: {
keptSamples: added.detail.cross_sections
.filter((_, i) => i !== 2)
.every(
(s, i) => JSON.stringify(s.samples) === JSON.stringify(saved.cross_sections[i].samples),
),
keptCenterZ: added.detail.cross_sections.filter((_, i) => i !== 2).map((s) => s.center_z),
savedCenterZ: saved.cross_sections.map((s) => s.center_z),
newSamples:
JSON.stringify(added.detail.cross_sections[2].samples) ===
JSON.stringify(samplesOf(groundB, 30)),
browser: cumulative(addedSections),
server: cumulative(serverPlus),
back: cumulative(recross(removed.detail)),
saved: cumulative(saved.cross_sections),
// 고치기 전 꼴(모든 측점 브라우저 표본) — 차가 큼을 보임
allBrowser: cumulative(
plusList.map(([, m], i) => ({
...addedSections[i],
design: designOf(samplesOf(groundB, m), m),
})),
),
shiftedFresh: shifted.detail.cross_sections.every(
(s, i) => JSON.stringify(s.samples) === JSON.stringify(samplesOf(groundB, baseKeys[i][1])),
),
},
}),
);
@@ -5,7 +5,8 @@ const path = require("path");
const ROOT = path.resolve(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
const REAL = /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross)$/;
const REAL =
/(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_station_key)$/;
const STUBS = {
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
common_util_station: { stationLabel: (m) => String(m) },
@@ -0,0 +1,175 @@
/* PLAN 448(442-2) 단위검증 헬퍼 — 페이지 설정 저장 차례 · 초안 내보내기 · 기준표 거울 재료.
* ① DESIGN_SAVE_ORDER 에 settings(route 뒤 · pipes 앞) · 노선 바꾼 [저장]에도 settings 기본 창구가 돎
* ② flushPageSettings — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로
* ③ 끝 측점 계획고 편집(시 · 종점 조정) — 칸 값 = 첫 · 끝 측점 station_offsets
* ④ TS 기준표(PROFILE_CRITERIA · SPEED_CHOICES) — 거울 시험이 파이썬과 대조
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_442_2_page_settings.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
globalThis.window = { setTimeout, clearTimeout };
const anything = () =>
new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) });
function load(file, stubs) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r] ?? anything());
return box.exports;
}
/** 상대 경로 import 를 실제 파일로 따라 읽는 로더(순수 logic 묶음용) · 그 밖은 가짜. */
const cache = new Map();
function loadReal(file) {
if (cache.has(file)) return cache.get(file);
const box = { exports: {} };
cache.set(file, box.exports);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const req = (r) => {
if (!r.startsWith(".")) return anything();
const target = path.resolve(path.dirname(file), r);
return loadReal(fs.existsSync(`${target}.ts`) ? `${target}.ts` : target);
};
new Function("exports", "module", "require", js)(box.exports, box, req);
cache.set(file, box.exports);
return box.exports;
}
async function run() {
const out = {};
// ── ① 저장 차례 ──
const calls = [];
const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts"), {
"../../B05_Profile/B05_Profile_Api_PageSettings": {
flushPageSettings: async (projectId) => calls.push(`settings:${projectId}`),
},
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": {
pendingRouteSave: async () => ({ draft: 1 }),
saveRouteDraft: async () => (calls.push("route"), { route_id: 9 }),
carryAfterRouteSave: async () => [],
routeSaveSummary: () => "",
rebuildAfterRouteSave: async () => undefined,
},
});
out.order = [...save.DESIGN_SAVE_ORDER];
const done = await save.saveDesign({
projectId: "p1",
routeId: () => 3,
steps: { settings: async () => calls.push("own-settings") }, // 노선 바뀌면 화면 몫 건너뜀
cross: async () => calls.push("cross"),
failed: (error) => calls.push(`failed:${error.message}`),
});
out.routeSaveDone = done;
out.routeSaveCalls = calls;
// ── ② 초안 내보내기 ──
const session = new Map();
const puts = [];
let mode = "ok";
let latest = {
route_params: { options: { grade_class: "trunk", design_speed_kph: 20 }, max_grade_pct: 15 },
};
const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts"), {
"../A00_Common/b_page_state": {
readState: (name, p) => (session.has(`${name}:${p}`) ? session.get(`${name}:${p}`) : null),
writeState: (name, value, p) => session.set(`${name}:${p}`, value),
clearState: (name, p) => session.delete(`${name}:${p}`),
},
"./B05_Profile_Api_Fetch": {
savePageSettings: async (projectId, body) => {
puts.push(body);
if (mode === "boom") throw new Error("서버 500");
return { warnings: [], profile_updated: true };
},
readRouteLatestCache: () => latest,
writeRouteLatestCache: (_p, value) => (latest = value),
},
});
await api.flushPageSettings("p1");
out.putsWithoutDraft = puts.length;
const draft = api.pageSettingsOf({
designSpeed: 30,
terrainType: "normal",
maxGradePct: null,
minVerticalRadius: 300,
minTangentLength: null,
enforcePipeClearance: true,
stationDisplay: { station: 12, cumulative: 240 },
});
out.draft = draft;
api.writePageSettings("p1", draft);
out.restoreFromDraft = api.panelValuesOfDraft(api.readPageSettings("p1"));
mode = "boom";
out.boom = await api.flushPageSettings("p1").then(
() => "ok",
(error) => error.message,
);
out.draftAfterBoom = api.readPageSettings("p1");
mode = "ok";
await api.flushPageSettings("p1");
out.draftAfterOk = api.readPageSettings("p1");
out.latestAfterOk = latest;
out.puts = puts.length;
// ── ③ 끝 측점 계획고 편집 ──
const logic = path.join(ROOT, "B05_Profile", "logic");
const alignment = loadReal(path.join(logic, "B05_Profile_UI_Profile_Alignment.ts"));
const ends = loadReal(path.join(logic, "B05_Profile_EndOffsets.ts"));
const base = {
policy: {
station_interval_m: 20,
curve_radius_ratio: 5,
default_curve_length_m: 20,
curve_tangent_max_ratio: 0.5,
curve_skip_legal_exception: true,
balance_tolerance_percent: 5,
edit_step_m: 0.1,
grade_violation_policy: "warn",
max_grade_pct: 14,
curve_skip_delta_pct: 5,
paved: false,
},
onlyExplicitCurves: false,
basePvi: [
{ chainage_m: 0, elevation_m: 100 },
{ chainage_m: 100, elevation_m: 105 },
{ chainage_m: 200, elevation_m: 108 },
],
chainage: [0, 100, 200],
ground: [100, 105, 108],
stations: [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200].map((s) => ({
station_id: `s${s}`,
chainage_m: s,
})),
};
const empty = { station_offsets: {}, curve_radii: {} };
out.endsEmpty = ends.endOffsetsOf(base, empty);
const raised = ends.withEndOffset(base, empty, "start", 1.25);
out.endsRaised = ends.endOffsetsOf(base, raised);
const both = ends.withEndOffset(base, raised, "end", -0.5);
out.endsBoth = ends.endOffsetsOf(base, both);
const plan = alignment.buildAlignment(base, both);
out.planStart = plan.samples[0].elevation_m;
out.planEnd = plan.samples[plan.samples.length - 1].elevation_m;
out.backToZero = ends.endOffsetsOf(base, ends.withEndOffset(base, both, "start", 0));
// ── ④ 기준표 ──
const fields = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Fields.ts"), {});
out.criteria = fields.PROFILE_CRITERIA;
out.speedChoices = fields.SPEED_CHOICES;
process.stdout.write(JSON.stringify(out));
}
run().catch((error) => {
process.stderr.write(String(error.stack || error));
process.exit(1);
});
+152
View File
@@ -0,0 +1,152 @@
/* PLAN 449 헬퍼 — [저장] 관 차례 · 노선 저장 뒤 실패(433-7 · 433-12). 실제 `design_save.ts` · 관 초안 창구 · 가게를
* Node 로 · 서버 창구 · 계획고 초안 · 노선 편집 창구만 가짜. 짝 = `test_449_save_pipes.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, "..", "..");
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;
},
};
}
globalThis.window = {
sessionStorage: memoryStorage(),
localStorage: memoryStorage(),
setTimeout,
clearTimeout,
};
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("@config/")) return { API_BASE_URL: "http://fake" };
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));
let calls = [];
let failAt = null; // 이 차례에서 throw
let routeBody = null;
const alignment = new Map(); // 노선 칸 → 계획고 초안
STUBS["ui_template_elements"] = {
showLoadingOverlay: () => undefined,
hideLoadingOverlay: () => undefined,
showToast: () => undefined,
};
const failing = (name) => {
if (failAt === name) throw new Error(`${name} 409`);
};
STUBS["B04_PreProcess_Api_Fetch"] = {
fetchDetailPipePoints: async () => (calls.push(["GET"]), { pipe_points: [] }),
saveDetailPipePoints: async (_projectId, points, recompute) => {
calls.push(["PUT", points ?? null, !!recompute]);
return {};
},
};
STUBS["B05_Profile_Api_Structures"] = {
flushPendingStructures: async () => failing("structures"),
};
STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined };
STUBS["B05_Profile_Api_Fetch"] = {
flushUphillOverrides: async () => undefined,
saveProfileAlignment: async (_projectId, routeId, draft) => {
calls.push(["ALIGN", routeId, draft]);
return {};
},
};
STUBS["B05_Profile_UI_Profile_Edit"] = {
pendingAlignment: (routeId) => alignment.get(routeId) ?? null,
markAlignmentSaved: (routeId) => alignment.delete(routeId),
writeAlignmentDraft: (routeId, edits) => alignment.set(routeId, edits),
};
STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined };
STUBS["B05_Profile_UI_RouteEdit_Apply"] = {
pendingRouteSave: async () => routeBody,
saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }),
carryAfterRouteSave: async (_projectId, from, to, options) => {
calls.push(["CARRY", from, to, options?.save ?? true]);
failing("carry");
return 0;
},
routeSaveSummary: () => "",
rebuildAfterRouteSave: async () => void calls.push(["REBUILD"]),
};
const save = load("A00_Common", "design", "design_save.ts");
const drafts = load("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts");
const P = "proj-449";
async function once({ route = false, draft = null, confirm = false, fail = null, routeId = 7 }) {
calls = [];
failAt = fail;
routeBody = route ? { draft: {} } : null;
drafts.writePendingPipes(P, draft);
let failure = null;
const ok = await save.saveDesign(
{
projectId: P,
routeId: () => routeId,
// 화면 몫 = 배수판 다시 받기(PUT 없음) — 433-7 뒤 design_shared 와 같음
steps: { pipes: async () => void calls.push(["RELOAD"]) },
cross: async () => void calls.push(["CROSS"]),
failed: (error) => (failure = String(error && error.message ? error.message : error)),
},
{ confirm },
);
return { ok, failure, calls, left: drafts.readPendingPipes(P) };
}
async function run() {
window.localStorage.setItem("frd_current_project_id", P);
const fifteen = [{ chainage_m: 15, source: "user", id: "p1" }];
const out = {};
out.draft = await once({ draft: fifteen }); // B06 고침 직후 [저장] = 초안 값
out.noDraft = await once({}); // 판 안 섬 · 초안 없음 = PUT 없음
out.confirmNoDraft = await once({ confirm: true }); // [확정] = 칸 없이(서버 저장분)
out.emptyDraft = await once({ draft: [] }); // 사용자가 다 지움 = 빈 목록
// 433-12 — 노선 저장 뒤 구조물 실패 → 0 칸 계획고가 새 노선 칸에 · 다음 [저장]이 보냄
alignment.clear();
alignment.set(0, { pvi: { 120: 1.5 } });
out.routeFail = await once({ route: true, fail: "structures" });
out.alignAfterFail = Object.fromEntries(alignment);
out.nextSave = await once({ routeId: 8 });
// 노선 저장 뒤 횡단 넘김 자체가 실패 → 두 번 안 넘김
alignment.clear();
out.carryFail = await once({ route: true, fail: "carry" });
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);
});
+2
View File
@@ -0,0 +1,2 @@
/* 452 시험 묶음 진입점 — B06 측점 열쇠(시종점 규칙)를 vite ssr 로 묶어 Node 헬퍼가 부름. */
export { stationKeysOf, useStructureIds } from "../../B06_Section/B06_Section_Station_Keys";
+36
View File
@@ -0,0 +1,36 @@
/* 452 헬퍼 — B06 `stationKeysOf` 열쇠를 JSON 으로.
* node helper_452_keys.mjs <묶음.mjs> <입력.json>
* 입력 = { sections: [{chainage_m, kind}], structures: [{structure_id, chainage_m, start_m, end_m}], interval }.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
const [bundlePath, inputPath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundlePath).href);
const input = JSON.parse(readFileSync(inputPath, "utf8"));
m.useStructureIds("p452", input.structures);
const sections = input.sections.map((s) => ({ station_id: `s${s.chainage_m}`, samples: [], ...s }));
process.stdout.write(JSON.stringify(m.stationKeysOf("p452", sections, input.interval)));
@@ -0,0 +1,96 @@
/* 454(433-21 ②) 방향키 측점 No. 걸음 — 무작위 짧은 다리 노선(14 IP · 고정 R) 교점마다 네 방향 한 걸음
* 누르고 측점 밖 막힘 수를 JSON 한 줄로(`test_454_arrow_station_step.py` 가 판정).
* 누름 판정은 편집기 방향키 길과 같음 — 하한 깸 = 막힘 · 미리보기(`fitDrag`) 측점 밖이 늘면 막힘.
*/
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 INTERVAL = 20;
const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL };
const columns = (p) => ({
planned: p,
curveOn: p.map(() => true),
curveRadius: p.map(() => null),
curveLock: p.map(() => null),
curveArc: p.map(() => null),
apexLock: p.map((_, i) => i === 0 || i === p.length - 1),
});
const copy = (c) => ({
...c,
planned: c.planned.map(([x, y]) => [x, y]),
curveRadius: [...c.curveRadius],
});
const off = (b) => fit.offStationCount(b.vertices, b.curves, INTERVAL);
const floor = (b) => edits.curveShortfalls(b.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM);
function route(seed) {
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const line = [[0, 0]];
let a = 0;
for (let i = 0; i < 14; i += 1) {
a += (rnd() - 0.5) * 1.6;
const length = 35 + rnd() * 60;
const p = line[line.length - 1];
line.push([p[0] + Math.cos(a) * length, p[1] + Math.sin(a) * length]);
}
return line;
}
const out = { press: 0, floorBlock: 0, offBlock: 0, offStart: 0, offAfterRelease: 0 };
for (let seed = 1; seed <= 10; seed += 1) {
const hist = columns(fit.fitCurveStations(columns(route(seed)), LIMITS, 1));
hist.curveRadius = edits
.rebuildLine(copy(hist), LIMITS)
.nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100));
out.offStart += off(edits.rebuildLine(copy(hist), LIMITS));
for (let k = 1; k < hist.planned.length - 1; k += 1)
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
const st = copy(hist);
const b0 = edits.rebuildLine(st, LIMITS);
const node = st.planned[k];
st.planned[k] = [node[0] + dx * INTERVAL, node[1] + dy * INTERVAL];
out.press += 1;
if (edits.shortfallCrossed(floor(b0), floor(edits.rebuildLine(st, LIMITS)))) {
out.floorBlock += 1;
continue;
}
const pv = { ...st, planned: fit.fitDrag(st, hist.planned, k, LIMITS) };
if (off(edits.rebuildLine(copy(pv), LIMITS)) > off(b0)) {
out.offBlock += 1;
continue;
}
const rel = { ...pv, planned: fit.fitCurveStations(pv, LIMITS, fit.fitStart(hist, pv)) };
if (off(edits.rebuildLine(copy(rel), LIMITS))) out.offAfterRelease += 1;
}
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,23 @@
/* 454 — 옛 masshaul 단계 탐침 자리: 묶음 cross · profile 출력으로 누가토량 끝값(m3).
* 유토곡선 판(`B05_Profile_UI_Profile_MassHaul`)과 같은 공용 계산(`computeMassHaulSeries`) · 횡단 기준.
* `load` = 부르는 헬퍼의 TS 로더(`load("common_util", "…ts")`). */
module.exports = function finalCumulative(load, pipe, conversion) {
const cross = pipe.output("cross");
const samples = pipe.output("profile")?.samples ?? [];
if (!cross) return null;
const { computeMassHaulSeries } = load("common_util", "common_util_mass_haul.ts");
const sections = Object.values(cross.byKey)
.sort((a, b) => a.chainageM - b.chainageM)
.map((e) => ({ station_id: e.key, chainage_m: e.chainageM, design: e.detail }));
const series = computeMassHaulSeries(
{
length_m:
samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0,
design_profiles: samples.length ? [{ samples }] : [],
},
sections,
conversion,
);
const picked = series.find((s) => s.basis === "cross") ?? series[0];
return picked?.result?.final_cumulative_m3 ?? null;
};
@@ -0,0 +1,77 @@
/* 455 (433-13 ①) 종단 ▲▼ 자동 R — `B05_Profile_UI_Profile_Alignment.ts` adjustStation 을 Node 로 돌림.
* ① 누른 꼭짓점 R = 저장 자동 R → 지움 · 곡선 L = 기본 L ② 사용자 R(저장분과 다름) → 그대로
* ③ 자동 R 기준을 안 넘기면(옛 부름) → 그대로. 표준출력 = JSON.
*/
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) => {
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);
cache.set(full, box.exports);
return box.exports;
}
const align = loadTs(
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
);
// 20m 측점 · 100m 에 꺾임 하나(대수차 0.5 %)
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
const ground = stations.map((s) => 100 + 0.02 * s + (s > 100 ? 0.005 * (s - 100) : 0));
const base = {
policy: {
station_interval_m: 20,
curve_radius_ratio: 1,
curve_tangent_max_ratio: 0.5,
curve_skip_legal_exception: false,
default_curve_length_m: 15,
default_curve_radius_m: 300,
balance_tolerance_percent: 10,
edit_step_m: 0.1,
grade_violation_policy: "warn",
max_grade_pct: 14,
curve_skip_delta_pct: 0,
paved: true,
},
onlyExplicitCurves: false,
basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })),
chainage: stations,
ground,
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
};
const KEY = "100.000";
const AUTO = 15 / 0.005; // 관 정착이 역산한 꼴(R = L / A)
function press(radius, autoRadii) {
const edits = { station_offsets: {}, curve_radii: { [KEY]: radius } };
const next =
autoRadii === undefined
? align.adjustStation(base, edits, 100, 0.1)
: align.adjustStation(base, edits, 100, 0.1, autoRadii);
const curve = align
.buildAlignment(base, next)
.curves.find((c) => Math.abs(c.chainage_m - 100) < 1e-6);
return { radius: next.curve_radii[KEY] ?? null, length: curve ? curve.l_m : null };
}
process.stdout.write(
JSON.stringify({
auto: press(AUTO, { [KEY]: AUTO }),
user: press(777, { [KEY]: AUTO }),
legacy: press(AUTO),
}),
);
@@ -0,0 +1,38 @@
/* 455 (433-13 ③) 옛 정책 경고 — 진짜 `B05_Profile_UI_Profile_Data.ts` readAlignment +
* `B05_Profile_UI_Profile_Alignment.ts` buildAlignment 를 Node 로 돌림.
* 입력 argv[2] = 종단 정본 JSON · 표준출력 = { warnings: [법정 미달 글], policy }.
*/
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) => {
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 LOGIC = path.join(ROOT, "B05_Profile", "logic");
const data = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Data.ts"));
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
const longitudinal = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const stored = data.readAlignment(longitudinal);
const built = align.buildAlignment(align.toAlignmentBase(stored), align.emptyEdits());
process.stdout.write(
JSON.stringify({
warnings: built.warnings.filter((text) => text.includes("법정 최소")),
policy: stored.policy,
}),
);
@@ -74,6 +74,8 @@ const STUBS = {
"../../B06_Section/B06_Section_Api_Culvert_Options": {
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
},
// 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings)
"../../B05_Profile/B05_Profile_Api_PageSettings": { flushPageSettings: async () => undefined },
};
const cache = new Map();
+3 -1
View File
@@ -80,7 +80,9 @@ def test_종단도는_막힌_방향키를_건너뜀() -> None:
encoding="utf-8"
)
assert "if (event.defaultPrevented) return;" in src
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src
assert (
"ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()))" in src
)
def _ground(level: float) -> list[dict]:
+1 -8
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross · masshaul · haul 단계.
"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross 단계(masshaul · haul 단계는 454 에서 걷음).
못박는 것
- 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 것은 지우지 않고 남김.
@@ -8,7 +8,6 @@
- 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 ·
노견 확장(417) · 단면 유형 · 측구 방향 · 포장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」.
- cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용.
- masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로.
Node 헬퍼 `helper_344_3_station_keys.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_344_3_entry.ts`)을
부름.
@@ -116,9 +115,3 @@ def test_cross_단계_국부_연산(out):
assert out["crossOne"] == {"computed": 1, "reused": 5}
assert out["crossOneChanged"] == "soil"
assert out["crossGrade"]["computed"] <= 3 and out["crossGrade"]["reused"] >= 3
def test_masshaul_haul_단계(out):
assert out["massSeries"] == ["cross", "longitudinal"]
assert out["massHaulInput"] is True
assert out["haul"] == {"posts": 1, "plan1": {"n": 1}, "plan2": {"n": 1}, "plan3": {"n": 1}}
@@ -31,9 +31,8 @@ BROWSER = [
"profile",
"cross",
"footprint",
"masshaul",
]
AFTER = ["haul", "drainage", "corridor"]
AFTER = ["drainage", "corridor"]
@pytest.fixture(scope="module")
@@ -65,7 +64,7 @@ def test_같은_입력_다시_안_셈(r):
b = r["basic"]
assert b["same"] == {"result": None, "generation": 1} # 같은 값 = 차례 번호도 안 오름
# 횡단만 바뀌면 노선 ~ 종단 · 배수유역은 건너뜀
assert b["crossRan"] == ["cross", "footprint", "masshaul", "haul", "corridor"]
assert b["crossRan"] == ["cross", "footprint", "corridor"]
assert b["crossSkipped"] == [
"route",
"follow",
@@ -76,7 +75,7 @@ def test_같은_입력_다시_안_셈(r):
"drainage",
]
# 계획고만 바뀌면 측점 · 지반 · 첫 계획선 · 배수유역 건너뜀
assert b["gradeRan"] == ["profile", "cross", "footprint", "masshaul", "haul", "corridor"]
assert b["gradeRan"] == ["profile", "cross", "footprint", "corridor"]
# 노선이 바뀌면 전부
assert b["routeRan"] == BROWSER + AFTER
@@ -101,8 +100,8 @@ def test_늦은_응답_버림_브라우저_단계(r):
def test_늦은_응답_버림_뒤_단계_끊김(r):
late = r["lateAfter"]
assert late["haul1Aborted"] is True # 새 조작 = 옛 요청 끊음
assert late["commits"] == [2] and late["haulGen"] == 2 and late["generation"] == 2
assert late["after1Aborted"] is True # 새 조작 = 옛 요청 끊음
assert late["commits"] == [2] and late["afterGen"] == 2 and late["generation"] == 2
def test_못_끝낸_단계는_범위를_합쳐_셈(r):
@@ -133,8 +132,6 @@ def test_단계_시간_기록(r):
"profile",
"cross",
"footprint",
"masshaul",
"haul",
"corridor",
]
assert t["last"]["totalIsNumber"] is True
@@ -201,7 +201,7 @@ def r(request) -> dict:
def test_열_때_한_번_발자국_저장_횡단_입력(r):
o = r["open"]
assert o["outcome"] == "done"
assert {"cross", "footprint", "masshaul"} <= set(o["stages"])
assert {"cross", "footprint"} <= set(o["stages"])
assert o["crossCount"] == o["stations"] and o["footprint"] > 2
assert o["carried"] == o["stations"] # 저장 측점마다 입력이 측점 열쇠로
assert o["corridor"] == 0 and o["haulCalls"] == 0 and o["detail"] is None
@@ -214,7 +214,7 @@ def test_노선_옮김_한_번에_유토곡선까지_시간(r):
assert run["outcome"] == "done" and run["sources"] == ["route"]
for stage in ("route", "stations", "ground", "planline", "profile", "cross", "footprint"):
assert stage in run["stages"], stage
assert "masshaul" in run["stages"]
assert "masshaul" not in run["stages"] and "haul" not in run["stages"] # 454 걷음
assert (
sum(run["stages"][s] for s in run["stages"] if s not in ("haul", "drainage", "corridor"))
< BUDGET_MS
@@ -271,9 +271,8 @@ def test_누가토량_서버_엔진과_같은_자리수(r):
def test_늦은_응답_버림(r):
late = r["late"]
assert late["genB"] > late["genA"]
assert late["haul"]["aborted"] >= 1 # 앞 차례 흙 배분 요청 끊김
assert f"haul@{late['genA']}" not in late["haulCommits"] # 옛 차례 배분 안 들어옴
assert late["haulCommits"][-1] == f"haul@{late['genB']}"
# 454 — 묶음은 흙 배분을 안 물음(유토곡선 판 선반입만) · 늦은 응답 버림 = 344-4 corridor
assert late["haul"]["calls"] == 0 and late["haulCommits"] == []
def test_저장_관_목록이_좌표로_따라감(r):
+3 -1
View File
@@ -96,7 +96,9 @@ def test_셋_늘고_둘_빠진_목록으로_갈아끼움(out):
"ep",
]
assert pure["labels"] == ["STA.0+047.000", "STA.0+090.000"]
assert pure["samples"] == [-0.5, 0, 0.5]
# 자리 같은 앞 단면 = 앞 표본(서버 정본 · 433-11) · 옮긴 측점 = 새 지반 표본
assert pure["samples"] == [0]
assert pure["movedSamples"] == [-0.5, 0, 0.5]
assert pure["oldUntouched"] is True and pure["length"] == 90
assert pure["same"] is False and pure["sameSelf"] is True
+3 -3
View File
@@ -123,9 +123,9 @@ def test_측점_열쇠_규칙():
"at:33.30": 33.3, # id 없는 관
"grid:2": 40.0,
"pipe:p-7": 50.0,
"structure:w1": 60.0, # 구간 시작 측점이 먼저 열쇠를 가짐
"at:77.00": 77.0, # 같은 구조물 열쇠 둘째 — at:
"at:80.00": 80.0,
"structure:w1:start": 60.0, # 구간형 시작 · 끝 = :start · :end(452 · 화면과 한 규칙)
"structure:w1": 77.0,
"structure:w1:end": 80.0,
"end:EP": 95.5,
}
assert "pipe:0" not in keys and "pipe:1" not in keys # 파일 차례 열쇠 없음
@@ -3,7 +3,7 @@
- 그리기 = 횡단 기준만(`["cross"]`) · 범례는 낸 계열마다라 종단 단추가 안 섬.
- 저장된 범례가 종단(`longitudinal`)이면 횡단으로 맞춤 · 분배 켬은 그대로.
- 계산 묶음 단계(MassHaul_Stage)는 두 기준 그대로 · 공용 계산 코드 남김.
- 공용 계산 코드는 두 기준 그대로 남김(계산 묶음 masshaul 단계는 454 에서 걷음).
`helper_354_2_masshaul_cross_only.cjs` 가 진짜 TS 를 잘라 돌림.
"""
@@ -52,8 +52,7 @@ def test_그리기는_횡단만_부름():
def test_묶음_단계와_공용_코드는_그대로():
stage = (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").read_text(encoding="utf-8")
assert '["cross"]' not in stage
assert not (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").exists() # 454 걷음
util = (ROOT / "common_util/common_util_mass_haul.ts").read_text(encoding="utf-8")
assert 'const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];' in util
assert "longitudinalAreaSamples(longitudinal, sections)" in util
+3 -2
View File
@@ -69,10 +69,11 @@ def test_직선_안_점은_새_꺾임_축은_좌우_다음_꺾임(out, case):
def test_부르는_곳_셋이_같은_함수():
assert "adjustStation(base, store.edits(), chainage, delta)" in _read(
# 455 — 자동 R 기준(저장 곡선반경)을 함께 넘김
assert "adjustStation(base, store.edits(), chainage, delta, store.savedRadii())" in _read(
"B05_Profile/B05_Profile_UI_Profile_Render.ts"
)
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read(
assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in _read(
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts"
)
assert "pivotAxes(current, chainageM)" in _read(
@@ -195,3 +195,63 @@ def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None:
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
for mine, theirs in zip(nodes, server["base_pvi"]):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
def _balanced() -> dict:
"""지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴 · 438 시험과 같은 꼴)."""
data = _longitudinal()
for node in data["profile_alignment"]["base_pvi"]:
node["elevation_m"] -= 1.0
alignment, profile = rebuild_alignment_profile(data, {})
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
return data
@pytest.mark.parametrize("make", [_longitudinal, _balanced])
def test_433_5_관_옮김_뒤_이웃_측점_계획고_그대로(tmp_path: Path, make) -> None:
"""관 351 → 356 — 이웃 340 · 360 곡선이 바뀌어 0.2 m 뜨던 것(433-5) → 옮기기 전 계획고."""
first = make()
before = _rows(first, 0, 400)
_write(tmp_path, first)
after = _rows(_move(tmp_path, 356.0), 0, 400)
for at, value in before.items():
if at != PIPE:
assert abs(after[at] - value) < 0.01, at
if make is _longitudinal:
assert after[356.0] == pytest.approx(_ground(356.0), abs=1e-4)
# 화면 짝 같은 꼭짓점
stored = first["profile_alignment"]
base = {
"basePvi": stored["base_pvi"],
"chainage": [s["chainage_m"] for s in stored["samples"]],
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
"policy": stored["policy"],
}
server = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"]
[nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": []}])
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
def test_433_5_높이_고친_이웃은_그대로(tmp_path: Path) -> None:
"""340 높이 고침(+0.3) · 관 351 → 356 — 340 꼭짓점 그대로 · 360 은 맞춤 · 화면 짝."""
first = _longitudinal({"340.000": 0.3})
stored = copy.deepcopy(first["profile_alignment"])
_write(tmp_path, first)
server = _move(tmp_path, 356.0)["profile_alignment"]
old = {n["chainage_m"]: n["elevation_m"] for n in stored["base_pvi"]}
new = {n["chainage_m"]: n["elevation_m"] for n in server["base_pvi"]}
assert new[340.0] == old[340.0]
assert new[360.0] != old[360.0]
base = {
"basePvi": stored["base_pvi"],
"chainage": [s["chainage_m"] for s in stored["samples"]],
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
"policy": stored["policy"],
}
[nodes] = _ts(
tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": ["340.000"]}]
)
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""433-11 — 덧 측점 넣기 · 빼기 뒤 브라우저 누가토량 = 서버(0.1 ㎥ 안).
뿌리: 갈아끼움이 안 바뀐 측점까지 지반 표본 · 상단측 · 중심고를 브라우저 지형 값으로 갈음.
못박는 것
- 자리 · 축이 같은 앞 단면 = 서버 표본 · 중심고 그대로 · 새 측점만 브라우저 표본.
- 덧 측점 넣은 뒤 누가토량 = 서버 기준(저장 표본 + 새 측점) · 빼면 저장 값 그대로.
- 노선이 옮겨 간 측점(좌표 다름)은 새 지형 표본.
- 고치기 전 꼴(모든 측점 브라우저 표본)은 1 ㎥ 넘게 어긋남(시험이 차를 잼).
Node 헬퍼 `helper_433_swap.cjs` — 진짜 갈아끼움 · 횡단 엔진 · 유토.
"""
import json
import subprocess
import sys
from pathlib import Path
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 config.config_system import STANDARD_CROSS_SECTION # noqa: E402
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
TOL_M3 = 0.1
@pytest.fixture(scope="module")
def plus(tmp_path_factory):
work = tmp_path_factory.mktemp("b433_11")
standard = work / "standard.json"
conversion = work / "conversion.json"
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
conversion.write_text(json.dumps(EARTHWORK_CONVERSION_FACTORS), encoding="utf-8")
proc = subprocess.run(
["node", str(HERE / "helper_433_swap.cjs"), str(standard), str(conversion)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)["plus"]
def _max_gap(a, b):
assert [p[0] for p in a] == [p[0] for p in b]
return max(abs(x[1] - y[1]) for x, y in zip(a, b))
def test_안_바뀐_측점은_서버_표본_중심고(plus):
assert plus["keptSamples"]
assert plus["keptCenterZ"] == plus["savedCenterZ"]
assert plus["newSamples"]
def test_덧_측점_넣은_뒤_누가토량_서버와_같음(plus):
assert _max_gap(plus["browser"], plus["server"]) < TOL_M3
def test_덧_측점_뺀_뒤_저장_값_그대로(plus):
assert _max_gap(plus["back"], plus["saved"]) < TOL_M3
def test_고치기_전_꼴은_어긋남(plus):
assert _max_gap(plus["allBrowser"], plus["server"]) > 1.0
def test_노선이_옮긴_측점은_새_지형(plus):
assert plus["shiftedFresh"]
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""433-19 · 433-20 — 구간값 죽은 길 걷음 · 표 위 CAD 요소 한도(서버 2000 짝).
못박는 것
- 구간값 제어에 기준벽 `update`(culvertopt 큐 쓰기) 없음 · 구간 줄은 다단 벽만(444 관 초안 한 벌).
- 표 위 CAD 한도 = 서버 `CrossNoteCad.entities max_length` · 넘으면 초안에 안 쓰고 알림.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B06 = ROOT / "B06_Section"
def _read(name: str) -> str:
return (B06 / name).read_text(encoding="utf-8")
def test_구간값_제어에_culvertopt_큐_쓰기_없음():
span = _read("B06_Section_UI_Page_Span_Control.ts")
assert "culvertOptions" not in span
assert "update: (section, role" not in span
wire = _read("B06_Section_UI_Cross_Culvert_Wire.ts")
assert not re.search(r"^\s*update: \(section: CrossSection, role", wire, re.M)
view = _read("B06_Section_UI_Cross_View_Structure.ts")
assert "structureSpan?.update(" not in view
def test_CAD_한도_서버와_같음():
schema = _read("B06_Section_Schema.py")
server = re.search(
r"class CrossNoteCad\(BaseModel\):.*?entities:.*?max_length=(\d+)", schema, re.S
)
cad = _read("B06_Section_UI_Cross_Detail_Cad.ts")
screen = re.search(r"NOTE_CAD_MAX_ENTITIES = (\d+)", cad)
assert server and screen and server.group(1) == screen.group(1)
def test_CAD_한도_넘으면_초안에_안_씀():
cad = _read("B06_Section_UI_Cross_Detail_Cad.ts")
body = cad[cad.index("function writeNoteCad(") :]
guard = body.index("NOTE_CAD_MAX_ENTITIES")
assert guard < body.index("structure_note_cad = cad")
assert "showToast(" in body[guard : body.index("structure_note_cad = cad")]
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""433-4 — 곡선 R 바꿔 MC 측점이 움직임(curve:47.35 → curve:49.10).
상세 갈아끼움이 옛 MC 횡단을 이어 받음.
못박는 것
- MC 설계(사용자 칸) 남음 · 측점 id 그대로 · added · removed 빔.
- moves 에 MC 짝(누가거리 열쇠 칸이 따라감).
- CURVE_NEAR_M 밖으로 간 MC = 못 이음(새 측점 · 옛 열쇠 removed).
Node 헬퍼 `helper_433_swap.cjs` 가 진짜 모듈(`B06_Section_Station_Swap.ts`)을 부름.
"""
import json
import subprocess
import sys
from pathlib import Path
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 config.config_system import STANDARD_CROSS_SECTION # noqa: E402
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b433")
standard = work / "standard.json"
conversion = work / "conversion.json"
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
conversion.write_text(json.dumps(EARTHWORK_CONVERSION_FACTORS), encoding="utf-8")
proc = subprocess.run(
["node", str(HERE / "helper_433_swap.cjs"), str(standard), str(conversion)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_MC_설계가_새_곡선_열쇠로_남음(out):
mc = out["mc"]
assert mc["memo"] == "MY-EDIT@47.35"
assert mc["stationId"] == "station_000000047350"
assert mc["added"] == []
assert mc["removed"] == []
def test_moves_에_MC_짝(out):
assert ["47.35", "49.10"] in out["mc"]["moves"]
def test_멀리_간_MC_는_못_이음(out):
far = out["mc"]["far"]
assert far["added"] == ["curve:62.00"]
assert far["removed"] == ["curve:47.35"]
@@ -0,0 +1,80 @@
"""PLAN 448(442-2) 거울 — 「페이지 설정」 TS 기준표 ↔ 파이썬 법정 기준 전수.
TS `B05_Profile_UI_Panel_Fields.ts` PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙
(고를 수 없는 속도 = 첫 선택지 · 넣은 값 > 0 이면 그 값 · 아니면 표)을 파이썬
`FOREST_ROAD_PROFILE_CRITERIA` · `resolve_grade_options(...).max_grade_pct` 와 임도 종류 × 속도 ×
지형 × 입력 유무 전부 대조. TS 표는 `helper_442_2_page_settings.cjs` 가 꺼냄.
./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_grade_limit_mirror.py -q
"""
from __future__ import annotations
import itertools
import json
import subprocess
from pathlib import Path
import pytest
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options
from config.config_system_design import FOREST_ROAD_PROFILE_CRITERIA as PY
HERE = Path(__file__).parent
@pytest.fixture(scope="module")
def ts_tables():
proc = subprocess.run(
["node", str(HERE / "helper_442_2_page_settings.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
return out["criteria"], out["speedChoices"]
def test_속도별_기울기_반경_표가_같음(ts_tables):
criteria, _ = ts_tables
assert sorted(int(speed) for speed in criteria) == sorted(PY["design_speed"])
for speed, legal in PY["design_speed"].items():
ts = criteria[str(speed)]
assert ts["grade"] == legal["max_grade_pct"]
assert ts["radius"] == legal["min_vertical_radius_m"]
def test_종류별_속도_선택지가_같음(ts_tables):
_, choices = ts_tables
for road, allowed in choices.items():
assert tuple(allowed) == tuple(PY["selectable_design_speeds"].get(road, (20,)))
def _ts_limit(criteria, choices, road, speed, terrain, typed):
allowed = choices.get(road, [20])
speed = speed if speed in allowed else allowed[0] # syncSpeedChoices
if typed is not None and typed > 0:
return float(typed)
return float(criteria[str(speed)]["grade"][terrain])
def test_기울기_상한_전수(ts_tables):
criteria, choices = ts_tables
cases = itertools.product(
["trunk", "fire", "work", "branch"], [20, 30, 40], ["normal", "special"], [None, 11.5]
)
for road, speed, terrain, typed in cases:
py = resolve_grade_options(
road,
terrain_type=terrain,
design_speed_kph=speed,
stored={} if typed is None else {"max_grade_pct": typed},
)
assert _ts_limit(criteria, choices, road, speed, terrain, typed) == py.max_grade_pct, (
road,
speed,
terrain,
typed,
)
@@ -0,0 +1,404 @@
"""PLAN 448(442-2 · 요구 269-1) — B05 페이지 설정 [저장] · [확정] 영구저장.
- 병합 — stage 2 params 에 사용자 값만 얹음(`options` 칸끼리 · 상태 · STALE 안 건드림)
- 정책 갱신 — 종단 경고 기준(최대 기울기 · 종단곡선 R · 평면 R)만 · 기하(base_pvi · 편집) 그대로
- 작업임도 30 → 20 해석 · params 엔 원값
- E1 — 노선 바꾼 저장 · B04 재확정 요청이 설계속도 · 최소고 강제 · 시작 측점을 옮김
- E2 — B06 재생성 지형 기본 = 특수(B05 기본)
- E3 — 초기값 스냅샷이 stage 2 params 를 뜨고 [초기화]가 되돌림 · 옛 스냅샷은 설정 칸만 걷음
- 저장 차례 · 초안 내보내기 · 끝 측점 편집 — Node 헬퍼 `helper_442_2_page_settings.cjs`
./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_page_settings.py -q
"""
from __future__ import annotations
import asyncio
import copy
import json
import subprocess
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from B03_FileInput.B03_FileInput_Service_Chain import redesign_solve_request
from B05_Profile import B05_Profile_PageSettings as page
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options
from B05_Profile.B05_Profile_Engine_Grade_Profile import design_alignment_profile
from B05_Profile.B05_Profile_Schema import PageSettingsRequest
from B06_Section.B06_Section_Router_Regenerate_Options import _regeneration_grade_options
from common_util import common_util_initial_snapshot as snapshot
from common_util.common_util_workflow_state import merge_stage_params, merged_params
HERE = Path(__file__).parent
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", str(HERE / "helper_442_2_page_settings.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
PARAMS = {
"points": {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}},
"options": {"grade_class": "trunk", "terrain_type": "special", "paved": False},
"station_interval_m": 20,
"max_grade_pct": 13.0,
"start_elevation_offset_m": 0.4,
}
# ── 병합 ─────────────────────────────────────────────────────────────────────
def test_병합_options_칸끼리_나머지_갈음():
request = PageSettingsRequest(
design_speed_kph=30,
terrain_type="normal",
max_grade_pct=None,
min_vertical_radius_m=300,
station_display={"station": 12.4, "cumulative": 240},
)
merged = merged_params(PARAMS, page.params_patch(request))
assert merged["options"] == {
"grade_class": "trunk",
"terrain_type": "normal",
"paved": False,
"design_speed_kph": 30,
}
assert merged["max_grade_pct"] is None # 비운 칸 = 법정값
assert merged["min_vertical_radius_m"] == 300
assert merged["station_display"] == {"station": 12.0, "cumulative": 240.0}
assert merged["points"] == PARAMS["points"] and merged["start_elevation_offset_m"] == 0.4
assert PARAMS["options"]["terrain_type"] == "special" # 원본 안 바뀜
class _Cursor:
def __init__(self, params):
self.params = params
self.sql: list[str] = []
async def execute(self, sql, args=None):
self.sql.append(" ".join(sql.split()))
if sql.strip().upper().startswith("UPDATE"):
self.params = args[0]
async def fetchone(self):
return {"params": self.params}
def test_merge_stage_params_상태_안_건드림():
cursor = _Cursor(json.dumps(PARAMS))
merged = asyncio.run(merge_stage_params(cursor, "p1", 2, {"options": {"design_speed_kph": 40}}))
assert merged["options"]["design_speed_kph"] == 40
assert json.loads(cursor.params)["options"]["grade_class"] == "trunk"
assert "FOR UPDATE" in cursor.sql[0]
assert len(cursor.sql) == 2 and cursor.sql[1].startswith("UPDATE project_workflow_stages SET")
assert all("state" not in sql.lower() and "projects" not in sql for sql in cursor.sql[1:])
def test_요청_검증():
with pytest.raises(ValueError):
PageSettingsRequest(design_speed_kph=50)
with pytest.raises(ValueError):
PageSettingsRequest(terrain_type="flat")
with pytest.raises(ValueError):
PageSettingsRequest(max_grade_pct=0)
with pytest.raises(ValueError):
PageSettingsRequest(extra=1)
def test_작업임도_30은_20으로_params_는_원값():
request = PageSettingsRequest(design_speed_kph=30, terrain_type="normal")
params = merged_params({"options": {}}, page.params_patch(request))
assert params["options"]["design_speed_kph"] == 30 # 원값 저장(해석값 금지)
work = page.grade_options_of("work", params)
assert (work.design_speed_kph, work.max_grade_pct, work.min_vertical_radius_m) == (20, 9, 100)
trunk = page.grade_options_of("main", params) # B02 main = 간선
assert (trunk.design_speed_kph, trunk.max_grade_pct, trunk.min_vertical_radius_m) == (
30,
8,
250,
)
typed = page.grade_options_of("main", {**params, "max_grade_pct": 11.0})
assert typed.max_grade_pct == 11.0 and typed.warnings # 법정 8 초과 = 경고(막지 않음)
# ── 정책 갱신 ────────────────────────────────────────────────────────────────
def _longitudinal() -> dict:
"""앞 200 m 11 % 오르막 · 뒤 200 m 2 % — 특수 20(14 %)엔 위반 없음 · 일반 30(8 %)엔 위반."""
chainage = np.arange(0.0, 400.5, 1.0)
ground = np.where(chainage <= 200, 100 + 0.11 * chainage, 122 + 0.02 * (chainage - 200))
longitudinal = {
"samples": [
{"chainage_m": float(s), "elevation_m": float(z)} for s, z in zip(chainage, ground)
],
"stations": [
{"station_id": f"s{i}", "chainage_m": float(s)}
for i, s in enumerate(np.arange(0, 401, 20))
],
}
options = resolve_grade_options("trunk", terrain_type="special", design_speed_kph=20)
alignment, profile = design_alignment_profile(longitudinal, options)
edits = {"station_offsets": {"100.000": 0.8}, "curve_radii": {}}
longitudinal["profile_alignment"] = alignment
longitudinal["design_profiles"] = [profile]
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
longitudinal["profile_alignment"] = alignment
longitudinal["design_profiles"] = [profile]
return longitudinal
def test_정책_갱신_기하_그대로():
before = _longitudinal()
after = copy.deepcopy(before)
strict = resolve_grade_options("trunk", terrain_type="normal", design_speed_kph=30)
alignment, summary = page.retarget_longitudinal(after, strict)
policy = alignment["policy"]
assert (policy["max_grade_pct"], policy["min_vertical_radius_m"]) == (8, 250)
assert policy["min_plan_radius_m"] == 30 # 30 km/h 일반지형
criteria = after["design_profiles"][0]["criteria"]
assert (criteria["design_speed_kph"], criteria["terrain_type"]) == (30, "normal")
assert not before["profile_alignment"]["violations"]
assert after["profile_alignment"]["violations"] # 같은 선 · 엄한 기준 = 위반 뜸
assert summary["id"] == after["design_profiles"][0]["id"]
# 기하 그대로 — 자동 선형 · 편집 · 계획고 표본
assert alignment["base_pvi"] == before["profile_alignment"]["base_pvi"]
assert alignment["edits"] == before["profile_alignment"]["edits"]
z0 = [s["elevation_m"] for s in before["profile_alignment"]["samples"]]
z1 = [s["elevation_m"] for s in alignment["samples"]]
assert np.allclose(z0, z1)
def test_retarget_file_선형_없으면_그대로(tmp_path):
options = resolve_grade_options("trunk", terrain_type="normal")
rel = "B06_Section/longitudinal/longitudinal.json"
path = tmp_path / rel
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"samples": []}), encoding="utf-8")
assert page.retarget_file(tmp_path, rel, options) is None
assert json.loads(path.read_text(encoding="utf-8")) == {"samples": []}
assert page.retarget_file(tmp_path, "nope.json", options) is None
with pytest.raises(ValueError):
page.retarget_file(tmp_path, "../outside.json", options)
path.write_text(json.dumps(_longitudinal()), encoding="utf-8")
summary = page.retarget_file(tmp_path, rel, options)
stored = json.loads(path.read_text(encoding="utf-8"))
assert summary is not None and stored["profile_alignment"]["policy"]["max_grade_pct"] == 9
# ── E1 · E2 ──────────────────────────────────────────────────────────────────
def test_E1_재설계_요청이_설정을_옮김():
params = {
**PARAMS,
"options": {**PARAMS["options"], "design_speed_kph": 30},
"enforce_pipe_clearance": True,
"station_display": {"station": 12, "cumulative": 240},
}
request = redesign_solve_request(params, {"source_filter": "csf"}, 5)
assert request.design_speed_kph == 30
assert request.enforce_pipe_clearance is True
assert request.station_display == {"station": 12, "cumulative": 240}
assert request.terrain_type == "special"
def test_E2_B06_재생성_지형_기본_특수():
no_terrain = _regeneration_grade_options({"options": {"grade_class": "trunk"}})
assert no_terrain.terrain_type == "special" and no_terrain.max_grade_pct == 14
saved = _regeneration_grade_options(
{"options": {"grade_class": "trunk", "terrain_type": "normal", "design_speed_kph": 30}}
)
assert (saved.terrain_type, saved.max_grade_pct) == ("normal", 8)
def test_노선_추천_설정은_options_에서_읽음(monkeypatch):
from B05_Profile import B05_Profile_Router_Replan as replan
stage = {
"stage_no": 2,
"params": {"options": {"design_speed_kph": 30, "terrain_type": "normal"}},
}
class _Cur:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, *_):
return None
async def fetchone(self):
return ("main",)
class _Conn:
def cursor(self, *_):
return _Cur()
class _Acquire:
async def __aenter__(self):
return _Conn()
async def __aexit__(self, *_):
return False
monkeypatch.setattr(replan, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
async def fake_state(*_):
return {"stages": [stage]}
import common_util.common_util_workflow_state as workflow
monkeypatch.setattr(workflow, "get_workflow_state", fake_state)
assert asyncio.run(replan._road_settings("p1")) == ("main", 30, "normal")
# ── E3 스냅샷 ────────────────────────────────────────────────────────────────
class _SnapCursor:
def __init__(self, state):
self.state = state
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql, args=None):
self.state["sql"].append(sql)
if sql.strip().upper().startswith("UPDATE"):
self.state["params"] = args[0]
async def fetchone(self):
return (self.state["params"],)
class _SnapConn:
def __init__(self, state):
self.state = state
def cursor(self, *_):
return _SnapCursor(self.state)
def test_E3_스냅샷_stage2_왕복():
initial = {**PARAMS, "options": {**PARAMS["options"], "design_speed_kph": 20}}
state = {"params": json.dumps(initial), "sql": []}
dumped = asyncio.run(snapshot._stage2_params(_SnapConn(state), "p1"))
assert dumped == initial
state["params"] = json.dumps(
{**initial, "max_grade_pct": 7, "options": {"design_speed_kph": 40}}
)
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": dumped}))
assert json.loads(state["params"]) == initial
def test_E3_옛_스냅샷은_설정_칸만_걷음():
edited = {
**PARAMS,
"options": {**PARAMS["options"], "design_speed_kph": 40, "terrain_type": "normal"},
"min_vertical_radius_m": 500,
"enforce_pipe_clearance": True,
"station_display": {"station": 3, "cumulative": 60},
}
state = {"params": json.dumps(edited), "sql": []}
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"routes": []}))
restored = json.loads(state["params"])
assert restored["options"] == {"grade_class": "trunk", "paved": False}
for key in page.PAGE_TOP_KEYS:
assert key not in restored
assert restored["points"] == PARAMS["points"] and restored["start_elevation_offset_m"] == 0.4
def test_E3_뜰_params_없으면_안_씀():
state = {"params": None, "sql": []}
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": None}))
assert not any(sql.strip().upper().startswith("UPDATE") for sql in state["sql"])
# ── 화면(Node) ───────────────────────────────────────────────────────────────
def test_저장_차례_settings_는_노선_뒤_관_앞(out):
order = out["order"]
assert order.index("route") + 1 == order.index("settings") < order.index("alignment")
def test_노선_바꾼_저장에도_settings_기본_창구(out):
assert out["routeSaveDone"] is True
assert out["routeSaveCalls"] == ["route", "settings:p1"] # 화면 몫(own) 아님 · 기본 창구
def test_초안_내보내기(out):
assert out["putsWithoutDraft"] == 0
assert out["boom"] == "서버 500" and out["draftAfterBoom"] == out["draft"]
assert out["draftAfterOk"] is None and out["puts"] == 2
params = out["latestAfterOk"]["route_params"]
assert params["options"] == {
"grade_class": "trunk",
"design_speed_kph": 30,
"terrain_type": "normal",
}
assert params["max_grade_pct"] is None and params["min_vertical_radius_m"] == 300
assert params["station_display"] == {"station": 12, "cumulative": 240}
def test_초안_되살림_값(out):
restore = out["restoreFromDraft"]
assert restore["designSpeed"] == 30 and restore["maxGradePct"] is None
assert restore["stationDisplay"] == {"station": 12, "cumulative": 240}
def test_시종점_조정은_끝_측점_계획고_편집(out):
assert out["endsEmpty"] == {"start": 0, "end": 0}
assert out["endsRaised"] == {"start": 1.25, "end": 0}
assert out["endsBoth"] == {"start": 1.25, "end": -0.5}
assert out["planStart"] == pytest.approx(101.25) and out["planEnd"] == pytest.approx(107.5)
assert out["backToZero"]["start"] == 0
def test_시작_측점은_열쇠_다른_페이지에_안_섞임():
root = HERE.parents[1]
hits = subprocess.run(
["git", "grep", "-l", "station_display", "--", "*.py", "*.ts"],
capture_output=True,
text=True,
cwd=root,
encoding="utf-8",
).stdout.split()
allowed = {
"B03_FileInput/B03_FileInput_Service_Chain.py", # E1 옮김
"B05_Profile/B05_Profile_Api_Fetch.ts",
"B05_Profile/B05_Profile_Api_PageSettings.ts",
"B05_Profile/B05_Profile_PageSettings.py",
"B05_Profile/B05_Profile_Router.py",
"B05_Profile/B05_Profile_Schema.py",
"B05_Profile/B05_Profile_UI_Page_Latest.ts",
}
assert {hit for hit in hits if not hit.startswith("resources/")} <= allowed
def test_저장_정책을_다시_읽어도_평면_R_유지():
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
policy = AlignmentPolicy.from_dict({"min_plan_radius_m": 30, "max_grade_pct": 8})
assert policy.min_plan_radius_m == 30 and policy.hairpin_min_radius_m == 10
assert AlignmentPolicy.from_dict({}).min_plan_radius_m == 0 # 옛 정책 = 판정 안 함 그대로
+105
View File
@@ -0,0 +1,105 @@
"""PLAN 449 — B05 [저장] 관 목록 · 노선 저장 뒤 실패(433-7 · 433-12 · 433-6).
· 관 정본 PUT = 세션 관 초안 한 번(B06 고침 직후 [저장] = 초안 값) · 배수판 메모리는 안 보냄
· 초안 없음 = [저장] PUT 없음(판이 안 서도 관 수 그대로) · [확정] = 칸 없이(서버 저장분) · 빈 초안 = 다 지움
· 노선 저장 뒤 구조물 차례 실패 → 0 칸 계획고가 새 노선 칸에 · 횡단 초안 넘김(저장 없이) · 다시 세움 →
다음 [저장]이 새 노선으로 계획고를 보냄 · 넘김 자체가 실패하면 두 번 안 넘김
· 관 PUT 끝 측점 맞춤 실패 = 응답 `stations_failed`
짝: `helper_449_save_pipes.cjs`(실제 `design_save.ts` 를 Node 로 · 서버 창구만 가짜).
"""
from __future__ import annotations
import asyncio
import json
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@pytest.fixture(scope="module")
def out() -> dict:
if not (ROOT / "config" / "node_modules" / "typescript").is_dir():
pytest.skip("프론트엔드 의존성 미설치")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_449_save_pipes.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _puts(case: dict) -> list:
return [call for call in case["calls"] if call[0] == "PUT"]
def test_관_PUT_은_초안_한_번(out):
case = out["draft"]
assert case["ok"] and _puts(case) == [
["PUT", [{"chainage_m": 15, "source": "user", "id": "p1"}], False]
]
assert case["calls"].index(["RELOAD"]) > case["calls"].index(
_puts(case)[0]
) # 화면은 뒤에 받기만
assert case["left"] is None
def test_초안_없으면_PUT_없음(out):
assert out["noDraft"]["ok"] and _puts(out["noDraft"]) == []
def test_확정은_칸_없이_다시_셈(out):
assert _puts(out["confirmNoDraft"]) == [["PUT", None, True]]
def test_빈_초안은_다_지움(out):
assert _puts(out["emptyDraft"]) == [["PUT", [], False]]
def test_노선_저장_뒤_실패_초안_새_노선(out):
case = out["routeFail"]
assert not case["ok"] and "구조물" in case["failure"]
assert case["calls"] == [["ROUTE"], ["CARRY", 7, 8, False], ["REBUILD"]]
assert out["alignAfterFail"] == {"8": {"pvi": {"120": 1.5}}} # 0 칸 비움 · 새 노선 칸
align = [call for call in out["nextSave"]["calls"] if call[0] == "ALIGN"]
assert align == [["ALIGN", 8, {"pvi": {"120": 1.5}}]]
def test_넘김_실패면_두_번_안_넘김(out):
case = out["carryFail"]
assert not case["ok"]
assert [call for call in case["calls"] if call[0] == "CARRY"] == [["CARRY", 7, 8, True]]
assert case["calls"][-1] == ["REBUILD"]
def test_공용_저장이_판_메모리를_안_보냄():
shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8")
panel = (ROOT / "B05_Profile/B05_Profile_UI_Drainage_Panel.ts").read_text(encoding="utf-8")
assert "savePipes" not in shared and "saveDetailPipePoints" not in panel
def test_측점_맞춤_실패는_응답에(monkeypatch):
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from B06_Section import B06_Section_Router_Stations as stations
async def boom(_project_id):
raise RuntimeError("샘플링 없음")
async def fine(_project_id):
return 1
monkeypatch.setattr(stations, "ensure_structure_stations", boom)
assert asyncio.run(basins._stand_pipe_stations(uuid4())) is False
monkeypatch.setattr(stations, "ensure_structure_stations", fine)
assert asyncio.run(basins._stand_pipe_stations(uuid4())) is True
@@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
"""452 서버 — 구 구조물 이관 · 옛 횡단 파일 · 시종점 열쇠 (433-17① · 433-10 · 433-18).
못박는 것
- 이관 원천 = 저장 종단 파일의 비정규 측점 — 화면 본문(초안 관 자리 섞임)은 안 믿음 ·
저장 관 자리면 structures.json · pipe_points.json 그대로.
- 관 PUT 끝 측점 다시 뜸(`_regenerate_structure_stations`) = 옛 자리 cross_*.json 도 지움.
- 시종점 열쇠 한 규칙 — 맨 앞 · 맨 끝은 구조물이 얹혀도(병합이 종류를 irregular 로 갈음해도)
`end:BP/EP` · 구간형 시작 · 끝 = `:start` · `:end` · 서버 `station_keys` = B06 `stationKeysOf`.
"""
import asyncio
import json
import shutil
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B05_Profile.B05_Profile_Structures_Router as router_module # noqa: E402
import B06_Section.B06_Section_Repository as b06_repo # noqa: E402
from B05_Profile.B05_Profile_Engine_Sections import cross_filename # noqa: E402
from B05_Profile.B05_Profile_Route_Save_Parts import station_keys # noqa: E402
from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402
PROJECT_ID = "45245245-2452-4524-5245-245245245245"
LONG_REL = "B06_Section/longitudinal/longitudinal.json"
def _write_longitudinal(project: Path, stations):
path = project / LONG_REL
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"stations": stations}), encoding="utf-8")
return path
# ── 433-17① 이관 = 저장 종단 파일 ─────────────────────────────────────────
@pytest.fixture()
def migrate_client(tmp_path, monkeypatch):
project = tmp_path / "project"
async def fake_root(project_id):
return str(project)
async def fake_length(project_id):
return 400.0
async def fake_latest(connection, project_id):
return {"id": 7}
async def fake_longitudinal(connection, project_id, route_id):
assert route_id == 7
return {"longitudinal_file_path": LONG_REL}
class _Acquire:
async def __aenter__(self):
return object()
async def __aexit__(self, *exc):
return False
monkeypatch.setattr(router_module, "_project_root", fake_root)
monkeypatch.setattr(router_module, "_route_length", fake_length)
monkeypatch.setattr(router_module, "get_latest_route", fake_latest)
monkeypatch.setattr(router_module, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
monkeypatch.setattr(b06_repo, "get_longitudinal_section", fake_longitudinal)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app), project
def test_초안_측점_본문은_버리고_저장_관_자리는_이관_안_함(migrate_client):
client, project = migrate_client
pipes = pipe_points_path_in(project)
pipes.parent.mkdir(parents=True, exist_ok=True)
pipes.write_text(
json.dumps({"route_signature": "s", "points": [{"chainage_m": 351.0, "id": "p1"}]}),
encoding="utf-8",
)
_write_longitudinal(
project,
[
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 351.0, "kind": "irregular", "structure": "파형강관 D800"},
{"chainage_m": 400.0, "kind": "ep"},
],
)
before = pipes.read_bytes()
# 화면이 초안(관 351 → 364) 상세를 들고 와도 — 옛 본문 꼴을 보내 봄
body = {"route_id": 7, "stations": [{"chainage_m": 364.0, "structure": "낙석방지책"}]}
response = client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=body)
assert response.status_code == 200, response.text
assert response.json()["migrated"] == 0
assert not (project / "B05_Profile" / "route" / "structures.json").exists()
assert pipes.read_bytes() == before
def test_저장_종단의_옛_자유글_측점은_이관(migrate_client):
client, project = migrate_client
_write_longitudinal(
project,
[
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 260.0, "kind": "irregular", "structure": "낙석방지책"},
{"chainage_m": 400.0, "kind": "ep"},
],
)
response = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"route_id": None}
)
assert response.status_code == 200, response.text
assert response.json()["migrated"] == 1
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
assert [(item["type_id"], item["chainage_m"]) for item in stored] == [("etc", 260.0)]
def test_저장_종단이_없으면_아무것도_안_함(migrate_client):
client, project = migrate_client
response = client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={})
assert response.status_code == 200 and response.json()["migrated"] == 0
assert not (project / "B05_Profile" / "route" / "structures.json").exists()
# ── 433-10 관 PUT 측점 다시 뜸 = 옛 cross 파일 지움 ─────────────────────────
def test_관_옮김_측점_다시_뜸이_옛_자리_횡단_파일을_지움(tmp_path, monkeypatch):
import B05_Profile.B05_Profile_Engine_Sections as engine
import B06_Section.B06_Section_Router_Stations as stations_module
_write_longitudinal(
tmp_path,
[
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 351.0, "kind": "irregular", "structure": "파형강관 D800"},
{"chainage_m": 400.0, "kind": "ep"},
],
)
cross_dir = tmp_path / "B06_Section" / "cross_sections"
cross_dir.mkdir(parents=True)
for at in (0.0, 351.0, 400.0):
(cross_dir / cross_filename(at)).write_text("{}", encoding="utf-8")
def fake_generate(*args, **kwargs):
(cross_dir / cross_filename(361.0)).write_text("{}", encoding="utf-8")
return [{"chainage_m": 361.0, "kind": "irregular", "structure": "파형강관 D800"}]
async def fake_inputs(project_id, route, snapshot):
return None, None, {"longitudinal_file_path": LONG_REL}
async def fake_run(fn, *args):
return None # DB 행 맞춤 · 등급 요약은 이 시험 밖
monkeypatch.setattr(engine, "_load_route_polyline", lambda *a: [[0.0, 0.0], [400.0, 0.0]])
monkeypatch.setattr(engine, "_load_pipe_points", lambda *a: [])
monkeypatch.setattr(engine, "resolve_extra_stations", lambda *a: [])
monkeypatch.setattr(engine, "generate_irregular_sections", fake_generate)
monkeypatch.setattr(stations_module, "_generation_inputs", fake_inputs)
monkeypatch.setattr(stations_module, "run_with_connection", fake_run)
made = asyncio.run(
stations_module._regenerate_structure_stations(
PROJECT_ID,
tmp_path,
{"id": 7, "route_data_path": "route.json"},
{"filter_key": "f", "method": "m"},
)
)
assert made == 1
assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted(
cross_filename(at) for at in (0.0, 361.0, 400.0)
)
# ── 433-18 시종점 열쇠 한 규칙 ─────────────────────────────────────────────
STRUCTURES = [
{"structure_id": "w1", "type_id": "retaining_wall", "chainage_m": 80.0, "start_m": 60.0},
]
# 구간형 옹벽 끝 = 노선 끝(EP) — 병합이 EP 행 종류를 irregular 로 갈음한 저장분 꼴
SECTIONS = [
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 20.0, "kind": "regular"},
{"chainage_m": 40.0, "kind": "regular"},
{"chainage_m": 60.0, "kind": "irregular", "structure": "옹벽"},
{"chainage_m": 80.0, "kind": "irregular", "structure": "옹벽"},
{"chainage_m": 100.0, "kind": "irregular", "structure": "옹벽"},
]
def _structures_ns():
return [SimpleNamespace(**item, end_m=100.0, options={}) for item in STRUCTURES]
def test_서버_시종점_열쇠가_구조물보다_먼저():
keys = station_keys(SECTIONS, [], _structures_ns(), 20.0)
assert keys == {
"end:BP": 0.0,
"grid:1": 20.0,
"grid:2": 40.0,
"structure:w1:start": 60.0,
"structure:w1": 80.0,
"end:EP": 100.0,
}
def test_B06_열쇠가_서버와_같음(tmp_path):
work = tmp_path / "b452"
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_452_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_452_entry.js", bundle)
source = work / "input.json"
source.write_text(
json.dumps(
{
"sections": SECTIONS,
"structures": [{**item, "end_m": 100.0} for item in STRUCTURES],
"interval": 20.0,
}
),
encoding="utf-8",
)
proc = subprocess.run(
["node", str(HERE / "helper_452_keys.mjs"), str(bundle), str(source)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
ts = {entry["key"]: entry["chainageM"] for entry in json.loads(proc.stdout)}
assert ts == station_keys(SECTIONS, [], _structures_ns(), 20.0)
def test_노선_저장_이월_EP_고침이_새_EP_에_남음():
from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs
old_keys = station_keys(SECTIONS, [], _structures_ns(), 20.0)
# 노선이 늘어 옹벽이 따라감 — 끝(EP) 110 · 시작 70 · 기준 90
moved = [
SimpleNamespace(
structure_id="w1",
type_id="retaining_wall",
chainage_m=90.0,
start_m=70.0,
end_m=110.0,
options={},
)
]
new_sections = [
{"chainage_m": at, "kind": "regular"} for at in (0.0, 20.0, 40.0, 60.0, 70.0, 80.0, 90.0)
] + [{"chainage_m": 100.0, "kind": "regular"}, {"chainage_m": 110.0, "kind": "irregular"}]
new_keys = station_keys(new_sections, [], moved, 20.0)
designs = {100.0: {"ground_type": "rock"}, 60.0: {"ground_type": "weathered"}}
inputs, _, dropped = carry_cross_inputs(old_keys, new_keys, designs, ("ground_type",), ())
assert inputs[110.0] == {"ground_type": "rock"} # EP 고침 = 새 EP
assert inputs[70.0] == {"ground_type": "weathered"} # 구간 시작 고침 = 따라간 시작
assert dropped == 0
@@ -0,0 +1,35 @@
"""454(433-21 ②) 방향키 측점 No. 20 m 걸음 막힘 — 곡선 맞춤이 후보 측점 둘(내림 · 올림) + 뒤 곡선
앞보기로 고름. 무작위 노선 열 개 · 교점마다 네 방향 = 520 누름(`helper_454_arrow_station_step.cjs`).
고치기 전 측점 밖 막힘 48 · 처음 맞춤 측점 밖 2 · 놓은 뒤 측점 밖 43 → 6 · 0 · 0.
./venv/Scripts/python.exe -m pytest resources/tester/test_454_arrow_station_step.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
HELPER = Path(__file__).with_name("helper_454_arrow_station_step.cjs")
@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 test_누름_수(got) -> None:
assert got["press"] == 520
def test_측점_밖_막힘_줄어듦(got) -> None:
assert got["offBlock"] <= 10
def test_처음_맞춤_놓은_뒤_측점_밖_없음(got) -> None:
assert got["offStart"] == 0 and got["offAfterRelease"] == 0
@@ -0,0 +1,52 @@
"""455 (433-13 ①) 종단 ▲▼ — 누른 꼭짓점의 자동 R 을 지워 기본 L.
까닭: ▲▼ 가 누른 꼭짓점 자신의 R 을 남겨 거의 직선이 된 꼭짓점에 옛 자동 R(관 정착 역산값)로
짧은 곡선이 생김. 자동 R = 서버 저장분 곡선반경과 같은 값 · 사용자가 넣은 R 은 둠.
계산은 `helper_455_arrow_auto_radius.cjs` 가 진짜 TS 를 Node 로 돌림 · 부르는 곳 셋은 소스로 봄.
"""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_455_arrow_auto_radius.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_자동_R_은_지워_기본_L(out) -> None:
assert out["auto"]["radius"] is None
assert out["auto"]["length"] == pytest.approx(15.0, abs=1e-6)
# 옛 판(지우지 않음)은 같은 R 로 짧은 곡선 — 까닭 근거
assert out["legacy"]["radius"] == pytest.approx(3000.0)
assert out["legacy"]["length"] < 14.0
def test_사용자_R_은_그대로(out) -> None:
assert out["user"]["radius"] == 777
def test_부르는_곳_셋이_저장_R_을_넘김() -> None:
def read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
render = read("B05_Profile/B05_Profile_UI_Profile_Render.ts")
assert render.count("chainage, delta, store.savedRadii())") == 2
tools = read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in tools
assert "savedRadii: () => store.savedRadii()," in read("A00_Common/design/design_profile.ts")
@@ -0,0 +1,122 @@
"""455 (433-13 ②) 폴백 첫 종단 직선화 — 관 정착 · 직선 분할도 R > 500 m 꼭짓점 없음.
까닭: 초기값 직선화(438)가 1순위 지반 추종 선형에만 걸려, 1순위가 실패해 2 · 3순위(관 정착 ·
직선 분할)로 내려가면 R > 500 m 꼭짓점이 남음. 사용자 확정(269-2) — 직선화 높이 한도 없음.
"""
from __future__ import annotations
import math
import sys
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.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
AlignmentPolicy,
build_curves,
is_straight_bend,
)
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_alignment_profile,
design_pipe_anchored_profile,
)
PIPES = [60.0, 125.0, 190.0, 260.0, 330.0]
def _ground(s: float) -> float:
return 100.0 + 0.06 * s + 2.5 * math.sin(s / 45.0) + 0.05 * math.sin(s / 6.0)
def _longitudinal() -> dict:
samples = [
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
for s in range(0, 401)
]
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
return {"samples": samples, "stations": stations}
OPTIONS = GradeDesignOptions(
max_grade_pct=14.0,
max_reverse_grade_pct=5.0,
min_vertical_radius_m=100.0,
min_curve_length_m=20.0,
min_tangent_length_m=20.0,
vertical_curve_skip_delta_pct=0.5,
design_speed_kph=20,
)
def _flat(alignment: dict) -> tuple[list[float], int]:
"""(R > 500 m 꺾임 측점 · 직선 위로 옮긴 꼭짓점 수) — 438 시험과 같은 판정."""
s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64)
z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64)
policy = AlignmentPolicy.from_dict(alignment["policy"])
grades = np.diff(z) / np.diff(s)
straight = np.zeros(len(s), dtype=bool)
for i in range(1, len(s) - 1):
straight[i] = is_straight_bend(grades[i] - grades[i - 1], s[i] - s[i - 1], s[i + 1] - s[i])
flat = flat_bends(s, z, policy.default_curve_length_m) & ~straight
assert all(float(c["r_m"]) <= 500.0 for c in alignment["curves"])
return s[flat].tolist(), int(straight.sum())
def test_관_정착_선형_R500_넘는_꼭짓점_없음() -> None:
alignment, _profile = design_pipe_anchored_profile(_longitudinal(), OPTIONS, PIPES)
flat, straight = _flat(alignment)
assert flat == [] and 0 < straight < len(PIPES)
# 직선 위로 옮긴 관 꼭짓점은 자동 R 을 안 남김 · 남은 꺾임은 곡선이 관 자리를 지남
radii = alignment["edits"]["curve_radii"]
kinks = {n["chainage_m"] for n in alignment["base_pvi"][1:-1]}
assert set(map(float, radii)) <= kinks
s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64)
z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64)
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(alignment["policy"]), radii)
assert all(float(c["chainage_m"]) in kinks for c in curves)
def test_직선_분할_선형_R500_넘는_꼭짓점_없음(monkeypatch) -> None:
alignment, _profile = design_alignment_profile(_longitudinal(), OPTIONS)
assert _flat(alignment)[0] == []
# 풀이가 R > 500 m 꺾임(100 m · 대수차 0.5 %)을 낸 꼴 — 직선 위로 · 200 m 꺾임은 그대로
import B05_Profile.B05_Profile_Engine_Grade_Profile as engine
s = np.array([0.0, 100.0, 200.0, 400.0])
z = np.array([100.0, 103.0, 106.5, 124.5])
monkeypatch.setattr(engine, "station_breakpoints", lambda *_a, **_k: s)
monkeypatch.setattr(engine, "solve_alignment_elevations", lambda *_a, **_k: (z.copy(), True))
alignment, _profile = engine.design_alignment_profile(_longitudinal(), OPTIONS)
flat, straight = _flat(alignment)
assert flat == [] and straight == 1
nodes = {n["chainage_m"]: n["elevation_m"] for n in alignment["base_pvi"]}
assert nodes[100.0] == pytest.approx(103.25)
assert nodes[200.0] == pytest.approx(106.5)
def test_부르는_곳() -> None:
src = (ROOT / "B05_Profile" / "B05_Profile_Engine_Grade_Profile.py").read_text(encoding="utf-8")
assert src.count("flat_bends(base_s, ") >= 3 # 지반 추종(straight_pass_through 안) 밖 둘
def test_여유로_들린_관_꼭짓점은_그대로() -> None:
"""관 여유 강제(+1.5 m)로 들린 관 꼭짓점 = 직선화 경계 · 여유 없는 관 꼭짓점만 직선 위."""
clearances = {125.0: 1.5}
alignment, _profile = design_pipe_anchored_profile(
_longitudinal(), OPTIONS, PIPES, pipe_clearances=clearances
)
flat, straight = _flat(alignment)
assert straight > 0
assert flat in ([], [125.0]) # 들린 꼭짓점만 R > 500 이 남을 수 있음
rows = {r["chainage_m"]: r["plan_elevation_m"] for r in alignment["stations"]}
plan = np.interp(125.0, list(rows), list(rows.values()))
assert plan > _ground(125.0) + 1.0
@@ -0,0 +1,105 @@
"""455 (433-13 ③) 옛 정책 종단 — 법정 최소 R · L 미달 경고가 초안 · 저장 뒤 같음.
까닭: 옛 저장 정책엔 `min_vertical_radius_m` · `min_curve_length_m` 가 없어 화면 초안(저장
정책 그대로) 경고 0 · 노선 [저장](새 정책)에서야 뜸. 고침: 빈 값은 첫 계획선 설계 기준(criteria =
설계속도 법정값)
— TS `readAlignment` ↔ 서버 `rebuild_alignment_profile` 같은 규칙(`helper_455_old_policy.cjs`).
"""
from __future__ import annotations
import copy
import json
import math
import subprocess
import sys
from pathlib import Path
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.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_ground_following_profile,
rebuild_alignment_profile,
)
# 설계속도 40 — 법정 최소 R 450 · L 40 > 기본 L 20 → 곡선마다 미달
OPTIONS = GradeDesignOptions(
max_grade_pct=14.0,
max_reverse_grade_pct=5.0,
min_vertical_radius_m=450.0,
min_curve_length_m=40.0,
min_tangent_length_m=20.0,
vertical_curve_skip_delta_pct=0.5,
design_speed_kph=40,
)
def _ground(s: float) -> float:
return 100.0 + 0.05 * s + 2.0 * math.sin(s / 30.0)
def _fresh() -> dict:
samples = [
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
for s in range(0, 401)
]
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
data = {"length_m": 400.0, "samples": samples, "stations": stations}
alignment, profile = design_ground_following_profile(data, OPTIONS, station_interval_m=20.0)
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
return data
def _old() -> dict:
data = _fresh()
for key in ("min_vertical_radius_m", "min_curve_length_m"):
data["profile_alignment"]["policy"].pop(key)
return data
def _law(warnings: list[str]) -> list[str]:
return [text for text in warnings if "법정 최소" in text]
def _ts(tmp_path: Path, data: dict) -> dict:
source = tmp_path / "in455.json"
source.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_455_old_policy.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_새_정책_경고_기준(tmp_path: Path) -> None:
fresh = _fresh()
law = _law(fresh["profile_alignment"]["warnings"])
assert law
assert _ts(tmp_path, fresh)["warnings"] == law
def test_옛_정책_초안_경고_저장_뒤와_같음(tmp_path: Path) -> None:
expected = _law(_fresh()["profile_alignment"]["warnings"])
old = _old()
ts = _ts(tmp_path, copy.deepcopy(old))
assert ts["policy"]["min_vertical_radius_m"] == 450.0
assert ts["policy"]["min_curve_length_m"] == 40.0
assert ts["warnings"] == expected
alignment, _profile = rebuild_alignment_profile(old, {})
assert _law(alignment["warnings"]) == expected
assert alignment["policy"]["min_curve_length_m"] == 40.0
def test_criteria_없는_판은_그대로(tmp_path: Path) -> None:
old = _old()
old["design_profiles"][0].pop("criteria")
assert _ts(tmp_path, copy.deepcopy(old))["warnings"] == []
+11 -1
View File
@@ -30,7 +30,13 @@ def _make_client(tmp_path, monkeypatch, invalidated_calls, *, stale_ok=True, rou
monkeypatch.setattr(router_module, "_project_root", fake_project_root)
monkeypatch.setattr(router_module, "_invalidate_downstream", fake_invalidate)
async def fake_saved_irregular(project_id, root, route_id):
# 이관 원천 = 저장 종단 파일(452) — 본문 대신 여기서 같은 측점을 줌
return _migrate_payload()["stations"]
monkeypatch.setattr(router_module, "_route_length", fake_route_length)
monkeypatch.setattr(router_module, "_saved_irregular_stations", fake_saved_irregular)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
@@ -298,7 +304,11 @@ def test_migrate_keeps_existing_structures(client, tmp_path):
assert len(stored) == 3 # 기존 1 + 이관 2(기슭막이는 관 지점으로)
def test_migrate_empty_list_is_noop(client):
def test_migrate_empty_list_is_noop(client, monkeypatch):
async def none_saved(project_id, root, route_id):
return []
monkeypatch.setattr(router_module, "_saved_irregular_stations", none_saved)
response = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"stations": []}
)
@@ -50,6 +50,7 @@ def test_계획고_가게_한_벌(out):
def test_저장_차례(out):
assert out["order"] == [
"route",
"settings", # 448 페이지 설정 — 노선 뒤
"pipes",
"structures",
"plusStations", # 368-2 추가 측점 — 계획고 앞