Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_8
This commit is contained in:
@@ -65,6 +65,8 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
|
||||
}
|
||||
},
|
||||
},
|
||||
// 측점 열쇠 세우기(344-3) — 이 시험 몫 아님
|
||||
"./B06_Section_Station_Keys": { useStationKeys: () => {}, useStationOwners: () => {} },
|
||||
"./B06_Section_Api_Fetch": {
|
||||
previewStructureSections: async (_projectId, chainages) => {
|
||||
asked.push(chainages);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* 344-3 · 346-2 2파 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
|
||||
* 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross · masshaul · haul 단계를 화면 · 서버 없이 잰다.
|
||||
*/
|
||||
export {
|
||||
carryStationDrafts,
|
||||
chainageOfKey,
|
||||
keyAt,
|
||||
readKeyed,
|
||||
useStationKeys,
|
||||
useStructureIds,
|
||||
} from "../../B06_Section/B06_Section_Station_Keys";
|
||||
export {
|
||||
crossDesignChoices,
|
||||
readShoulderExtends,
|
||||
writeCrossDesignChoice,
|
||||
writeShoulderExtend,
|
||||
} from "../../B06_Section/B06_Section_Cross_Design_Session";
|
||||
export {
|
||||
createRockBoundaryStore,
|
||||
readRockBoundarySession,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
export { createCrossStage, groundSamples } from "../../B06_Section/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 } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
export { readState, stateKey, writeState } from "../../A00_Common/b_page_state";
|
||||
@@ -0,0 +1,240 @@
|
||||
/* 344-3 · 346-2 2파 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_344_3_entry.ts)을 부르고 결과를 JSON 한 벌로.
|
||||
* node helper_344_3_station_keys.mjs <묶음.mjs> <입력.json>
|
||||
* 입력 = { standard: 파이썬 config 표준단면, policy: 종단 정책 }.
|
||||
*/
|
||||
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;
|
||||
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
|
||||
|
||||
const [bundlePath, inputPath] = process.argv.slice(2);
|
||||
const m = await import(pathToFileURL(bundlePath).href);
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||
const P = "p344";
|
||||
const R = 7;
|
||||
const R2 = 8;
|
||||
const raw = (name, route) => m.readState(name, P, route) ?? {};
|
||||
const keysOf = (name, route) =>
|
||||
Object.keys(raw(name, route))
|
||||
.filter((k) => k !== "@at")
|
||||
.sort();
|
||||
const section = (chainage_m, kind, extra = {}) => ({
|
||||
station_id: `s${chainage_m}`,
|
||||
chainage_m,
|
||||
kind,
|
||||
label: String(chainage_m),
|
||||
samples: [],
|
||||
...extra,
|
||||
});
|
||||
const detailOf = (sections) => ({
|
||||
longitudinal: {
|
||||
stations: sections,
|
||||
profile_alignment: { policy: { station_interval_m: 20 } },
|
||||
},
|
||||
cross_sections: sections,
|
||||
});
|
||||
const choice = (ground) => ({
|
||||
ground_type: ground,
|
||||
section_mode: "right_cut",
|
||||
ditch_side: "right",
|
||||
ditch_type: "l_type",
|
||||
paved: true,
|
||||
two_stage_slope: true,
|
||||
ditch_choice: false,
|
||||
});
|
||||
const out = {};
|
||||
|
||||
/* 1. 옛 초안(누가거리 열쇠) → 측점 열쇠로 옮김 · 지금 측점에 없는 것은 남겨 둠 */
|
||||
const oldRoute = detailOf([
|
||||
section(0, "bp"),
|
||||
section(20, "regular"),
|
||||
section(40, "regular"),
|
||||
section(45.054, "irregular", { structure: "파형강관 D800", culvert: {} }),
|
||||
section(52.5, "irregular", { structure: "대피로" }),
|
||||
section(60, "regular"),
|
||||
section(70, "ep"),
|
||||
]);
|
||||
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 52.5 }]);
|
||||
m.useStationKeys(P, R, oldRoute);
|
||||
m.writeState(
|
||||
"crossdesign",
|
||||
{
|
||||
"20.00": choice("soil"),
|
||||
45.05: choice("blasting_rock"),
|
||||
"52.50": choice("soil"),
|
||||
"60.00": choice("soil"),
|
||||
"99.00": choice("soil"),
|
||||
},
|
||||
P,
|
||||
R,
|
||||
);
|
||||
out.legacyRead = [...m.crossDesignChoices(P, R).keys()].sort((a, b) => a - b);
|
||||
out.legacyKeys = keysOf("crossdesign", R);
|
||||
|
||||
/* 2. 쓰기 = 측점 열쇠 · 누가거리로 다시 읽음 · 암 경계 저장소도 같은 열쇠 */
|
||||
m.writeCrossDesignChoice(P, R, 40, choice("ripping_rock"));
|
||||
m.writeShoulderExtend(P, R, 40, "left", 0.5);
|
||||
out.writeKey = keysOf("crossdesign", R).includes("grid:2");
|
||||
out.shoulderKeys = keysOf("shoulderext", R);
|
||||
const rock = m.createRockBoundaryStore({
|
||||
sessionKey: () => m.stateKey("rockb", P, R),
|
||||
detail: () => oldRoute,
|
||||
refreshCard: () => undefined,
|
||||
recompute: () => undefined,
|
||||
});
|
||||
rock.control.adjust(20, -0.3);
|
||||
out.rockKeys = keysOf("rockb", R);
|
||||
rock.load();
|
||||
out.rockLoaded = Object.fromEntries(rock.offsets);
|
||||
out.rockSession = m.readRockBoundarySession(P, R);
|
||||
|
||||
/* 3. 구조물 id 를 아직 모를 때(첫 열기) — 쓴 때 자리(@at)로 그 측점 고침을 읽음 */
|
||||
m.useStructureIds(P, []);
|
||||
m.useStationKeys(P, R, oldRoute);
|
||||
out.hintKeyNow = m.keyAt(P, R, 52.5);
|
||||
out.hintRead = m.crossDesignChoices(P, R).has(52.5);
|
||||
|
||||
/* 4. 노선이 바뀜 — 측점 열쇠로 넘김 · 넘김 = 카드 선택 넷 · 암 경계 · 노견 확장 안 넘김 · 사라진 측점 = 노선 밖 */
|
||||
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P); // 관이 47 m 로 옮김
|
||||
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 55 }]);
|
||||
m.useStationKeys(
|
||||
P,
|
||||
R2,
|
||||
detailOf([
|
||||
section(0, "bp"),
|
||||
section(20, "regular"),
|
||||
section(40, "regular"),
|
||||
section(47, "irregular", { structure: "파형강관 D800", culvert: {} }),
|
||||
section(55, "irregular", { structure: "대피로" }),
|
||||
section(58, "ep"),
|
||||
]),
|
||||
);
|
||||
// 옛 노선 목록(st-1 은 52.5 · 관 장부는 옛 자리 기준으로 이미 열쇠가 섬)
|
||||
out.outside = m.carryStationDrafts(P, R, R2);
|
||||
out.carriedKeys = keysOf("crossdesign", R2);
|
||||
out.carriedChoice = raw("crossdesign", R2)["grid:1"];
|
||||
out.carriedRead = [...m.crossDesignChoices(P, R2).keys()].sort((a, b) => a - b);
|
||||
out.carriedRock = m.readRockBoundarySession(P, R2);
|
||||
out.carriedShoulder = keysOf("shoulderext", R2);
|
||||
|
||||
/* 5. cross 단계 — 가짜 입력 · 같은 입력 0 · 측점 하나 고침 = 1 · 범위 밖 재사용 */
|
||||
const grid = [0, 20, 40, 60, 80, 100];
|
||||
const groundAt = (c) => 100 + 0.05 * c - 3 * Math.sin(c / 17);
|
||||
const base = {
|
||||
policy: input.policy,
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: grid.map((c, i) => ({ chainage_m: c, elevation_m: groundAt(c) + (i % 2 ? 0.8 : -0.6) })),
|
||||
chainage: Array.from({ length: 101 }, (_, i) => i),
|
||||
ground: Array.from({ length: 101 }, (_, i) => groundAt(i)),
|
||||
stations: grid.map((c) => ({ station_id: `s${c}`, chainage_m: c })),
|
||||
};
|
||||
const profile = m.buildAlignment(base, m.emptyEdits());
|
||||
const side = (c, sign) =>
|
||||
Array.from({ length: 24 }, (_, i) => ({
|
||||
offsetM: (i + 1) * 0.5,
|
||||
z: groundAt(c) + sign * 0.3 * (i + 1) * 0.5,
|
||||
}));
|
||||
const ground = {
|
||||
stations: grid.map((c, i) => ({
|
||||
key: i === 0 ? "end:BP" : i === grid.length - 1 ? "end:EP" : `grid:${i}`,
|
||||
chainageM: c,
|
||||
x: c,
|
||||
y: 0,
|
||||
azimuthDeg: 90,
|
||||
left: [0, 1],
|
||||
kind: i === 0 || i === grid.length - 1 ? "end" : "grid",
|
||||
id: null,
|
||||
ground: { centerZ: groundAt(c), left: side(c, 1), right: side(c, -1) },
|
||||
planRadiusM: null,
|
||||
curveOuterSide: null,
|
||||
curveWideningM: null,
|
||||
})),
|
||||
longitudinal: { chainageM: [], z: [] },
|
||||
};
|
||||
const samples = m.groundSamples(ground.stations[1]);
|
||||
out.samplesSorted = samples.every((s, i) => i === 0 || s.offset_m > samples[i - 1].offset_m);
|
||||
out.samplesCount = samples.length;
|
||||
const { stage, counts } = m.createCrossStage();
|
||||
const ctxOf = (cross, range, prev, prof = profile) => ({
|
||||
generation: 1,
|
||||
range,
|
||||
signal: new AbortController().signal,
|
||||
inputs: { route: null, alignment: null, cross, pipes: null, structures: null },
|
||||
out: { profile: prof, ground },
|
||||
before: {},
|
||||
prev,
|
||||
});
|
||||
const crossInput = { standard: input.standard, byKey: { "grid:2": { groundType: "soil" } } };
|
||||
const first = stage(ctxOf(crossInput, null, null));
|
||||
out.crossFirst = { ...counts(), keys: Object.keys(first.byKey).length };
|
||||
out.crossHasArea = Object.values(first.byKey).every((r) => Number.isFinite(r.cutAreaM2));
|
||||
stage(ctxOf(crossInput, null, first));
|
||||
out.crossSame = counts();
|
||||
const touched = { ...crossInput, byKey: { ...crossInput.byKey, "grid:3": { groundType: "soil" } } };
|
||||
const third = stage(
|
||||
ctxOf(touched, { sources: ["cross"], fromM: 60, toM: 60, keys: ["grid:3"] }, first),
|
||||
);
|
||||
out.crossOne = counts();
|
||||
out.crossOneChanged = third.byKey["grid:3"].detail.ground_type;
|
||||
const edited = m.buildAlignment(base, { station_offsets: { "40.000": 0.4 }, curve_radii: {} });
|
||||
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));
|
||||
@@ -111,6 +111,7 @@ const frames = (n) => {
|
||||
|
||||
let terrainLoads = 0;
|
||||
let resolveTerrain;
|
||||
let opens = 0; // 345-4 — 열 때(지형 뒤) 낡은 3D 길 모형 다시 짓기 부름
|
||||
const view3d = createViewer3dModal({
|
||||
viewer,
|
||||
loadTerrain: () => {
|
||||
@@ -118,6 +119,7 @@ const view3d = createViewer3dModal({
|
||||
return new Promise((resolve) => (resolveTerrain = resolve));
|
||||
},
|
||||
visible: () => true,
|
||||
onOpen: () => (opens += 1),
|
||||
});
|
||||
|
||||
(async () => {
|
||||
@@ -131,12 +133,15 @@ const view3d = createViewer3dModal({
|
||||
const first = modals[0];
|
||||
out.wide = first.opts.wide === true;
|
||||
out.inModal = viewer.root.parent === first.body;
|
||||
await new Promise((r) => setImmediate(r));
|
||||
out.opensBeforeTerrain = opens;
|
||||
out.spinnerWhileLoading = viewer.root.children.length === 1;
|
||||
frames(3);
|
||||
out.openDraws = viewer.draws;
|
||||
resolveTerrain();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
out.spinnerGone = viewer.root.children.length === 0;
|
||||
out.opensAfterTerrain = opens;
|
||||
|
||||
// 닫기 단추 · Esc · 바깥 누름 = beforeClose 뒤 닫힘(공용 모달 규칙)
|
||||
out.beforeCloseOk = (await first.opts.beforeClose()) === true;
|
||||
@@ -145,6 +150,8 @@ const view3d = createViewer3dModal({
|
||||
out.afterCloseDraws = viewer.draws;
|
||||
out.backInPark = viewer.root.parent === view3d.park;
|
||||
out.disposedOnClose = viewer.disposed;
|
||||
await new Promise((r) => setImmediate(r));
|
||||
out.opensWhileClosed = opens;
|
||||
|
||||
view3d.open();
|
||||
view3d.open(); // 열린 동안 또 눌러도 하나
|
||||
@@ -152,10 +159,27 @@ const view3d = createViewer3dModal({
|
||||
out.terrainLoads = terrainLoads;
|
||||
frames(2);
|
||||
out.reopenDraws = viewer.draws;
|
||||
await new Promise((r) => setImmediate(r));
|
||||
out.opensAfterReopen = opens;
|
||||
|
||||
view3d.dispose(); // 페이지 떠남 — 열려 있으면 닫고 버림
|
||||
out.closedOnDispose = modals[1].open === false;
|
||||
out.disposed = viewer.disposed;
|
||||
out.runningAfterDispose = viewer.running;
|
||||
|
||||
// 지형 받는 사이 닫으면 — 다시 짓기 안 부름(닫힌 동안 짓기 0)
|
||||
let lateOpens = 0;
|
||||
let lateResolve;
|
||||
const late = createViewer3dModal({
|
||||
viewer: { root: new FakeNode("div"), setActive() {}, dispose() {} },
|
||||
loadTerrain: () => new Promise((resolve) => (lateResolve = resolve)),
|
||||
visible: () => true,
|
||||
onOpen: () => (lateOpens += 1),
|
||||
});
|
||||
late.open();
|
||||
late.close();
|
||||
lateResolve();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
out.closedBeforeTerrainOpens = lateOpens;
|
||||
console.log(JSON.stringify(out));
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/* 345-4 3D 길 모형 웹 워커 헬퍼 — 워커 몸(`handleCorridorRequest`) · 부르기(`buildCorridorOffThread`)를
|
||||
* 트랜스파일해 같은 입력으로 주 스레드 `buildCorridor` 와 견줌. 워커 경계는 structuredClone(+transfer)로 흉내 —
|
||||
* 브라우저 postMessage 와 같은 복제 규칙. 측점 꼴은 helper_115_8_corridor_loft.cjs 와 같다. 결과는 JSON 한 벌.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
|
||||
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.replace(/import\.meta\.url/g, '"file:///x.js"');
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request.startsWith("@ui/") || request.startsWith("@config/"))
|
||||
return new Proxy({}, { get: () => () => undefined });
|
||||
const target = request.startsWith("@util/")
|
||||
? path.join(ROOT, "common_util", request.slice(6))
|
||||
: path.join(path.dirname(full), request);
|
||||
if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8"));
|
||||
return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts"));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
|
||||
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
|
||||
const { handleCorridorRequest, corridorTransfers } = loadTs(B05("B05_Profile_UI_Corridor_Worker"));
|
||||
const { buildCorridorOffThread } = loadTs(B05("B05_Profile_UI_Corridor_Worker_Client"));
|
||||
|
||||
const HALF = 3;
|
||||
const ROAD_Z = 100;
|
||||
function station(chainage, cx, cy, lx, ly, groundZ, ratio) {
|
||||
const w = Math.abs(groundZ - ROAD_Z) * ratio;
|
||||
const mode = groundZ < ROAD_Z ? "both_fill" : "both_cut";
|
||||
const pts = [
|
||||
[-40, groundZ],
|
||||
[-(HALF + w), groundZ],
|
||||
[-HALF, ROAD_Z],
|
||||
[-2.5, ROAD_Z],
|
||||
[2.5, ROAD_Z],
|
||||
[HALF, ROAD_Z],
|
||||
[HALF + w, groundZ],
|
||||
[40, groundZ],
|
||||
];
|
||||
const samples = [];
|
||||
for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ });
|
||||
return {
|
||||
station_id: `S${chainage}`,
|
||||
chainage_m: chainage,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_x: cx,
|
||||
center_y: cy,
|
||||
center_z: groundZ,
|
||||
frame: { left_xy: [lx, ly] },
|
||||
samples,
|
||||
design: {
|
||||
section_mode: mode,
|
||||
road_edges: {
|
||||
left: { offset_m: HALF, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -HALF, elevation_m: ROAD_Z },
|
||||
},
|
||||
carriageway_edges: {
|
||||
left: { offset_m: 2.5, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -2.5, elevation_m: ROAD_Z },
|
||||
},
|
||||
design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||
ditch: { type: "none" },
|
||||
ditch_enabled: false,
|
||||
ditch_side: "left",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const sections = [
|
||||
station(0, 0, 0, 0, 1, 96, 1.5),
|
||||
station(20, 20, 0, 0, 1, 104, 1.0),
|
||||
station(40, 40, 0, 0, 1, 96, 1.5),
|
||||
];
|
||||
const route = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0 },
|
||||
];
|
||||
const designSamples = [
|
||||
{ chainage_m: 0, elevation_m: 100, ground_elevation_m: 96 },
|
||||
{ chainage_m: 20, elevation_m: 100, ground_elevation_m: 104 },
|
||||
{ chainage_m: 40, elevation_m: 100, ground_elevation_m: 96 },
|
||||
];
|
||||
|
||||
/** 비교용 평탄화 — 타입 배열은 숫자 배열로. */
|
||||
const plain = (value) =>
|
||||
JSON.parse(JSON.stringify(value, (_k, v) => (ArrayBuffer.isView(v) ? Array.from(v) : v)));
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
const direct = buildCorridor(sections, route, designSamples);
|
||||
// 워커 경계 흉내 — 요청 · 응답 둘 다 구조화 복제 · 응답은 리본 버퍼를 넘김(transfer)
|
||||
const request = structuredClone({
|
||||
id: 7,
|
||||
crossSections: sections,
|
||||
routePoints: route,
|
||||
designSamples,
|
||||
});
|
||||
const response = handleCorridorRequest(request);
|
||||
const transfer = corridorTransfers(response.build);
|
||||
const received = structuredClone(response, { transfer });
|
||||
out.ribbonCount = direct.ribbons.length;
|
||||
out.idKept = received.id === 7;
|
||||
out.sameAsMain = JSON.stringify(plain(received.build)) === JSON.stringify(plain(direct));
|
||||
out.float64 = received.build.ribbons.every((r) => r.positions instanceof Float64Array);
|
||||
out.transferCount = transfer.length;
|
||||
out.detached = response.build.ribbons.every((r) => r.positions.byteLength === 0); // 복사 없이 넘김
|
||||
// 측점 모자람 → null(워커도 같음)
|
||||
out.tooFew = handleCorridorRequest({
|
||||
id: 1,
|
||||
crossSections: [sections[0]],
|
||||
routePoints: route,
|
||||
}).build;
|
||||
// 워커 없는 곳(시험 · 옛 브라우저) — 부르기가 주 스레드 같은 함수로
|
||||
const fallback = await buildCorridorOffThread(sections, route, designSamples);
|
||||
out.fallbackSame = JSON.stringify(plain(fallback)) === JSON.stringify(plain(direct));
|
||||
console.log(JSON.stringify(out));
|
||||
})();
|
||||
@@ -0,0 +1,167 @@
|
||||
/* 346-4 떠남 확인 단위검증 헬퍼 — `A00_Common/design/design_leave.ts` 를 그 자리에서 트랜스파일 ·
|
||||
* `b_page_state` 는 진짜 · 라우터 · 물음 창 · 문구는 흉내. 결과 JSON 을 stdout 으로.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
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),
|
||||
clear: () => map.clear(),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
const win = new EventTarget();
|
||||
win.sessionStorage = session;
|
||||
win.localStorage = memoryStorage();
|
||||
win.setTimeout = setTimeout;
|
||||
globalThis.window = win;
|
||||
globalThis.sessionStorage = session;
|
||||
|
||||
let route = "/b05-profile";
|
||||
let guard = null;
|
||||
let guardSets = 0;
|
||||
let choice = "cancel";
|
||||
const asked = [];
|
||||
const STUBS = {
|
||||
"@config/config_frontend": {
|
||||
ROUTES: { B05_PROFILE: "/b05-profile", B06_SECTION: "/b06-section" },
|
||||
},
|
||||
"@ui/ui_template_locale": { t: (key) => key },
|
||||
"@ui/ui_template_leave_dialog": {
|
||||
leaveOrStay: async (message, hooks) => {
|
||||
asked.push(message);
|
||||
if (choice === "cancel") return "stay";
|
||||
if (choice === "discard") {
|
||||
await hooks.discard();
|
||||
return "discarded";
|
||||
}
|
||||
await hooks.save();
|
||||
return hooks.stillDirty() ? "stay" : "saved";
|
||||
},
|
||||
},
|
||||
"../router": {
|
||||
currentRoute: () => route,
|
||||
setLeaveGuard: (fn) => {
|
||||
guard = fn;
|
||||
guardSets += 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (request in STUBS) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts"));
|
||||
const P = "p1";
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
/** 브라우저 BeforeUnloadEvent 처럼 returnValue 를 쓸 수 있게. */
|
||||
class BeforeUnload extends Event {
|
||||
returnValue = true;
|
||||
}
|
||||
const unloadBlocked = () => {
|
||||
const event = new BeforeUnload("beforeunload", { cancelable: true });
|
||||
win.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
const root = { isConnected: true };
|
||||
let saveMarks = true;
|
||||
let saves = 0;
|
||||
leave.guardDesignLeave(P, root, async () => {
|
||||
saves += 1;
|
||||
if (saveMarks) leave.markDesignClean(P);
|
||||
});
|
||||
|
||||
out.emptyDirty = leave.designDirty(P);
|
||||
// 고침이 아닌 칸 · 다른 페이지 칸 · 표준 횡단면만 — 안 물음
|
||||
session.setItem(`aislo:draft:selection:${P}`, '{"chainageM":20}');
|
||||
session.setItem(`aislo:draft:std-cross:${P}`, '{"soil":{}}');
|
||||
session.setItem(`aislo:draft:pipe-ids:${P}`, '{"next":3}');
|
||||
session.setItem(`aislo:draft:b08-materials:${P}`, '{"units":1}');
|
||||
session.setItem(`aislo:draft:crossdesign:${P}:7`, "{}"); // 빈 칸
|
||||
out.nonEditDirty = leave.designDirty(P);
|
||||
out.nonEditUnload = unloadBlocked();
|
||||
route = "/b08-quantity";
|
||||
out.nonEditLeave = await guard();
|
||||
out.askedNonEdit = asked.length;
|
||||
|
||||
// 횡단 초안(노선 7) — 물음
|
||||
session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"20.000":{"ground_type":"soil"}}');
|
||||
out.editDirty = leave.designDirty(P);
|
||||
out.editUnload = unloadBlocked();
|
||||
|
||||
// 같은 페이지(B05 → B06) — 안 묻고 한 틱 뒤 다시 걺
|
||||
route = "/b06-section";
|
||||
const setsBefore = guardSets;
|
||||
out.samePage = await guard();
|
||||
await tick();
|
||||
out.reArmed = guardSets === setsBefore + 1;
|
||||
out.askedSamePage = asked.length;
|
||||
|
||||
// 다른 페이지 — 취소 = 머묾
|
||||
route = "/b08-quantity";
|
||||
choice = "cancel";
|
||||
out.cancelLeave = await guard();
|
||||
|
||||
// 저장 실패(깨끗한 표 없음) = 머묾 · 저장 성공 = 넘어감 · 뒤로 안 물음
|
||||
choice = "save";
|
||||
saveMarks = false;
|
||||
out.saveFailLeave = await guard();
|
||||
saveMarks = true;
|
||||
out.saveLeave = await guard();
|
||||
out.saves = saves;
|
||||
out.afterSaveDirty = leave.designDirty(P);
|
||||
out.afterSaveUnload = unloadBlocked();
|
||||
const askedAfterSave = asked.length;
|
||||
out.afterSaveLeave = await guard();
|
||||
out.askedAfterSave = asked.length - askedAfterSave;
|
||||
|
||||
// 저장 뒤 다시 고침 — 다시 물음 · 버리고 넘어감 = 고침 칸만 지움
|
||||
session.setItem(`aislo:draft:alignment-draft:${P}:7`, '{"station_offsets":{"20.000":0.3}}');
|
||||
session.setItem(`aislo:draft:route-draft:${P}`, '{"rev":2}');
|
||||
out.reEditDirty = leave.designDirty(P);
|
||||
choice = "discard";
|
||||
out.discardLeave = await guard();
|
||||
out.left = {
|
||||
crossdesign: session.getItem(`aislo:draft:crossdesign:${P}:7`),
|
||||
alignment: session.getItem(`aislo:draft:alignment-draft:${P}:7`),
|
||||
routeDraft: session.getItem(`aislo:draft:route-draft:${P}`),
|
||||
selection: session.getItem(`aislo:draft:selection:${P}`),
|
||||
stdCross: session.getItem(`aislo:draft:std-cross:${P}`),
|
||||
b08: session.getItem(`aislo:draft:b08-materials:${P}`),
|
||||
};
|
||||
out.afterDiscardDirty = leave.designDirty(P);
|
||||
|
||||
// 페이지가 화면에서 빠짐 — 안 물음
|
||||
session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"40.000":{}}');
|
||||
root.isConnected = false;
|
||||
const askedGone = asked.length;
|
||||
out.goneLeave = await guard();
|
||||
out.goneUnload = unloadBlocked();
|
||||
out.askedGone = asked.length - askedGone;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
@@ -0,0 +1,57 @@
|
||||
/* 347-2 · 347-3 배수유역 자동/수동 · 제안 헬퍼 — logic 두 파일을 그 자리에서 트랜스파일해
|
||||
* Node 로 돌리고 값을 JSON 으로 낸다(틀 = helper_281_1).
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function loadTs(file) {
|
||||
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, require);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const live = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Live.ts"));
|
||||
const sug = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Suggest.ts"));
|
||||
|
||||
const pipes = [
|
||||
{ chainage_m: 100, source: "stream", options: { pipe_diameter_mm: 1000 } },
|
||||
{ chainage_m: 300, source: "user", facility: "pipe" },
|
||||
{ chainage_m: 500, source: "stream", facility: "box_culvert" },
|
||||
];
|
||||
const basins = [
|
||||
{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 },
|
||||
{ chainage_m: 300, recommended_facility: "ford_bridge", recommended_diameter_mm: null },
|
||||
{ chainage_m: 500, recommended_facility: "box_culvert", recommended_diameter_mm: null },
|
||||
];
|
||||
const candidates = [{ chainage_m: 200 }, { chainage_m: 500.2 }];
|
||||
const before = JSON.stringify(pipes);
|
||||
const suggestions = sug.collectSuggestions(pipes, basins, candidates);
|
||||
const applied = sug.applySuggestions(pipes, suggestions);
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
quietMs: live.QUIET_MS,
|
||||
modes: [
|
||||
live.parseDrainageMode(null),
|
||||
live.parseDrainageMode("auto"),
|
||||
live.parseDrainageMode("x"),
|
||||
],
|
||||
stale: [live.draftMakesStale(null), live.draftMakesStale({ rev: 1 })],
|
||||
accept: [
|
||||
live.acceptLive({ status: "success", generation: 3 }, 3, 3),
|
||||
live.acceptLive({ status: "success", generation: 3 }, 3, 4),
|
||||
live.acceptLive({ status: "superseded" }, 3, 3),
|
||||
live.acceptLive({ status: "success", generation: 2 }, 3, 3),
|
||||
],
|
||||
body: live.liveRequestBody(5, undefined, [{ chainage_m: 1 }]),
|
||||
suggestions,
|
||||
applied,
|
||||
untouched: JSON.stringify(pipes) === before,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""346-3 노선 저장 실경로 시간 — 검증 프로젝트의 지금 노드 · 곡선 그대로 저장 한 번(수동 실행).
|
||||
|
||||
같은 노선이라 값은 그대로(새 노선 행 번호만 바뀜) · 초기값은 안 건드림(`as_initial` 끔).
|
||||
쓰는 법: `./venv/Scripts/python.exe resources/tester/run_346_3_route_save_timing.py <project_id>`
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from config.config_db import close_db_pool, init_db_pool # noqa: E402
|
||||
|
||||
|
||||
async def main(project_id: UUID) -> None:
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
await init_db_pool()
|
||||
try:
|
||||
plan = await replan.read_route_plan(project_id)
|
||||
nodes = plan["nodes"]
|
||||
radii: dict[int, float] = {}
|
||||
for curve in plan["curves"]:
|
||||
if curve.get("node_first") == curve.get("node_last"):
|
||||
radii[int(curve["node_first"])] = float(curve["radius_m"])
|
||||
body = RouteSaveRequest(
|
||||
vertices=[
|
||||
{
|
||||
"x": node["x"],
|
||||
"y": node["y"],
|
||||
"curve": index in radii,
|
||||
"radius_m": radii.get(index),
|
||||
}
|
||||
for index, node in enumerate(nodes)
|
||||
],
|
||||
station_snap=bool(plan.get("station_snap")),
|
||||
)
|
||||
started = time.perf_counter()
|
||||
result = await replan.replan_route(project_id, body)
|
||||
seconds = time.perf_counter() - started
|
||||
body_text = getattr(result, "body", None)
|
||||
print(json.dumps(json.loads(body_text) if body_text else result, ensure_ascii=False))
|
||||
print(f"[시간] 노선 저장 전체 {seconds:.1f}s")
|
||||
finally:
|
||||
await close_db_pool()
|
||||
|
||||
|
||||
asyncio.run(main(UUID(sys.argv[1])))
|
||||
@@ -18,7 +18,7 @@ import numpy as np
|
||||
import B03_FileInput.B03_FileInput_Service_Chain as chain
|
||||
import B04_PreProcess.B04_PreProcess_Service as service
|
||||
import B05_Profile.B05_Profile_Repository as route_repo
|
||||
import common_util.common_util_drainage_pipes as drainage_pipes
|
||||
import B05_Profile.B05_Profile_Route_Save as route_save
|
||||
import common_util.common_util_workflow_state as workflow_state
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
from B06_Section import B06_Section_Router_Stations as stations
|
||||
@@ -212,16 +212,15 @@ def _patch_recompute(monkeypatch, tmp_path: Path, db: _Db, failure: str | None):
|
||||
async def start(*_args):
|
||||
return None
|
||||
|
||||
async def run_chain(_project_id, model_id, selection, _target=None):
|
||||
async def run_chain(_project_id, _root, _stored, model_id, selection, *_rest):
|
||||
seen["model_id"], seen["selection"] = model_id, dict(selection)
|
||||
return failure
|
||||
return {"error": failure} if failure else {"route_id": 238}
|
||||
|
||||
points = [{"x": 0.0, "y": 0.0}, {"x": 5.0, "y": 5.0}]
|
||||
monkeypatch.setattr(replan, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(drainage_pipes, "clear_pipe_points", lambda _s: None)
|
||||
monkeypatch.setattr(route_repo, "get_latest_route", latest)
|
||||
monkeypatch.setattr(chain, "_planned_route_points_in_project_crs", lambda *_a: points)
|
||||
monkeypatch.setattr(chain, "run_redesign_chain", run_chain)
|
||||
monkeypatch.setattr(route_save, "run_route_save_chain", run_chain)
|
||||
monkeypatch.setattr(workflow_state, "get_workflow_state", state)
|
||||
monkeypatch.setattr(workflow_state, "start_stage", start)
|
||||
return seen
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""344-3 측점 열쇠 거울 — `common_util_station_key.py` ↔ `common_util_station_key.ts` 같은 열쇠.
|
||||
|
||||
일반 grid:k(누가거리 ÷ 간격 반올림 · 0.5 는 위로) · 관 pipe:<id> · 구조물 structure:<id> ·
|
||||
시종점 end:BP/EP · id 모름 at:<0.01 m> · 옛 열쇠(누가거리 글) · at: 의 누가거리.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from common_util.common_util_station_key import ( # noqa: E402
|
||||
chainage_key,
|
||||
loose_chainage,
|
||||
station_key,
|
||||
)
|
||||
|
||||
TS = PROJECT_ROOT / "common_util" / "common_util_station_key.ts"
|
||||
TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript"
|
||||
|
||||
CASES = [
|
||||
("bp", 0.0, 20.0, None, "structure"),
|
||||
("ep", 812.345, 20.0, None, "structure"),
|
||||
("regular", 40.0, 20.0, None, "structure"),
|
||||
("regular", 50.0, 20.0, None, "structure"), # 2.5 → 3(위로)
|
||||
("regular", 69.996, 20.0, None, "structure"),
|
||||
("regular", 40.0, None, None, "structure"),
|
||||
("irregular", 85.054, 20.0, "pipe-s3", "pipe"),
|
||||
("irregular", 85.054, 20.0, "st-7", "structure"),
|
||||
("irregular", 85.054, 20.0, None, "structure"),
|
||||
("irregular", 1.005, 20.0, None, "structure"),
|
||||
("pipe", 12.344, 20.0, "pipe-s1", "structure"),
|
||||
("structure", 12.345, 20.0, None, "structure"),
|
||||
]
|
||||
LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"]
|
||||
|
||||
SCRIPT = r"""
|
||||
const ts = require(process.argv[1]);
|
||||
const fs = require("fs");
|
||||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||||
const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } };
|
||||
const js = ts.transpileModule(src, opts).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", js)(box.exports, box);
|
||||
const m = box.exports;
|
||||
const input = JSON.parse(process.argv[3]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
keys: input.cases.map((c) => m.stationKey(c[0], c[1], c[2], c[3], c[4])),
|
||||
chainage: input.cases.map((c) => m.chainageKey(c[1])),
|
||||
loose: input.loose.map((k) => m.looseChainage(k)),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def test_파이썬과_TS_열쇠_같음():
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"-e",
|
||||
SCRIPT,
|
||||
str(TYPESCRIPT),
|
||||
str(TS),
|
||||
json.dumps({"cases": CASES, "loose": LOOSE}),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
out = json.loads(proc.stdout)
|
||||
assert out["keys"] == [station_key(*case) for case in CASES]
|
||||
assert out["chainage"] == [chainage_key(case[1]) for case in CASES]
|
||||
assert out["loose"] == [loose_chainage(key) for key in LOOSE]
|
||||
|
||||
|
||||
def test_열쇠_규칙():
|
||||
assert station_key("bp", 0.0, 20.0) == "end:BP"
|
||||
assert station_key("ep", 812.3, 20.0) == "end:EP"
|
||||
assert station_key("regular", 50.0, 20.0) == "grid:3"
|
||||
assert station_key("irregular", 85.054, 20.0, "pipe-s3", "pipe") == "pipe:pipe-s3"
|
||||
assert station_key("irregular", 85.054, 20.0) == "at:85.05"
|
||||
assert loose_chainage("85.05") == 85.05 and loose_chainage("grid:4") is None
|
||||
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross · masshaul · haul 단계.
|
||||
|
||||
못박는 것
|
||||
- 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 것은 지우지 않고 남김.
|
||||
- 쓰기 = 측점 열쇠(grid · pipe · structure) · 부르는 쪽은 누가거리로 읽음 · 암 경계 저장소도 같음.
|
||||
- 구조물 id 를 아직 모를 때(첫 열기)도 쓴 때 자리로 그 측점 고침을 읽음.
|
||||
- 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 ·
|
||||
단면 유형 · 측구 방향 · 포장 · 노견 확장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」.
|
||||
- cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용.
|
||||
- masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로.
|
||||
|
||||
Node 헬퍼 `helper_344_3_station_keys.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_344_3_entry.ts`)을
|
||||
부름.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
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
|
||||
|
||||
FIXTURE = HERE / "fixtures" / "b05_192_1_moved_pipe_longitudinal.json"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory):
|
||||
work = tmp_path_factory.mktemp("b344")
|
||||
build = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../resources/tester/helper_344_3_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_344_3_entry.js", bundle)
|
||||
policy = json.loads(FIXTURE.read_text(encoding="utf-8"))["profile_alignment"]["policy"]
|
||||
source = work / "input.json"
|
||||
source.write_text(
|
||||
json.dumps({"standard": STANDARD_CROSS_SECTION, "policy": policy}, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_344_3_station_keys.mjs"), str(bundle), str(source)],
|
||||
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_옛_초안은_읽을_때_측점_열쇠로_옮김(out):
|
||||
assert out["legacyRead"] == [20, 45.05, 52.5, 60]
|
||||
assert out["legacyKeys"] == ["99.00", "grid:1", "grid:3", "pipe:pipe-s1", "structure:st-1"]
|
||||
|
||||
|
||||
def test_쓰기는_측점_열쇠_읽기는_누가거리(out):
|
||||
assert out["writeKey"] is True
|
||||
assert out["shoulderKeys"] == ["grid:2"]
|
||||
assert out["rockKeys"] == ["grid:1"]
|
||||
assert out["rockLoaded"] == {"20.00": -0.8}
|
||||
assert out["rockSession"] == {"20.00": -0.8}
|
||||
|
||||
|
||||
def test_구조물_id_모를_때도_쓴_자리로_읽음(out):
|
||||
assert out["hintKeyNow"] == "at:52.50"
|
||||
assert out["hintRead"] is True
|
||||
|
||||
|
||||
def test_노선_바뀜_측점_열쇠로_넘김(out):
|
||||
assert out["outside"] == 2 # 60 m(노선이 짧아짐) · 99 m(옛 노선에도 없던 옛 초안)
|
||||
assert out["carriedKeys"] == ["grid:1", "grid:2", "pipe:pipe-s1", "structure:st-1"]
|
||||
assert out["carriedRead"] == [20, 40, 47, 55] # 관 · 구조물은 옮긴 자리로
|
||||
assert out["carriedChoice"] == {
|
||||
"ground_type": "soil",
|
||||
"ditch_type": "l_type",
|
||||
"ditch_choice": False,
|
||||
"two_stage_slope": True,
|
||||
}
|
||||
assert out["carriedRock"] == {"20.00": -0.8}
|
||||
assert out["carriedShoulder"] == []
|
||||
|
||||
|
||||
def test_cross_단계_국부_연산(out):
|
||||
assert out["samplesSorted"] is True and out["samplesCount"] == 49
|
||||
assert out["crossFirst"] == {"computed": 6, "reused": 0, "keys": 6}
|
||||
assert out["crossHasArea"] is True
|
||||
assert out["crossSame"] == {"computed": 0, "reused": 6}
|
||||
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}}
|
||||
@@ -48,6 +48,15 @@ def test_처음_열_때_지형_한_번_로딩_원(out):
|
||||
assert out["reopenDraws"] == 5
|
||||
|
||||
|
||||
def test_열_때만_다시_짓기_닫힌_동안_0(out):
|
||||
"""345-4 — 낡은 3D 길 모형은 모달을 열 때(지형이 선 뒤)만 다시 짓기를 부름 · 닫혀 있으면 안 부름."""
|
||||
assert out["opensBeforeTerrain"] == 0
|
||||
assert out["opensAfterTerrain"] == 1
|
||||
assert out["opensWhileClosed"] == 1
|
||||
assert out["opensAfterReopen"] == 2 # 다시 열 때 한 번(열린 동안 또 눌러도 하나)
|
||||
assert out["closedBeforeTerrainOpens"] == 0
|
||||
|
||||
|
||||
def test_떠날_때만_버림(out):
|
||||
assert out["beforeCloseOk"] is True
|
||||
assert out["closedOnDispose"] is True
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""345-4 정리 — B05 등고선 주소 한 벌(`contourAssetUrl` · 미리 받기와 같은 파일) · 빈 saveCorridorIfDirty 지움."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_B05_등고선_주소는_한_벌():
|
||||
for rel in (
|
||||
"B05_Profile/B05_Profile_UI_Viewer.ts",
|
||||
"B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts",
|
||||
):
|
||||
source = _read(rel)
|
||||
assert "contourAssetUrl(" in source, rel
|
||||
assert "/contour?interval" not in source, rel
|
||||
# 미리 받기(b_asset_cache)도 같은 함수 — 주소가 같아야 보관함 한 칸을 같이 씀
|
||||
assert "contourAssetUrl(projectId, confirmed.model_id, interval, smooth)" in _read(
|
||||
"A00_Common/b_asset_cache.ts"
|
||||
)
|
||||
|
||||
|
||||
def test_빈_코리도_올림_함수_지움():
|
||||
for rel in (
|
||||
"B05_Profile/B05_Profile_UI_Corridor.ts",
|
||||
"B05_Profile/B05_Profile_UI_Page_Actions.ts",
|
||||
"A00_Common/design/design_view_profile.ts",
|
||||
"A00_Common/design/design_view_profile_3d.ts",
|
||||
):
|
||||
assert "saveCorridorIfDirty" not in _read(rel), rel
|
||||
@@ -0,0 +1,72 @@
|
||||
"""345-4 3D 길 모형 웹 워커 — 워커 결과 = 주 스레드 결과(같은 입력) · 리본 좌표는 복사 없이(Transferable) ·
|
||||
서버 저장본이 먼저(해시 맞으면 받기만) · 저장 안 한 초안일 때만 워커 미리보기 · 모달을 열 때만 다시 짓기.
|
||||
|
||||
셈은 Node 헬퍼 `helper_345_4_corridor_worker.cjs`(워커 경계 = structuredClone + transfer) · 배선은 글로 봄.
|
||||
모달 닫힌 동안 짓기 0 은 `test_345_2_3d_modal.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
run = subprocess.run(
|
||||
["node", str(HERE / "helper_345_4_corridor_worker.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def test_워커_결과는_주_스레드와_같음(out):
|
||||
assert out["ribbonCount"] > 0
|
||||
assert out["sameAsMain"] is True and out["idKept"] is True
|
||||
assert out["float64"] is True # 모델 좌표 해상도 그대로
|
||||
assert out["tooFew"] is None
|
||||
assert out["fallbackSame"] is True # 워커 없는 곳은 주 스레드 같은 함수
|
||||
|
||||
|
||||
def test_리본_좌표는_복사_없이_넘김(out):
|
||||
assert out["transferCount"] == out["ribbonCount"]
|
||||
assert out["detached"] is True
|
||||
|
||||
|
||||
def test_저장본_먼저_초안만_워커():
|
||||
corridor = _read("B05_Profile/B05_Profile_UI_Corridor.ts")
|
||||
body = corridor[corridor.index("export async function ensureCorridor(") :]
|
||||
body = body[: body.index("\n}\n")]
|
||||
assert body.index("fetchStored(") < body.index("buildCorridorOffThread(")
|
||||
assert "if (!rebuild) return null;" in body # 진입 = 저장본만(짓지 않음)
|
||||
assert "buildCorridor(" not in body.replace("buildCorridorOffThread(", "")
|
||||
client = _read("B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts")
|
||||
assert 'new Worker(new URL("./B05_Profile_UI_Corridor_Worker.ts", import.meta.url)' in client
|
||||
worker = _read("B05_Profile/B05_Profile_UI_Corridor_Worker.ts")
|
||||
for banned in ("document.", "window.", "sessionStorage", 'from "three"'):
|
||||
assert banned not in worker
|
||||
|
||||
|
||||
def test_모달_열_때_낡았으면_다시_짓기():
|
||||
view3d = _read("A00_Common/design/design_view_profile_3d.ts")
|
||||
assert "if (state.corridorPending) void refreshOnce();" in view3d
|
||||
assert "onCorridorRefresh: () => refreshOnce()," in view3d
|
||||
assert "deps.sections().setCorridorPending(false);" in view3d
|
||||
sections = _read("A00_Common/design/design_view_profile_sections.ts")
|
||||
assert ".then((fresh) => setCorridorPending(!fresh));" in sections
|
||||
assert "deps.panel().setCorridorPending(pending);" in sections # 「3D 늦음」 표시 그대로
|
||||
view = _read("A00_Common/design/design_view_profile.ts")
|
||||
assert "onCrossDesignsUpdated: () => sections.setCorridorPending(true)," in view
|
||||
@@ -0,0 +1,561 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 오목 보충 · 이월 열쇠 ·
|
||||
초기값 규칙 ·
|
||||
배수유역 낡음 · 실패 때 옛 노선 되살림.
|
||||
|
||||
순수 부품(`B05_Profile_Route_Save_Parts`)은 값으로 · 체인(`run_route_save_chain`)과 API
|
||||
(`replan_route`)는 DB · 엔진을 가짜로 바꿔 차례 · 되살림 · 초기값만 잰다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from fastapi.responses import JSONResponse # noqa: E402
|
||||
|
||||
import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402
|
||||
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402
|
||||
import B05_Profile.B05_Profile_Route_Save as save # noqa: E402
|
||||
import B05_Profile.B05_Profile_Router as b05_router # noqa: E402
|
||||
import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402
|
||||
import B05_Profile.B05_Profile_Router_RouteInitial as route_initial # noqa: E402
|
||||
import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402
|
||||
import B06_Section.B06_Section_Repository as b06_repo # noqa: E402
|
||||
import B06_Section.B06_Section_Router as b06_router # noqa: E402
|
||||
import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402
|
||||
import common_util.common_util_drainage_pipes as drainage_pipes # noqa: E402
|
||||
import common_util.common_util_initial_snapshot as snapshot # noqa: E402
|
||||
import common_util.common_util_workflow_state as workflow # noqa: E402
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402
|
||||
add_crossing_pipes,
|
||||
carry_cross_inputs,
|
||||
concave_chainages,
|
||||
fill_concave_pipes,
|
||||
follow_pipes,
|
||||
station_keys,
|
||||
vertices_of,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import ( # noqa: E402
|
||||
PIPE_SOURCE_SPACING,
|
||||
PIPE_SOURCE_STREAM,
|
||||
PipePoint,
|
||||
analysis_route_signature,
|
||||
route_signature,
|
||||
)
|
||||
|
||||
OLD = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0), (400.0, 0.0)]
|
||||
# 새 노선 = 100 ~ 300 m 를 북으로 휘게(앞 · 뒤는 옛 노선 위)
|
||||
NEW = [(0.0, 0.0), (100.0, 0.0), (200.0, 60.0), (300.0, 0.0), (400.0, 0.0)]
|
||||
|
||||
|
||||
# ── 관 따라감 · 교차점 · 오목 보충 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_관은_지우지_않고_좌표를_따라감():
|
||||
old = [
|
||||
PipePoint(chainage_m=50.0, x=50.0, y=0.0, options={"pipe_kind": "흄관"}),
|
||||
PipePoint(chainage_m=350.0, x=350.0, y=0.0, start_m=345.0, end_m=355.0),
|
||||
]
|
||||
pairs = follow_pipes(old, vertices_of(NEW))
|
||||
assert [pipe_id for pipe_id, _ in pairs] == [0, 1]
|
||||
assert pairs[0][1].options == {"pipe_kind": "흄관"} # 사용자 관 속성 그대로
|
||||
shift = pairs[1][1].chainage_m - 350.0 # 새 노선이 길어진 만큼 뒤 관이 밀림
|
||||
assert shift > 30 and pairs[1][1].start_m == pytest.approx(345.0 + shift)
|
||||
assert old[1].chainage_m == 350.0 # 옛 목록은 안 고침
|
||||
|
||||
|
||||
def test_새_교차점에만_기본_관():
|
||||
pairs = follow_pipes([PipePoint(chainage_m=50.0, x=50.0, y=0.0)], vertices_of(NEW))
|
||||
crossings = [
|
||||
SimpleNamespace(chainage_m=20.0, x=20.0, y=0.0), # 옛 노선 위 — 사용자가 지운 관 안 되살림
|
||||
SimpleNamespace(chainage_m=150.4, x=150.0, y=30.0), # 새 자리 · 관 없음 → 더함
|
||||
SimpleNamespace(chainage_m=52.0, x=52.0, y=0.0), # 옛 자리
|
||||
]
|
||||
added = add_crossing_pipes(pairs, crossings, OLD, 456.0)
|
||||
assert added == 1
|
||||
new = [pipe for pipe_id, pipe in pairs if pipe_id < 0]
|
||||
assert new[0].chainage_m == 150.0 and new[0].source == PIPE_SOURCE_STREAM
|
||||
# 같은 자리 다시 = 이미 관이 있음
|
||||
assert add_crossing_pipes(pairs, crossings, OLD, 456.0) == 0
|
||||
|
||||
|
||||
def test_오목_지점_보충은_새_자리의_오목만():
|
||||
vertices = vertices_of(NEW)
|
||||
total = vertices[-1].chainage_m
|
||||
chainage = np.arange(0.0, total, 2.0)
|
||||
ground = 100.0 + np.abs(chainage - 220.0) * 0.05 # 220 m 에 골(새 자리)
|
||||
ground[chainage < 60] = (
|
||||
100.0 + np.abs(chainage[chainage < 60] - 30.0) * 0.05
|
||||
) # 30 m 골(옛 자리)
|
||||
concave = concave_chainages(chainage, ground)
|
||||
assert any(abs(at - 220.0) < 3 for at, _ in concave)
|
||||
pairs: list = []
|
||||
added = fill_concave_pipes(pairs, concave, vertices, OLD)
|
||||
places = [pipe.chainage_m for _, pipe in pairs]
|
||||
assert added >= 1 and all(pipe.source == PIPE_SOURCE_SPACING for _, pipe in pairs)
|
||||
assert any(abs(at - 220.0) <= 3 for at in places)
|
||||
assert not any(at < 60 for at in places) # 옛 노선 위 오목엔 안 둠
|
||||
|
||||
|
||||
def test_간격이_좁으면_보충_없음():
|
||||
vertices = vertices_of(NEW)
|
||||
pairs = [(0, PipePoint(chainage_m=at)) for at in (0.0, 90.0, 180.0, 270.0, 360.0, 450.0)]
|
||||
concave = [(220.0, 99.0)]
|
||||
assert fill_concave_pipes(pairs, concave, vertices, OLD) == 0
|
||||
|
||||
|
||||
# ── 측점 열쇠 · 횡단 이월 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _structure(sid, type_id, anchor, start=None, end=None):
|
||||
return SimpleNamespace(
|
||||
structure_id=sid,
|
||||
type_id=type_id,
|
||||
chainage_m=anchor,
|
||||
start_m=start,
|
||||
end_m=end,
|
||||
options={},
|
||||
)
|
||||
|
||||
|
||||
def test_측점_열쇠_규칙():
|
||||
stations = [{"chainage_m": s} for s in (0.0, 20.0, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)]
|
||||
pipes = [(3, PipePoint(chainage_m=50.0))]
|
||||
structures = [_structure("w1", "retaining_wall", 77.0, 60.0, 80.0)]
|
||||
keys = station_keys(stations, pipes, structures, 20.0)
|
||||
assert keys == {
|
||||
"end:BP": 0.0,
|
||||
"grid:1": 20.0,
|
||||
"grid:2": 40.0,
|
||||
"pipe:3": 50.0,
|
||||
"structure:w1:start": 60.0,
|
||||
"structure:w1": 77.0,
|
||||
"structure:w1:end": 80.0,
|
||||
"end:EP": 95.5,
|
||||
}
|
||||
|
||||
|
||||
def test_이월은_열쇠로_누가거리가_밀려도():
|
||||
old_keys = {
|
||||
"end:BP": 0.0,
|
||||
"grid:1": 20.0,
|
||||
"pipe:0": 50.0,
|
||||
"structure:w1": 77.0,
|
||||
"grid:5": 100.0,
|
||||
}
|
||||
new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:0": 57.0, "structure:w1": 84.0}
|
||||
designs = {
|
||||
0.0: {"ground_type": "soil"},
|
||||
20.0: {"ground_type": "rock", "berm": {"on": True}, "cut_area_m2": 9.0},
|
||||
50.0: {"ditch_type": "u", "inlet_structure": "basin", "basin_adjust": {"d": 1}},
|
||||
77.0: {"rock_boundary_offset_m": 0.8, "revet_adjust": {"h": 2}},
|
||||
100.0: {"ground_type": "soil"},
|
||||
}
|
||||
inputs, adjusts, dropped = carry_cross_inputs(
|
||||
old_keys,
|
||||
new_keys,
|
||||
designs,
|
||||
("ground_type", "ditch_type", "berm", "rock_boundary_offset_m"),
|
||||
("inlet_structure", "basin_adjust", "revet_adjust"),
|
||||
)
|
||||
assert inputs == {
|
||||
0.0: {"ground_type": "soil"},
|
||||
20.0: {"ground_type": "rock", "berm": {"on": True}}, # 결과 칸(면적)은 안 넘김
|
||||
57.0: {"ditch_type": "u"},
|
||||
84.0: {"rock_boundary_offset_m": 0.8},
|
||||
}
|
||||
# 구조물 조정은 관 · 구조물 열쇠만 따라감
|
||||
assert adjusts == {
|
||||
57.0: {"inlet_structure": "basin", "basin_adjust": {"d": 1}},
|
||||
84.0: {"revet_adjust": {"h": 2}},
|
||||
}
|
||||
assert dropped == 1 # grid:5 — 새 노선에 없음(노선 밖 고침)
|
||||
|
||||
|
||||
# ── 노선 초안 요청 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_초안_모양을_옛_칸으로():
|
||||
body = save.RouteSaveRequest(
|
||||
draft={
|
||||
"rev": 3,
|
||||
"nodes": [[0, 0], [10, 5], [20, 0]],
|
||||
"curveOn": [True, False, True],
|
||||
"curveRadius": [None, 30.0, None],
|
||||
"curveLock": [None, "radius", None],
|
||||
"curveArc": [None, None, None],
|
||||
"stationSnap": True,
|
||||
"terrain": "sheet",
|
||||
}
|
||||
)
|
||||
request = body.resolved()
|
||||
assert [(v.x, v.y, v.curve, v.radius_m) for v in request.vertices] == [
|
||||
(0, 0, True, None),
|
||||
(10, 5, False, 30.0),
|
||||
(20, 0, True, None),
|
||||
]
|
||||
assert request.station_snap is True and request.terrain == "sheet"
|
||||
with pytest.raises(ValueError):
|
||||
save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}).resolved()
|
||||
|
||||
|
||||
# ── 체인 차례 · 되살림 · 배수유역 낡음 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, params=None):
|
||||
self.log.append(" ".join(sql.split()))
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
def cursor(self, *args):
|
||||
return _Cursor(self.log)
|
||||
|
||||
async def begin(self):
|
||||
return None
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def rollback(self):
|
||||
return None
|
||||
|
||||
|
||||
class _Pool:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
def acquire(self):
|
||||
connection = _Connection(self.log)
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self):
|
||||
return connection
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
return _Acquire()
|
||||
|
||||
|
||||
def _async(value, order=None, name=None):
|
||||
async def fake(*args, **kwargs):
|
||||
if order is not None:
|
||||
order.append(name)
|
||||
return value
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chain_world(monkeypatch, tmp_path):
|
||||
order: list[str] = []
|
||||
sql: list[str] = []
|
||||
root = tmp_path
|
||||
route_file = root / "B05_Profile" / "route" / "route_main.geojson"
|
||||
route_file.parent.mkdir(parents=True)
|
||||
route_file.write_text(
|
||||
json.dumps({"geometry": {"coordinates": [[*p, 0] for p in OLD]}}), "utf-8"
|
||||
)
|
||||
long_dir = root / "B06_Section" / "longitudinal"
|
||||
long_dir.mkdir(parents=True)
|
||||
(long_dir / "longitudinal.json").write_text(json.dumps({"stations": [], "old": True}), "utf-8")
|
||||
drain = root / "drain"
|
||||
pipes_file = drain / "edits" / "pipe_points.json"
|
||||
pipes_file.parent.mkdir(parents=True)
|
||||
pipes_file.write_text(
|
||||
json.dumps(
|
||||
{"route_signature": "old", "points": [{"chainage_m": 50.0, "x": 50.0, "y": 0.0}]}
|
||||
),
|
||||
"utf-8",
|
||||
)
|
||||
(drain / "00_watershed_response.json").write_text(
|
||||
json.dumps({"route_signature": "old"}), "utf-8"
|
||||
)
|
||||
state = {"stages": [{"stage_no": 2, "params": {"points": {}, "options": {}}}]}
|
||||
|
||||
def solve(project_id, request, *, generate_sections=True):
|
||||
order.append("solve")
|
||||
assert generate_sections is False
|
||||
route_file.write_text(
|
||||
json.dumps({"geometry": {"coordinates": [[*p, 0] for p in NEW]}}), "utf-8"
|
||||
)
|
||||
return _async(
|
||||
SimpleNamespace(
|
||||
route_id=8,
|
||||
total_length_m=456.0,
|
||||
route_data_path="B05_Profile/route/route_main.geojson",
|
||||
)
|
||||
)()
|
||||
|
||||
async def regenerate(*args, **kwargs):
|
||||
order.append("sections")
|
||||
(long_dir / "longitudinal.json").write_text(
|
||||
json.dumps({"stations": [], "new": True}), "utf-8"
|
||||
)
|
||||
return SimpleNamespace()
|
||||
|
||||
async def watershed_must_not_run(*args, **kwargs):
|
||||
raise AssertionError("배수유역은 체인에서 뗌")
|
||||
|
||||
async def prepare(project_id):
|
||||
order.append("pipes")
|
||||
return {"vertices": vertices_of(NEW), "streams": []}
|
||||
|
||||
monkeypatch.setattr(save, "get_db_pool", lambda: _Pool(sql))
|
||||
monkeypatch.setattr(workflow, "get_workflow_state", _async(state))
|
||||
monkeypatch.setattr(workflow, "start_stage", _async(None, order, "restore_params"))
|
||||
monkeypatch.setattr(
|
||||
b06_repo,
|
||||
"get_longitudinal_section",
|
||||
_async(
|
||||
{"data": {}, "longitudinal_file_path": "B06_Section/longitudinal/longitudinal.json"}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([]))
|
||||
monkeypatch.setattr(b06_repo, "get_latest_section_options", _async({}))
|
||||
monkeypatch.setattr(
|
||||
structures_router,
|
||||
"follow_structures_to_route",
|
||||
lambda *a: order.append("structures") or {"moved": 0, "far": 0},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"redesign_solve_request",
|
||||
lambda *a: b05_router.RouteSolveRequest(
|
||||
filter_key="csf",
|
||||
method="dtm",
|
||||
smooth=False,
|
||||
algorithm="as_planned",
|
||||
bp={"x": 0, "y": 0},
|
||||
ep={"x": 400, "y": 0},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(b05_router, "solve_route_record", solve)
|
||||
monkeypatch.setattr(b05_router, "confirm_latest_route", _async({}))
|
||||
monkeypatch.setattr(watershed, "get_primary_region", watershed_must_not_run)
|
||||
monkeypatch.setattr(watershed, "_prepare", prepare)
|
||||
monkeypatch.setattr(drainage_pipes, "clear_pipe_points", watershed_must_not_run)
|
||||
monkeypatch.setattr(drainage_pipes, "drainage_dir", lambda stored: drain)
|
||||
monkeypatch.setattr(save, "_ground_along", lambda *a: (np.zeros(0), np.zeros(0)))
|
||||
monkeypatch.setattr(b06_router, "regenerate_route_sections", regenerate)
|
||||
monkeypatch.setattr(save, "_carry_profile", _async(None, order, "profile"))
|
||||
monkeypatch.setattr(save, "_carry_cross", _async(2, order, "cross"))
|
||||
monkeypatch.setattr(
|
||||
b06_confirm, "confirm_sections", _async(SimpleNamespace(), order, "confirm")
|
||||
)
|
||||
monkeypatch.setattr(snapshot, "mark_designing", lambda root: None)
|
||||
monkeypatch.setattr(snapshot, "clear_designing", lambda root: None)
|
||||
return SimpleNamespace(
|
||||
order=order,
|
||||
sql=sql,
|
||||
root=root,
|
||||
route_file=route_file,
|
||||
pipes_file=pipes_file,
|
||||
long_dir=long_dir,
|
||||
drain=drain,
|
||||
)
|
||||
|
||||
|
||||
def _run(world, **kwargs):
|
||||
old_route = {"id": 7, "route_data_path": "B05_Profile/route/route_main.geojson"}
|
||||
return asyncio.run(
|
||||
save.run_route_save_chain(
|
||||
uuid4(),
|
||||
world.root,
|
||||
"p",
|
||||
1,
|
||||
{},
|
||||
old_route,
|
||||
[{"x": x, "y": y} for x, y in NEW],
|
||||
{"old": 1},
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_차례와_배수유역_뗌(chain_world):
|
||||
result = _run(chain_world)
|
||||
assert "error" not in result, result
|
||||
assert chain_world.order == [
|
||||
"structures",
|
||||
"solve",
|
||||
"pipes",
|
||||
"sections",
|
||||
"profile",
|
||||
"cross",
|
||||
"confirm",
|
||||
]
|
||||
assert result["cross_dropped"] == 2 and result["drainage_stale"] is True
|
||||
assert result["pipes"] == {"kept": 1, "crossing": 0, "concave": 0}
|
||||
# 관 정본 = 새 노선 지문 · 배수유역 분석 지문(옛) 과 달라 B04 가 「낡음」
|
||||
saved = json.loads(chain_world.pipes_file.read_text("utf-8"))
|
||||
assert saved["route_signature"] == route_signature(vertices_of(NEW))
|
||||
assert analysis_route_signature("p") != saved["route_signature"]
|
||||
assert len(saved["points"]) == 1 # 사용자 관 그대로(지우지 않음)
|
||||
|
||||
|
||||
def test_실패하면_옛_노선을_되살림(chain_world, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
b06_confirm, "confirm_sections", _async(JSONResponse(status_code=500, content={}))
|
||||
)
|
||||
before_pipes = chain_world.pipes_file.read_text("utf-8")
|
||||
result = _run(chain_world)
|
||||
assert result["error"].startswith("종횡단 확정 실패")
|
||||
assert json.loads(chain_world.route_file.read_text("utf-8"))["geometry"]["coordinates"] == [
|
||||
[*p, 0] for p in OLD
|
||||
]
|
||||
assert chain_world.pipes_file.read_text("utf-8") == before_pipes
|
||||
assert json.loads((chain_world.long_dir / "longitudinal.json").read_text("utf-8")) == {
|
||||
"stations": [],
|
||||
"old": True,
|
||||
}
|
||||
assert any(
|
||||
sql.startswith("DELETE FROM routes WHERE project_id = %s AND id > %s")
|
||||
for sql in chain_world.sql
|
||||
)
|
||||
assert chain_world.order[-1] == "restore_params"
|
||||
assert not list(chain_world.root.glob("route_save_*")) # 손에 쥔 사본 치움
|
||||
|
||||
|
||||
def test_추천_종단_반영이면_균형_종단(chain_world, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
route_initial, "apply_profile_target", _async(None, chain_world.order, "balanced")
|
||||
)
|
||||
_run(chain_world, profile_target=([0.0, 456.0], [100.0, 110.0]))
|
||||
assert "balanced" in chain_world.order and "profile" not in chain_world.order
|
||||
|
||||
|
||||
# ── 초기값 규칙 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_world(monkeypatch, tmp_path):
|
||||
calls: list = []
|
||||
monkeypatch.setattr(replan, "_project_paths", _async((tmp_path, "p")))
|
||||
monkeypatch.setattr(replan, "_ensure_expected_route", lambda root: None)
|
||||
monkeypatch.setattr(replan, "_plan_criteria", _async((20.0, 0.0, 0.0)))
|
||||
monkeypatch.setattr(replan, "_ensure_planned_initial", lambda *a: None)
|
||||
monkeypatch.setattr(
|
||||
replan,
|
||||
"_write_planned_polyline",
|
||||
lambda *a, **k: {"vertices": 3, "curves": 0, "violations": 0},
|
||||
)
|
||||
monkeypatch.setattr(replan, "_recompute", _async({"route_id": 9, "total_length_m": 10.0}))
|
||||
monkeypatch.setattr(replan, "read_initial_choice", lambda root: None)
|
||||
monkeypatch.setattr(
|
||||
route_initial, "refresh_initial_snapshot", lambda *a: calls.append(a) or _async(None)()
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def _body(**kwargs):
|
||||
return save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}], **kwargs)
|
||||
|
||||
|
||||
def test_손으로_고친_노선_저장은_초기값_그대로(api_world):
|
||||
result = asyncio.run(replan.replan_route(uuid4(), _body()))
|
||||
assert result["initial_refreshed"] is False and api_world == []
|
||||
|
||||
|
||||
def test_추천_적용은_초기값_다시(api_world):
|
||||
result = asyncio.run(replan.replan_route(uuid4(), _body(as_initial=True)))
|
||||
assert result["initial_refreshed"] is True and len(api_world) == 1
|
||||
|
||||
|
||||
def test_초안_칸이_어긋나면_400(api_world):
|
||||
body = save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]})
|
||||
response = asyncio.run(replan.replan_route(uuid4(), body))
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def _longitudinal(total: float) -> dict:
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import design_ground_following_profile
|
||||
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": 100.0 + 0.05 * s + 2.0 * np.sin(s / 17.0)}
|
||||
for s in range(int(total) + 1)
|
||||
]
|
||||
stations = [{"chainage_m": float(s)} for s in range(0, int(total) + 1, 20)]
|
||||
longitudinal = {"samples": samples, "stations": stations}
|
||||
alignment, profile = design_ground_following_profile(
|
||||
longitudinal, resolve_grade_options("trunk")
|
||||
)
|
||||
return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]}
|
||||
|
||||
|
||||
def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch):
|
||||
new_xy = [list(p) for p in NEW]
|
||||
total = float(vertices_of(NEW)[-1].chainage_m)
|
||||
old = _longitudinal(400.0)
|
||||
old["profile_alignment"]["edits"] = {
|
||||
"station_offsets": {"40.000": 0.7, "200.000": -0.4, "380.000": 0.3},
|
||||
"curve_radii": {},
|
||||
}
|
||||
path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps(_longitudinal(total)), "utf-8")
|
||||
import B05_Profile.B05_Profile_Repository as b05_repo
|
||||
|
||||
monkeypatch.setattr(save, "get_db_pool", lambda: _Pool([]))
|
||||
monkeypatch.setattr(b05_repo, "update_longitudinal_grade_summary", _async(None))
|
||||
asyncio.run(
|
||||
save._carry_profile(
|
||||
tmp_path,
|
||||
"B06_Section/longitudinal/longitudinal.json",
|
||||
8,
|
||||
old["profile_alignment"],
|
||||
[list(p) for p in OLD],
|
||||
new_xy,
|
||||
)
|
||||
)
|
||||
edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"]
|
||||
delta = total - 400.0
|
||||
assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로
|
||||
assert "200.000" not in edits # 바뀐 구간 안 — 새 지반
|
||||
assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂
|
||||
|
||||
|
||||
def test_저장_확정이_옛_횡단_파일을_지움(tmp_path):
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
|
||||
|
||||
long_path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
cross_dir = tmp_path / "B06_Section" / "cross_sections"
|
||||
long_path.parent.mkdir(parents=True)
|
||||
cross_dir.mkdir(parents=True)
|
||||
long_path.write_text(json.dumps({"stations": [{"chainage_m": 0.0}, {"chainage_m": 20.0}]}))
|
||||
for at in (0.0, 20.0, 37.0):
|
||||
(cross_dir / cross_filename(at)).write_text("{}")
|
||||
b06_confirm._prune_cross_files(tmp_path, "B06_Section/longitudinal/longitudinal.json")
|
||||
assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted(
|
||||
cross_filename(at) for at in (0.0, 20.0)
|
||||
)
|
||||
source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8")
|
||||
for name in ("async def save_sections(", "async def confirm_sections("):
|
||||
body = source[source.index(name) :]
|
||||
assert body.index("_stand_patch_stations(") < body.index("_prune_cross_files,")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""346-4 떠남 확인 — B05 · B06 저장 안 한 조작이 있으면 탭 닫기 · 다른 페이지 때 물음.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_design_leave.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HELPER = Path(__file__).resolve().parent / "helper_346_4_design_leave.cjs"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def test_고침_아닌_칸은_안_물음(out) -> None:
|
||||
assert out["emptyDirty"] is False
|
||||
assert out["nonEditDirty"] is False and out["nonEditUnload"] is False
|
||||
assert out["nonEditLeave"] is True and out["askedNonEdit"] == 0
|
||||
|
||||
|
||||
def test_횡단_초안이면_물음(out) -> None:
|
||||
assert out["editDirty"] is True and out["editUnload"] is True
|
||||
assert out["cancelLeave"] is False
|
||||
|
||||
|
||||
def test_B05_B06_사이는_안_묻고_다시_걺(out) -> None:
|
||||
assert out["samePage"] is True and out["reArmed"] is True and out["askedSamePage"] == 0
|
||||
|
||||
|
||||
def test_저장_실패는_머묾_성공_뒤엔_안_물음(out) -> None:
|
||||
assert out["saveFailLeave"] is False
|
||||
assert out["saveLeave"] is True and out["saves"] == 2
|
||||
assert out["afterSaveDirty"] is False and out["afterSaveUnload"] is False
|
||||
assert out["afterSaveLeave"] is True and out["askedAfterSave"] == 0
|
||||
|
||||
|
||||
def test_버리고_넘어감은_고침_칸만_지움(out) -> None:
|
||||
assert out["reEditDirty"] is True and out["discardLeave"] is True
|
||||
left = out["left"]
|
||||
assert left["crossdesign"] is None and left["alignment"] is None and left["routeDraft"] is None
|
||||
assert left["selection"] and left["stdCross"] and left["b08"]
|
||||
assert out["afterDiscardDirty"] is False
|
||||
|
||||
|
||||
def test_페이지가_빠지면_안_물음(out) -> None:
|
||||
assert out["goneLeave"] is True and out["goneUnload"] is False and out["askedGone"] == 0
|
||||
@@ -0,0 +1,58 @@
|
||||
"""347-2 · 347-3 배수유역 자동/수동 · 제안 — 화면 쪽 순수 규칙(logic/ 두 파일).
|
||||
|
||||
- 기본 수동 · 자동 = 3 초 조용한 뒤 · 노선 초안이 있을 때만 낡음.
|
||||
- 늦은 답 버림: 옛 차례 · 서버가 그만둔 답 · 답 차례가 다른 것은 안 올림.
|
||||
- 제안: 관경(배관 · 추천 ≠ 지금) · 시설(BOX암거 · 세월교) · 관 자리(기존 관 근처 뺌) ·
|
||||
[적용] = 한 번에 얹음 · 입력 목록은 안 고침(자동 바꿈 없음).
|
||||
Node 헬퍼(`helper_347_2_drainage_live.cjs`)가 TS 를 그 자리에서 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_347_2_drainage_live.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_mode_and_stale(r):
|
||||
assert r["quietMs"] == 3000
|
||||
assert r["modes"] == ["manual", "auto", "manual"]
|
||||
assert r["stale"] == [False, True]
|
||||
|
||||
|
||||
def test_late_answers_dropped(r):
|
||||
assert r["accept"] == [True, False, False, False]
|
||||
assert r["body"] == {"generation": 5, "route": None, "points": [{"chainage_m": 1}]}
|
||||
|
||||
|
||||
def test_suggestions_collected(r):
|
||||
assert r["suggestions"] == [
|
||||
{"kind": "diameter", "chainageM": 100, "from": 1000, "to": 1200},
|
||||
{"kind": "position", "chainageM": 200},
|
||||
{"kind": "facility", "chainageM": 300, "from": "pipe", "to": "ford_bridge"},
|
||||
]
|
||||
|
||||
|
||||
def test_apply_once_without_touching_input(r):
|
||||
assert r["untouched"]
|
||||
applied = {pipe["chainage_m"]: pipe for pipe in r["applied"]}
|
||||
assert sorted(applied) == [100, 200, 300, 500]
|
||||
assert applied[100]["options"]["pipe_diameter_mm"] == 1200
|
||||
assert applied[200]["source"] == "spacing"
|
||||
assert applied[300]["facility"] == "ford_bridge"
|
||||
assert applied[500]["facility"] == "box_culvert" # 이미 그 종류 — 그대로
|
||||
@@ -64,7 +64,8 @@ def test_등록표_밖에서_날문자열_세션_키를_안_쓴다():
|
||||
def test_옛_암경계_키가_코드에_안_남아_있다():
|
||||
"""실사고 자리 못박기 — 읽는 쪽이 등록표를 거치는지."""
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert 'readState<Record<string, number>>("rockb"' in persist, "등록표를 안 거침"
|
||||
# 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음
|
||||
assert 'readKeyed<number>("rockb", projectId, routeId)' in persist, "등록표를 안 거침"
|
||||
# 옛 키가 **코드로** 되살아나는 것만 막는다 — 주석에 사연으로 적힌 것은 남겨 둔다.
|
||||
code = "\n".join(
|
||||
line for line in persist.splitlines() if not line.lstrip().startswith(("*", "//", "/*"))
|
||||
|
||||
@@ -202,7 +202,8 @@ def test_암경계선은_등록표_키로_읽는다():
|
||||
persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'readState<Record<string, number>>("rockb"' in persist
|
||||
# 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음
|
||||
assert 'readKeyed<number>("rockb", projectId, routeId)' in persist
|
||||
|
||||
|
||||
# ── ⑦ 저장된 표준 횡단면이 새 탭에서 안 서던 것 (`72e50b3e`) ───────────────────
|
||||
|
||||
Reference in New Issue
Block a user