Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-10-17 12:59:58 +09:00
175 changed files with 4875 additions and 1444 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# 종단설계 설명서
# 노선설계 설명서
## 이 화면에서 하는 일
+7 -2
View File
@@ -288,7 +288,12 @@ const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-c
const stopped = hits[0].click();
handle.setActive(1);
const activeOn = hits.map((h) => h.classList.contains("is-active"));
const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent };
// 아래 띠 공용 칸(370-3) — 빈 칸 = is-empty · 값 = 버튼 줄 끝 글
const cellNow = () => ({
hidden: handle.cell.classList.contains("is-empty"),
text: handle.cell.children[1].children.at(-1).textContent,
});
const labelOn = cellNow();
handle.setActive(null);
out.real = {
designLines,
@@ -303,7 +308,7 @@ out.real = {
activeCount: activeOn.filter(Boolean).length,
activeIndex: activeOn.indexOf(true),
labelOn,
labelOff: { hidden: handle.label.hidden, text: handle.label.textContent },
labelOff: cellNow(),
zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(),
};
@@ -131,7 +131,8 @@ function reshaped(rise, slope) {
const s = clone(pipe);
for (const p of s.samples) {
const t = ((p.offset_m ?? 0) - start.offset) * outward;
if (t >= 0 && typeof p.elevation_m === "number")
// 시작점 바로 앞 표본까지(−1 m) — 보간이 시작점을 옛 지반에 두지 않게(371-1 벽 자리 이동)
if (t >= -1 && typeof p.elevation_m === "number")
p.elevation_m = start.elevation + rise + slope * t;
}
return study(
@@ -141,4 +142,5 @@ function reshaped(rise, slope) {
}
out.pipeDown = reshaped(0.3, -0.15); // 3도보다 가파르게 내림 → 3도 선이 만남
out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 3도 선이 못 만남 → 복합경사
out.pipeOpen = reshaped(-0.3, -0.5); // 끝 바깥이 지반 아래로 열림 → 성토부선(371-1)
process.stdout.write(JSON.stringify(out));
+15 -16
View File
@@ -147,21 +147,21 @@ const handle = pick.attachDesignLinePick({
set: (side, v) => (values[side] = v),
},
});
const panel = handle.panel;
const slot = (name) => panel.querySelector(`.b06-structure-panel__btn--${name}`);
const close = panel.querySelector(".b06-structure-panel__close");
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 · 값 한 줄
const cell = handle.cell;
const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
const cellTitle = () => cell.children[0].textContent;
const cellValue = () => cell.children[1].children.at(-1).textContent;
const pieceAt = (offset) =>
handle.pieces.findIndex(
(p) =>
Math.min(p.from.offset, p.to.offset) <= offset &&
offset <= Math.max(p.from.offset, p.to.offset),
);
const hiddenAtStart = panel.classList.contains("is-hidden");
const hiddenAtStart = cell.classList.contains("is-empty");
const log = [];
handle.setActive(pieceAt(4)); // 좌 사면
const titleLeft = panel.descendants.find((n) =>
n._cls.has("b06-structure-panel__title"),
).textContent;
const titleLeft = cellTitle();
slot("left").click(); // 화면 왼쪽 = 좌측 바깥 → +0.1
key("ArrowLeft"); // 방향키 = 십자
log.push({ ...values });
@@ -174,26 +174,25 @@ key("ArrowLeft"); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤
slot("right").click(); // 우측 바깥 → +0.1
log.push({ ...values });
handle.setActive(pieceAt(-4));
const text = handle.label.textContent;
const readout = panel.descendants.find((n) => n._cls.has("b06-structure-panel__value")).textContent;
const text = cellValue();
const slots = ["left", "up", "reset", "down", "right"]
.map((n) => slot(n))
.filter((b) => !b.hidden)
.map((b) => b.textContent);
slot("reset").click(); // ↺ = 그 쪽 0
const afterReset = { ...values };
close.click(); // ✕ = 같은 조각 다시 고름(카드가 풂)
handle.setActive(null);
key("ArrowRight"); // 숨은 패널은 방향키 안 먹음
key("ArrowRight"); // 빈 칸은 방향키 안 먹음
process.stdout.write(
JSON.stringify({
log,
text,
readout,
titleLeft,
afterReset,
afterHidden: { ...values },
picks,
pieceRight: pieceAt(-4),
hiddenAtStart,
hiddenAfter: panel.classList.contains("is-hidden"),
labelButtons: handle.label.children.length,
slots: ["left", "reset", "right"].map((n) => slot(n).textContent),
hiddenAfter: cell.classList.contains("is-empty"),
slots,
}),
);
+5 -8
View File
@@ -173,13 +173,10 @@ function card(sectionMode, ditch) {
Math.min(p.from.offset, p.to.offset) <= offset &&
offset <= Math.max(p.from.offset, p.to.offset),
);
const slot = (name) => handle.panel.querySelector(`.b06-structure-panel__btn--${name}`);
const title = () =>
handle.panel.descendants.find((n) => n._cls.has("b06-structure-panel__title")).textContent;
const rowHidden = () =>
handle.panel.descendants
.find((n) => n._cls.has("b06-structure-panel__struct"))
._cls.has("is-hidden");
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(◀▲↺▼▶ 보임만 바뀜) · 값 한 줄
const slot = (name) => handle.cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
const title = () => handle.cell.children[0].textContent;
const rowHidden = () => ["left", "reset", "right"].every((name) => slot(name).hidden);
return { design, handle, values, at, slot, title, rowHidden };
}
@@ -236,7 +233,7 @@ const out = {};
same: JSON.stringify(before) === JSON.stringify(c.values),
title: c.title(),
rowHidden: c.rowHidden(),
label: c.handle.label.textContent,
label: c.handle.cell.children[1].children.at(-1).textContent,
};
// 고름 풀면 아무 키도 안 막음
c.handle.setActive(null);
+14 -15
View File
@@ -176,15 +176,17 @@ function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], s
leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl),
road: find((p) => mid(p) > 0 && mid(p) < 2),
};
// 아래 띠 공용 칸(370-3) — 버튼 다섯 칸 한 벌(보임만 바뀜) · 제목 · 값 한 줄
const slot = (name) =>
handle.panel.descendants.find(
(n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden,
);
const rows = () =>
handle.panel.descendants
.filter((n) => n._cls.has("b06-structure-panel__struct"))
.map((n) => n._cls.has("is-hidden"));
const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent;
handle.cell.descendants.find((n) => n._cls.has(`b06-cross-card__cell-btn--${name}`));
const rows = () => [
slot("left").hidden && slot("right").hidden,
slot("up").hidden && slot("down").hidden,
];
const text = (what) =>
what === "title"
? handle.cell.children[0].textContent
: handle.cell.children[1].children.at(-1).textContent;
return { design, handle, values, sets, focus, at, slot, rows, text };
}
@@ -212,13 +214,10 @@ const out = {};
const afterSide = { values: { ...c.values }, sets: c.sets.length };
const upTaken = press("ArrowUp");
press("ArrowDown");
c.handle.panel.descendants
.filter((n) => n._cls.has("b06-structure-panel__btn--reset"))
.at(-1)
.click();
c.slot("reset").click();
out.berm = {
title: c.text("b06-structure-panel__title"),
readout: c.text("b06-structure-panel__value"),
title: c.text("title"),
readout: c.text("value"),
moveHidden,
liftHidden,
leftBlocked,
@@ -232,7 +231,7 @@ const out = {};
const before = c.sets.length;
out.berm.slopeUpBlocked = press("ArrowUp");
out.berm.slopeSets = c.sets.length - before;
out.berm.slopeTitle = c.text("b06-structure-panel__title");
out.berm.slopeTitle = c.text("title");
out.berm.focusAfter = c.focus.at(-1);
// 노면 = ▲▼ 는 종단도로 넘김(안 막음)
c.handle.setActive(c.at.road);
+72 -3
View File
@@ -26,7 +26,9 @@ function loadTs(file) {
}
const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts"));
const align = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"));
const align = loadTs(
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
);
// 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다.
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
@@ -124,14 +126,23 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]);
out.radiiKeys = Object.keys(next.curve_radii).sort();
const built = align.buildAlignment(base, next);
const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) };
out.pvi = {
40: pvi(40),
60: pvi(60),
80: pvi(80),
100: pvi(100),
120: pvi(120),
140: pvi(140),
160: pvi(160),
};
out.ground = { 40: z0(40), 160: z0(160) };
const g1 = (103.46 - z0(40)) / 40;
const g2 = (101.2 - 103.46) / 40;
out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6));
out.r80 = next.curve_radii["80.000"];
out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null;
out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
out.outsideUntouched =
!("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
}
// ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작.
@@ -172,4 +183,62 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]);
out.enterOutsideDrawing = draw.key("Enter");
}
// ⑥ 되돌리기 · 다시하기(367-3) — 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · apply 안 불림 · 범위까지 거꾸로.
{
const { draw, log } = harness();
out.undoEmpty = { can: draw.canUndo(), backspace: draw.key("Backspace") };
draw.click(40, 0, stations);
draw.click(40, 0, stations); // 같은 세로선 다시 — 상태 그대로 · 안 쌓임
draw.click(160, 0, stations);
draw.click(40, 0, stations);
draw.click(80, 102, stations);
draw.click(120, 103, stations);
const dots = () => draw.marks().dots.map((d) => d.chainage_m);
draw.undo();
out.undo1 = { dots: dots(), canRedo: draw.canRedo() };
draw.redo();
out.redo1 = { dots: dots(), canRedo: draw.canRedo() };
draw.undo();
draw.click(100, 104, stations); // 새 클릭 = future 비움
out.newClick = { dots: dots(), canRedo: draw.canRedo() };
const phases = [];
while (draw.canUndo()) {
draw.undo();
phases.push(`${draw.phase()}:${draw.marks().edges.length}`);
}
out.undoAll = phases;
out.undoApplied = log.applied.length;
out.undoClosed = log.closed;
draw.redo();
draw.redo();
out.redoTwice = { phase: draw.phase(), band: draw.marks().band };
draw.reset();
out.resetClears = { undo: draw.canUndo(), redo: draw.canRedo() };
}
// ⑦ 적용이 막히면(apply = false) 자유지정 열린 채 · 점 그대로.
{
let tries = 0;
const draw = free.createFreeDraw({
base: () => base,
alignment: () => align.buildAlignment(base, align.emptyEdits()),
edits: () => align.emptyEdits(),
apply: () => ((tries += 1), false),
close: () => (tries += 100),
changed: () => undefined,
});
draw.click(40, 0, stations);
draw.click(160, 0, stations);
draw.click(40, 0, stations);
draw.click(80, 102, stations);
draw.key("Enter");
draw.click(160, 0, stations);
out.blocked = {
tries,
phase: draw.phase(),
dots: draw.marks().dots.length,
canUndo: draw.canUndo(),
};
}
process.stdout.write(JSON.stringify(out));
+33 -15
View File
@@ -489,7 +489,8 @@ function makePage() {
return app;
}
function fakeDocument(app) {
const doc = { body: new El("BODY") };
// 374-1 뜨는 자리 = body(모달 포함) · 원 · 배율 글은 html 바로 아래
const doc = { body: app, documentElement: new El("HTML") };
doc.createElement = (tag) => new El(tag.toUpperCase());
doc.getElementById = (id) => (id === "app" ? app : null);
return doc;
@@ -515,10 +516,12 @@ function fakeDocument(app) {
};
const view = m.createLoupeView(win);
view.show(500, 400);
const root = doc.body.children[0];
const label = doc.body.children[1];
const root = doc.documentElement.children[0];
const label = doc.documentElement.children[1];
const stage = root.children[0];
const page = stage.children[0];
const scaler = stage.children[0]; // 이동 = stage · 배율 = scaler
const page = scaler.children[0];
const lens = () => `${stage.style.transform} ${scaler.style.transform}`;
const copy = page.children[0];
const [scroller, offscreen, table, svg, frame, cadBox] = copy.children;
const [canvas, blank, input, check] = scroller.children;
@@ -529,7 +532,8 @@ function fakeDocument(app) {
ariaHidden: root.attrs["aria-hidden"],
hidden: root.hidden,
rootTransform: root.style.transform,
stageTransform: stage.style.transform,
stageTransform: lens(),
stageMoveOnly: stage.style.transform,
pageBox: [page.style.left, page.style.top, page.style.width, page.style.height],
isClone: copy.cloned === true && copy !== app,
canvasDrawn: canvas.drawn.map((d) => d[0]),
@@ -560,19 +564,31 @@ function fakeDocument(app) {
hidden: label.hidden,
};
view.setZoom(3);
r.label.zoom3 = [label.textContent, stage.style.transform, root.style.transform];
r.label.zoom3 = [label.textContent, lens(), root.style.transform];
view.move(500, 790);
r.label.bottom = label.style.transform;
view.setZoom(2);
view.move(500, 400);
// 화면이 바뀜 — 0.5 초 뒤 한 번만 다시 뜸
// 화면이 바뀜 — 멈춘 뒤 0.5 초에 한 번만 다시 뜸
const firstCopy = copy;
observers[0].fn();
observers[0].fn();
win.tick(499);
const sameBefore = stage.children[0].children[0] === firstCopy;
const sameBefore = scaler.children[0].children[0] === firstCopy;
win.tick(1);
const recaptured = stage.children[0].children[0] !== firstCopy && stage.children.length === 1;
const recaptured = scaler.children[0].children[0] !== firstCopy && scaler.children.length === 1;
// 374-1 움직이는 동안 안 뜸 — 바뀜 뒤 움직이면 처음부터 · 멈춘 뒤 0.5 초에 한 번
const moving = scaler.children[0].children[0];
observers[0].fn();
win.tick(300);
view.move(520, 400);
win.tick(300);
view.move(540, 400);
win.tick(499);
const heldWhileMoving = scaler.children[0].children[0] === moving;
win.tick(1);
r.debounce = { heldWhileMoving, afterStop: scaler.children[0].children[0] !== moving };
view.move(500, 400);
view.move(10, 10);
const nearEdge = root.style.transform;
view.hide();
@@ -580,20 +596,21 @@ function fakeDocument(app) {
sameBefore,
recaptured,
nearEdge,
stageChildren: stage.children.length,
stageChildren: scaler.children.length,
hidden: root.hidden,
disconnected: observers[0].watching === null,
scrollListen: win.calls.some((c) => c.type === "scroll"),
};
observers[0].fn();
win.tick(1000);
r.hide.noRecaptureAfterHide = stage.children.length;
r.hide.noRecaptureAfterHide = scaler.children.length;
r.hide.labelHidden = label.hidden;
view.setZoom(4);
view.hide();
view.show(500, 400);
r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform];
r.hide.reuseRoot = doc.body.children.length;
r.hide.reopenLabel = [label.textContent, label.hidden, lens()];
r.hide.reuseRoot = doc.documentElement.children.length;
r.hide.bodyUntouched = app.children.length; // 원이 body 에 안 붙음
}
{
// 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서)
@@ -606,8 +623,9 @@ function fakeDocument(app) {
disconnect() {}
};
const view = m.createLoupeView(win);
const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0];
const page = () => doc.body.children[0].children[0].children[0];
const scaler = () => doc.documentElement.children[0].children[0].children[0];
const cloneScroller = () => scaler().children[0].children[0].children[0];
const page = () => scaler().children[0];
view.show(500, 400);
view.hide();
scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘
@@ -99,7 +99,7 @@ out.viewOf = {
buildAlignment: () => null,
store: { edits: () => ({}) },
projectId: "p",
pagePipeline: { pipelineGrade: () => undefined }, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
pagePipeline: { pipelineGrade: () => undefined, pipelineOwnsCross: () => false }, // 계산 묶음(346-1 · 365-4) — 묶음 없으면 아무 일 없음
scheduleCrossPreview: () => s.previews++,
draw: () => s.draws++,
root: inPopup(),
@@ -107,6 +107,8 @@ process.stdout.write(
attached,
sections: sections.map((s) => s.underdrain ?? null),
outline: draw.underdrainOutline(design, 1),
// 371-3 암거 경사 3 % — 하단 = 높은 쪽(좌) 노면 끝 − 1 m 에서 3 % 로 낮은 쪽(우)으로 내림
sloped: draw.underdrainOutline(design, 1, 3),
bothCut: draw.underdrainOutline(bothCut, 1),
noHeight: draw.underdrainOutline(design, null),
drew: !!group,
@@ -321,6 +321,12 @@ const out = {};
soil_exists: soil !== null,
soil_children: soil?.childElementCount ?? null,
};
// 370-3 — 설계선 고름 공용 칸이 오면 그 칸이 왼쪽(암 제어를 따로 안 만듦)
const cell = new FakeNode("div");
cell.className = "linecell";
const cellCard = new FakeNode("article");
appendCardFooter(cellCard, { design: { geometry_preset: "rock" } }, {}, {}, cell);
out.footer.cell_classes = cellCard.querySelector("footer").children.map((kid) => kid.className);
}
// ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ───────────
+1 -1
View File
@@ -56,7 +56,7 @@ const STUBS = {
if (failReplan) throw new Error("체인 실패");
return {
route_id: 9,
pipes: { kept: 3, crossing: 1, concave: 2 },
pipes: { kept: 3, crossing: 1 },
structures: { moved: 1, far: 0 },
cross_dropped: 1,
drainage_stale: true,
@@ -21,7 +21,7 @@ function loadTs(file, real) {
return box.exports;
}
const { reconcilePipes } = loadTs(
const { adoptDraftPipes, reconcilePipes } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Parts.ts"),
["design_selection"],
);
@@ -35,5 +35,19 @@ process.stdout.write(
moved: reconcilePipes(before, [100, 250]),
added: reconcilePipes(before, [100, 200, 300]),
twoMoved: reconcilePipes(before, [150, 250]),
// 369 되돌리기 — 그 걸음 초안이 같은 자리면 초안 속성 · id 를 씀 · 다르면 null
adopt: (() => {
const next = [
{ chainage_m: 100, reason: "user" },
{ chainage_m: 200, reason: "user" },
];
const draft = [
{ chainage_m: 100, id: "a1", facility: "ford_bridge" },
{ chainage_m: 200.004, id: "b2" },
];
return { draft: adoptDraftPipes(draft, next), next };
})(),
adoptOther: adoptDraftPipes([{ chainage_m: 120, id: "z" }], [{ chainage_m: 100 }]),
adoptNone: adoptDraftPipes(null, [{ chainage_m: 100 }]),
}),
);
@@ -86,7 +86,10 @@ const flush = async () => {
/* ---- 모듈 읽기(그 자리 트랜스파일 · 바깥 모듈은 가짜) ---- */
const state = new Map();
const detailCalls = [];
let modeRow = 1; // ⚙ 창에서 고를 줄 — 0 수동 · 1 자동
const detailAutos = []; // [자동 제안](369) 방식 — 없으면 null
let records = 0; // 사진첩 한 걸음(369)
let detailStale = false; // detail-basins 가 409 낡음으로 답함(369 — 화면이 몰랐던 낡음)
let modeRow = 1; // ⚙ · [자동 제안] 창에서 고를 줄 — ⚙ 0 수동 · 1 자동 / 제안 0 전체 · 1 지정 포함
const stubs = {
"@config/config_frontend": { API_BASE_URL: "/api" },
"@ui/ui_template_progress": { createProgressCircle: () => ({ root: el("progress") }) },
@@ -98,14 +101,21 @@ const stubs = {
body.children[modeRow].children[0].fire("change");
},
},
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_target, key) => [key, key] }),
},
"./B05_Profile_UI_Profile_History": { pageHistory: () => ({ record: () => (records += 1) }) },
"../A00_Common/b_page_state": {
readStateRaw: (key) => state.get(key) ?? null,
writeStateRaw: (key, value) => state.set(key, value),
},
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
computeDetailBasins: async (projectId, points) => {
computeDetailBasins: async (projectId, points, auto) => {
detailCalls.push(points);
return { basins: [], pipe_points: points, stale: null };
detailAutos.push(auto ?? null);
if (detailStale) throw new Error("배수유역 낡음 — 노선이 분석 때와 다릅니다.");
return { basins: [], pipe_points: points ?? [], stale: null };
},
},
"./B05_Profile_UI_Drainage_Parts": { analyzeFailed: (_error, fallback) => fallback },
@@ -135,6 +145,8 @@ async function main() {
const viewport = el("viewport");
const header = el("header");
const root = el("root");
const propose = el("button");
header.querySelector = (selector) => (selector === ".b05-drainage__propose" ? propose : null);
root.querySelector = (selector) =>
selector === ".b05-drainage__viewport"
? viewport
@@ -280,7 +292,12 @@ async function main() {
out.autoSavedDone = look();
// 349-0i 되돌리기 — 자동: 끌기 → 낡음 → Ctrl+Z(초안 없어짐) = 3 s 셈 대신 저장분
const savedOk = { basins: [], pipe_points: pipes, stale: null };
// 저장분 답에도 관경 제안 하나 — [적용] 켜짐은 낡음 · 관경/시설 제안 유무로 갈림(369)
const savedOk = {
basins: [{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 }],
pipe_points: pipes,
stale: null,
};
stage(draft(20, 300));
advance(1000);
stage(null);
@@ -308,6 +325,70 @@ async function main() {
savedAnswer({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" });
await flush();
out.undoSavedStale = { ...look(), savedLoads, mode: state.get("drainage-mode") };
// 369 [자동 제안] — 낡은 동안 끔 · ① all · ② add · [배관 배치 초기화] = 관 목록 없이(세류선만)
out.proposeStale = { off: propose.disabled, title: propose.title };
const beforeStale = detailCalls.length;
propose.fire("click");
await flush();
out.proposeStale.calls = detailCalls.length - beforeStale;
live.markFresh();
out.proposeFresh = { off: propose.disabled, title: propose.title };
const n0 = detailCalls.length;
const remembered0 = remembered;
const records0 = records;
modeRow = 0;
propose.fire("click");
await flush();
modeRow = 1;
propose.fire("click");
await flush();
live.resetToStreams();
await flush();
out.propose = {
autos: detailAutos.slice(n0),
points: detailCalls.slice(n0).map((points) => (points === null ? null : points.length)),
remembered: remembered - remembered0,
records: records - records0,
};
// 화면이 몰랐던 낡음 — 빠른 길이 409 낡음이면 곧바로 지금 노선으로 셈 길로 다시
detailStale = true;
modeRow = 0;
const fallbackFrom = calls.length;
propose.fire("click");
await flush();
const fallbackCall = calls[calls.length - 1];
out.proposeFallback = {
sent: calls.length - fallbackFrom,
auto: fallbackCall.body.auto,
status: status.textContent,
};
answer(fallbackCall);
await flush();
detailStale = false;
// 저장 분석이 옛 노선(서버 낡음 답) → [업데이트] 뒤 [자동 제안] = 지금 노선으로 셈 길에 방식 실음
await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }));
update.fire("click");
answer(calls[calls.length - 1]);
await flush();
const viaDetail = detailCalls.length;
modeRow = 0;
propose.fire("click");
const liveCall = calls[calls.length - 1];
answer(liveCall);
await flush();
live.resetToStreams();
const resetCall = calls[calls.length - 1];
answer(resetCall);
await flush();
out.proposeLive = {
auto: liveCall.body.auto,
points: liveCall.body.points.length,
resetPoints: resetCall.body.points,
resetAuto: resetCall.body.auto ?? null,
detailCalls: detailCalls.length - viaDetail,
remembered: remembered - remembered0,
records: records - records0,
};
// 입력 지문 — 판 번호 · 관 차례는 안 봄 · 꼭짓점 · 관 자리(cm) · id 는 봄
const { inputKey } = load(path.join(B05, "logic", "B05_Profile_Drainage_Live.ts"));
const a = { id: "a1", chainage_m: 100 };
@@ -23,7 +23,7 @@ const mass = read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts");
let stored = null;
const src = [
'const SERIES_BASES = ["cross", "longitudinal"];',
'const DEFAULT_VISIBLE = ["cross"];',
'const DEFAULT_VISIBLE = ["cross", "balance"];', // 367-1 기본 = 횡단 + 분배
block(util, "export function normalizeVisibleBasis("),
block(mass, "function readVisible(): Set<string> {"),
block(util, "export function computeMassHaulSeries("),
@@ -0,0 +1,403 @@
/* PLAN 365 단위검증 헬퍼 — 묶음 주인 = 페이지(초안 둔 채 열기 · B06 바로 열기 · 새로고침). 진짜 TS
* (`design_pipeline_page` · 단계 파일 전부)를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구만 가짜
* (틀 = `helper_346_2_pipeline_cross_page.cjs`). 입력 argv[2] JSON · argv[3] 격자 이진 · 결과 = 표준출력 JSON.
* 짝 = `test_365_page_pipeline.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
}
globalThis.window = {
sessionStorage: memoryStorage(),
localStorage: memoryStorage(),
setTimeout,
clearTimeout,
};
// 흙 배분 서버 — 늦게 답함 · 끊기면 버림
const haul = { calls: 0, aborted: 0, answered: 0 };
globalThis.fetch = (url, init) =>
new Promise((resolve, reject) => {
haul.calls += 1;
const timer = setTimeout(() => {
haul.answered += 1;
resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) });
}, job.haulDelayMs);
init.signal.addEventListener("abort", () => {
clearTimeout(timer);
haul.aborted += 1;
reject(new Error("aborted"));
});
});
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));
const sampler = load("common_util", "common_util_surface_sampler.ts");
const bytes = fs.readFileSync(process.argv[3]);
const grid = sampler.parseDtmGrid(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length),
);
const local = { grid, design: "las" };
STUBS["B05_Profile_Api_Replan"] = {
analysisTerrain: () => "las",
fetchRoutePlan: async () => ({
planned: job.line,
curves: [],
min_radius_m: job.minRadius,
station_snap: false,
}),
};
STUBS["B05_Profile_Api_Structures"] = {
fetchStructures: async () => ({ structures: [] }),
fetchStructureTypes: async () => job.types,
};
STUBS["B05_Profile_Api_TerrainGrid"] = {
loadDesignTerrainGrid: async () => local,
groundStageDeps: () => ({ grid: () => local, remote: async () => [] }),
};
STUBS["B04_PreProcess_Api_Fetch"] = {
fetchDetailPipePoints: async () => ({
pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })),
}),
};
STUBS["B06_Section_UI_Standard_Panel"] = {
effectiveStandardCross: () => job.standard,
readRockBoundaryDefault: () => -0.5,
};
const pipeMod = load("A00_Common", "design", "logic", "design_pipeline.ts");
const pageMod = load("A00_Common", "design", "design_pipeline_page.ts");
const stationsMod = load("A00_Common", "design", "logic", "design_pipeline_stations.ts");
const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts");
const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts");
const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts");
const detailMod = load("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts");
const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts");
const editMod = load("B05_Profile", "B05_Profile_UI_Profile_Edit.ts");
const page = load("A00_Common", "b_page_state.ts");
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const runs = () => globalThis.__aisloPipeline?.runs ?? [];
async function settle(count) {
for (let i = 0; i < 400; i += 1) {
const last = runs()[runs().length - 1];
if (runs().length >= count && last && last.outcome !== "running") return last;
await tick(5);
}
throw new Error("묶음이 끝나지 않음");
}
const n = job.nodes.length;
const draftOf = (nodes) => ({
nodes,
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(null),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
stationSnap: false,
});
const shift = (index, dx, dy) =>
job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node));
/** 저장 기준 — 저장 노선 지반 첫 계획선(관 측점 포함 · 서버 정책 그대로). */
async function savedBaseOf() {
const seed = pipeMod.createDesignPipeline({
stages: {
route: routeGround.createRouteStage({
minRadiusM: () => job.minRadius,
savedLine: () => ({ line: job.line, curves: [] }),
}),
stations: stationsMod.createStationsStage({
intervalM: job.interval,
types: () => job.types,
}),
ground: routeGround.createGroundStage({ grid: () => local, remote: async () => [] }),
},
});
await seed.start({ route: null, alignment: null, cross: null, pipes: job.pipes, structures: [] });
const ground = seed.output("ground");
seed.dispose();
const samples = ground.longitudinal.chainageM.map((c, i) => ({
chainage_m: c,
elevation_m: ground.longitudinal.z[i],
}));
const profile = gradeGround.groundProfile(samples);
return {
policy: job.policy,
onlyExplicitCurves: false,
basePvi: gradeGround.groundFollowingBasePvi({
samples,
stations: ground.stations.map((s) => s.chainageM),
policy: job.policy,
}),
chainage: profile.chainage,
ground: profile.ground,
stations: ground.stations.map((s) => ({
station_id: grade.stationIdOf(s.chainageM),
chainage_m: s.chainageM,
})),
};
}
const store = load("A00_Common", "design", "design_store.ts");
// B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜
STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined };
STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" };
const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts");
const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null);
/** 묶음 새 노선 상세(카드가 그리는 것) — 측점 · 계획고 · 절 · 성토 면적. */
function shownRows(P) {
const shown = pageMod.pipelineDetail(P);
return (
shown &&
shown.cross_sections.map((s) => [
r3(s.chainage_m),
r3(s.design?.design_elevation_m),
r3(s.design?.cut_area_m2),
r3(s.design?.fill_area_m2),
])
);
}
async function run() {
const P = "proj-365";
window.localStorage.setItem("frd_current_project_id", P); // 지금 프로젝트(노선 0 칸 자리)
const savedBase = await savedBaseOf();
const shop = store.useDesignStore(P, null);
const holds = [];
let unstable = 0;
const link = () => ({
pipelineSource: () => ({
base: savedBase,
edits: alignment.emptyEdits(),
detail: job.detail,
routeId: 7,
}),
adoptPipeline: () => undefined,
redraw: () => undefined,
});
const options = {
stationIntervalM: job.interval,
holdDraft: (_routeId, saved) => {
holds.push(saved === job.detail ? "saved" : saved ? "other" : "null");
return async () => "saved";
},
onUnstable: () => (unstable += 1),
};
const open = (label) => {
const bridge = pageMod.routeDraftBridge(P, link(), options);
pageMod.pipelineEarthwork(P, { conversion: job.conversion });
return { bridge, label };
};
const stationList = () => (shop.get("stations") ?? []).map((s) => r3(s.chainageM));
// ① B05 길 — 저장 노선으로 열고 노선 꼭짓점 옮김(초안)
const first = open("b05");
await settle(runs().length + 1);
first.bridge.edited(draftOf(shift(1, 8, -6)));
await settle(runs().length + 1);
await tick(20);
const b05 = { rows: shownRows(P), stations: stationList(), holds: [...holds] };
// ② 떠남(새로고침 · 묶음 메모리 버림) — 세션(가게) 초안은 남음 · 가게 측점은 비워 다시 내는지 봄
pageMod.disposePagePipeline(P);
const kept = shop.get("routeDraft") !== null;
shop.set("stations", null);
holds.length = 0;
// ③ B06 바로 열기 — 페이지가 묶음을 세움(편집기 없음) · 초안 그대로 셈
const again = open("b06");
const reopenRun = await settle(runs().length + 1);
await tick(20);
const b06 = {
rows: shownRows(P),
stations: stationList(),
holds: [...holds],
outcome: reopenRun.outcome,
stages: Object.keys(reopenRun.stages),
};
// ⑤ 초안 동안 B06 고침 — 세션 칸(측점 열쇠) → pipelineCross → 카드(묶음 새 노선 상세)
const pipe = pageMod.pagePipeline(P);
const target = pipe.output("ground").stations.find((s) => s.kind === "grid" && s.chainageM > 30);
const cell = (ground) => ({ [target.key]: { ground_type: ground, section_mode: "left_cut" } });
const groundAt = () =>
pageMod
.pipelineDetail(P)
.cross_sections.find((s) => Math.abs(s.chainage_m - target.chainageM) < 1e-3)?.design
?.ground_type ?? null;
const before = groundAt();
page.writeState("crossdesign", cell("blasting_rock"), P, 7);
let mark = runs().length;
pageMod.pipelineCross(P);
await settle(mark + 1);
await tick(20);
const edit = {
key: target.key,
before,
after: groundAt(),
input: pipe.input("cross").byKey[target.key]?.groundType ?? null,
runs: runs().length - mark,
sources: runs()[runs().length - 1].sources,
};
// ⑦ B05 오른쪽 횡단 판 = 묶음 그 측점(카드와 같은 계획고 · 면적)
const card = pageMod.pipelineDetail(P).cross_sections;
const panes = card.map((section) => {
const shown = pane.pipelineCrossAt(pipe, section.chainage_m + 0.4, job.interval, undefined);
return [
r3(shown?.chainage_m) === r3(section.chainage_m),
r3(shown?.design?.design_elevation_m) === r3(section.design?.design_elevation_m),
r3(shown?.design?.cut_area_m2) === r3(section.design?.cut_area_m2),
shown?.samples.length === section.samples.length,
];
});
const paneCheck = {
count: card.length,
same: panes.filter((row) => row.every(Boolean)).length,
elevation: r3(
pane.pipelineCrossAt(pipe, target.chainageM, job.interval, undefined)?.design
?.design_elevation_m,
),
};
// ⑥ 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜(알림 없음) = 1 바퀴 · 안 맞음 = 3 바퀴 뒤 알림
mark = runs().length;
const same = await pageMod.reconcilePagePipeline(P);
const sameRuns = runs().length - mark;
page.writeState("crossdesign", cell("ripping_rock"), P, 7);
mark = runs().length;
const once = await pageMod.reconcilePagePipeline(P);
const loop = {
same,
sameRuns,
once,
onceRuns: runs().length - mark,
onceGround: groundAt(),
};
let n = 0;
const realStandard = STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross;
STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = () => ({
...job.standard,
n: (n += 1),
});
mark = runs().length;
loop.unstable = await pageMod.reconcilePagePipeline(P);
loop.unstableRuns = runs().length - mark;
loop.alerts = unstable;
STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = realStandard;
await pageMod.reconcilePagePipeline(P);
// ④ 초안 버림 — 열 때 맡긴 초안 전 상세로 되돌림(저장 정본)
again.bridge.edited(null);
const dropRun = await settle(runs().length + 1);
await tick(20);
const dropped = {
detail: pageMod.pipelineDetail(P),
restoreWhy: dropRun.restoreWhy ?? null,
routeDraft: shop.get("routeDraft"),
};
pageMod.disposePagePipeline(P);
page.clearDrafts(P);
// ⑨ 저장 노선 ▲▼ 한 번 = 횡단 한 벌(365-4) — 묶음 cross 가 다시 셈 창구를 부르고 그 상세 설계를 냄
const Q = "proj-365b";
window.localStorage.setItem("frd_current_project_id", Q);
let refreshCalls = 0;
const qBridge = pageMod.routeDraftBridge(Q, link(), {
stationIntervalM: job.interval,
savedCross: async (detail, _routeId, stations) => {
if (!detailMod.detailCross(detail, stations)) return null;
refreshCalls += 1;
return detailMod.detailCross(detail, stations);
},
});
pageMod.pipelineEarthwork(Q, { conversion: job.conversion });
const qPipe = pageMod.pagePipeline(Q);
await settle(runs().length + 1);
await tick(20);
const openCalls = refreshCalls;
const key = alignment.chainageKey(savedBase.stations[3].chainage_m);
mark = runs().length;
pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.3 }, curve_radii: {} });
const gradeRun = await settle(mark + 1);
const firstKey = qPipe.output("ground").stations[0].key;
const saved = {
owns: pageMod.pipelineOwnsCross(Q),
openCalls,
gradeCalls: refreshCalls - openCalls,
runs: runs().length - mark,
sources: gradeRun.sources,
fromDetail:
qPipe.output("cross").byKey[firstKey]?.detail === job.detail.cross_sections[0].design,
};
// ⑧ 중심점 좌우(356-3 꼴) — 노선 한 걸음 → 새 종단이 오면 계획고 맞춤 · 첫 차례는 cross 전에 밀림(무거운 셈 한 번)
let graded = false;
const stopShift = qPipe.subscribe((event) => {
if (event.kind !== "stage" || event.stage !== "profile" || graded) return;
if (event.generation !== qPipe.generation) return;
graded = true;
void Promise.resolve().then(() =>
pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.1 }, curve_radii: {} }),
);
}, "중심점 좌우");
mark = runs().length;
qBridge.edited(draftOf(shift(1, 0, 1)));
await settle(mark + 2);
await tick(job.haulDelayMs + 30);
stopShift();
const shiftRuns = runs()
.slice(mark)
.map((r) => ({ outcome: r.outcome, cross: "cross" in r.stages, sources: r.sources }));
pageMod.disposePagePipeline(Q);
page.clearDrafts(Q);
return { kept, b05, b06, dropped, edit, loop, pane: paneCheck, saved, shiftRuns };
}
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);
});
+109
View File
@@ -0,0 +1,109 @@
/* 365-1 헬퍼 — 노선 초안 동안 측점 갈아끼움은 캐시(메모리)만 · 세션 상세 = 저장 노선 그대로.
* node helper_365_swap.mjs <묶음.mjs>
* vite ssr 로 묶은 진짜 모듈(helper_365_swap_entry.ts) · 표준출력 JSON 한 벌. 짝 = test_365_page_pipeline.py.
*/
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 m = await import(pathToFileURL(process.argv[2]).href);
const P = "p365";
const R = 9;
localStorage.setItem("frd_current_project_id", P);
const section = (chainage_m, kind) => ({
station_id: `station_${String(Math.round(chainage_m * 1000)).padStart(12, "0")}`,
chainage_m,
label: "",
kind,
center_z: 100,
azimuth_deg: 90,
center_x: chainage_m,
center_y: 0,
frame: { left_xy: [0, 1] },
samples: [{ offset_m: 0, elevation_m: 100, valid: true }],
design: { design_elevation_m: 101 },
});
const saved = {
longitudinal: {
length_m: 60,
stations: [],
profile_alignment: { policy: { station_interval_m: 20 } },
},
cross_sections: [
section(0, "bp"),
section(20, "regular"),
section(40, "regular"),
section(60, "ep"),
],
};
const station = (key, chainageM, kind) => ({
key,
chainageM,
x: chainageM,
y: 0,
azimuthDeg: 90,
left: [0, 1],
kind,
id: null,
ground: { centerZ: 99, left: [{ offsetM: 0.5, z: 99.1 }], right: [{ offsetM: 0.5, z: 98.9 }] },
planRadiusM: null,
curveOuterSide: null,
curveWideningM: null,
});
const list = (...at) =>
at.map((c, i) =>
station(
i === 0 ? "end:BP" : i === at.length - 1 ? "end:EP" : `grid:${c}`,
c,
i === 0 || i === at.length - 1 ? "end" : "grid",
),
);
const chainages = (detail) => detail?.cross_sections.map((s) => s.chainage_m) ?? null;
const shop = m.useDesignStore(P, null);
m.writeState("section-detail", saved, P, R);
await m.loadSectionDetail(P, R); // 세션 한 벌 → 캐시
const out = {};
// 노선 초안 동안 — 측점 하나 늘어난 목록
shop.set("routeDraft", { nodes: [], rev: 1 });
const draft = await m.swapSectionStations(P, R, list(0, 20, 30, 40, 60));
out.draft = {
swapped: draft !== null,
cache: chainages(await m.loadSectionDetail(P, R)),
session: chainages(m.readState("section-detail", P, R)),
};
// 초안 없음(저장 노선) — 갈아끼움이 세션까지
shop.set("routeDraft", null);
const plain = await m.swapSectionStations(P, R, list(0, 20, 50, 60));
out.saved = {
swapped: plain !== null,
cache: chainages(await m.loadSectionDetail(P, R)),
session: chainages(m.readState("section-detail", P, R)),
};
process.stdout.write(JSON.stringify(out));
process.exit(0);
@@ -0,0 +1,7 @@
/* 365-1 시험 묶음 진입점 — 초안 동안 측점 갈아끼움 = 메모리만(진짜 모듈 · vite ssr). */
export {
loadSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
export { useDesignStore } from "../../A00_Common/design/design_store";
export { readState, writeState } from "../../A00_Common/b_page_state";
@@ -0,0 +1,114 @@
/* 366-3 측점 스냅 = 곡선 가운데(MC)를 일반측점에 — `logic/B05_Profile_RouteEdit_StationFit.ts` 를
* Node 로 돌려 JSON 한 줄을 냄(`test_366_3_station_fit.py` 가 판정).
* `.cjs` = 루트 package.json 이 "type": "module" 이라서.
*/
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 curve = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Curve.ts"));
const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts"));
const INTERVAL = 20;
const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL };
// 지그재그 노선 — 꺾임 다섯(교각 다양) · 직선 넉넉
const ROUTE = [
[0, 0],
[93, 7],
[171, 61],
[262, 48],
[337, 112],
[431, 101],
[503, 160],
];
function columns(planned, lock = []) {
const n = planned.length;
return {
planned,
curveOn: planned.map(() => true),
curveRadius: planned.map(() => null),
curveLock: planned.map(() => null),
curveArc: planned.map(() => null),
apexLock: planned.map((_, i) => i === 0 || i === n - 1 || lock.includes(i)),
};
}
function measure(planned) {
const built = curve.buildEditedPolyline(
planned,
planned.map(() => true),
planned.map(() => null),
LIMITS.minRadiusM,
);
const spans = frame.curveSpans(built.vertices, built.curves);
const mcs = spans.map(([a, b]) => (a + b) / 2);
const total = frame.stationsAlong(built.vertices, INTERVAL, false);
const length = built.vertices.reduce(
(sum, v, i) =>
i ? sum + Math.hypot(v[0] - built.vertices[i - 1][0], v[1] - built.vertices[i - 1][1]) : 0,
0,
);
return {
curves: built.curves.length,
mcs,
residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / INTERVAL) * INTERVAL)),
innerStations: total.filter((s) => s > 0 && s < length).length,
off: fit.offStationCount(built.vertices, built.curves, INTERVAL),
};
}
const moved = (a, b) => a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1]));
const before = measure(ROUTE);
const all = fit.fitCurveStations(columns(ROUTE), LIMITS, 1);
const locked = fit.fitCurveStations(columns(ROUTE, [3]), LIMITS, 1);
const fromLater = fit.fitCurveStations(columns(ROUTE), LIMITS, 4);
const everyLocked = fit.fitCurveStations(columns(ROUTE, [1, 2, 3, 4, 5]), LIMITS, 1);
console.log(
JSON.stringify({
before,
all: { ...measure(all), moved: moved(ROUTE, all) },
locked: { ...measure(locked), moved: moved(ROUTE, locked) },
fromLater: { ...measure(fromLater), moved: moved(ROUTE, fromLater) },
everyLocked: { ...measure(everyLocked), moved: moved(ROUTE, everyLocked) },
again: moved(all, fit.fitCurveStations(columns(all), LIMITS, 1)),
noInterval: moved(ROUTE, fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: 0 }, 1)),
start: {
moved4: fit.fitStart(
columns(ROUTE),
columns(ROUTE.map((v, i) => (i === 4 ? [v[0] + 1, v[1]] : v))),
),
radius2: fit.fitStart(columns(ROUTE), {
...columns(ROUTE),
curveRadius: ROUTE.map((_, i) => (i === 2 ? 40 : null)),
}),
dropLast: fit.fitStart(columns(ROUTE), columns(ROUTE.filter((_, i) => i !== 5))),
same: fit.fitStart(columns(ROUTE), columns(ROUTE)),
none: fit.fitStart(null, columns(ROUTE)),
},
}),
);
+305
View File
@@ -0,0 +1,305 @@
/* PLAN 370-2 단위검증 헬퍼 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기.
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 입력 = argv[2] 표준단면 JSON(파이썬 config) · 마루 지반(우측 −4 m 너머 내리막). 짝 = `test_370_2_toe_stop.py`.
*/
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();
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
"@ui/ui_template_elements": { showToast: () => undefined },
};
class FakeNode {
constructor(tag) {
this.tagName = String(tag);
this.children = [];
this.attrs = {};
this.listeners = {};
this.hidden = false;
this.title = "";
this._text = "";
this._classes = new Set();
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
toggle: (name, force) => {
const on = force === undefined ? !self._classes.has(name) : !!force;
on ? self._classes.add(name) : self._classes.delete(name);
return on;
},
contains: (name) => self._classes.has(name),
};
this.style = { setProperty: () => {} };
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
get childElementCount() {
return this.children.length;
}
get isConnected() {
return true;
}
_detach(kid) {
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
kid.parent = this;
}
append(...kids) {
kids.forEach((k) => {
if (typeof k === "string") return;
this._detach(k);
this.children.push(k);
});
}
prepend(...kids) {
kids.reverse().forEach((k) => {
this._detach(k);
this.children.unshift(k);
});
}
appendChild(kid) {
this.append(kid);
return kid;
}
insertBefore(kid, ref) {
this._detach(kid);
const at = ref ? this.children.indexOf(ref) : -1;
if (at < 0) this.children.push(kid);
else this.children.splice(at, 0, kid);
return kid;
}
remove() {
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
}
querySelector() {
return null;
}
setAttribute(name, value) {
this.attrs[name] = String(value);
if (name === "class") this.className = value;
}
getAttribute(name) {
return name === "class" ? this.className : (this.attrs[name] ?? null);
}
hasAttribute(name) {
return name in this.attrs;
}
addEventListener(type, fn) {
(this.listeners[type] ??= []).push(fn);
}
click() {
const event = { stopPropagation: () => {}, preventDefault: () => {} };
(this.listeners.click ?? []).forEach((fn) => fn(event));
}
}
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
createElementNS: (_ns, tag) => new FakeNode(tag),
addEventListener: () => {},
removeEventListener: () => {},
};
globalThis.window = { setTimeout, clearTimeout, addEventListener: () => {} };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick"));
const fit = loadTs(B06("logic/B06_Section_UI_Cross_Fit"));
const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design"));
const { computeCrossDesign } = loadTs(
path.join(ROOT, "common_util", "common_util_cross_design.ts"),
);
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = {};
// 마루 지반 — 우측 −4 m 까지 평지(노면보다 높음 = 절토) · 그 너머 1:0.6 내리막
const samples = Array.from({ length: 49 }, (_, i) => {
const o = -12 + i * 0.5;
return { offset_m: o, elevation_m: 100.3 - 0.6 * Math.max(0, -o - 4), valid: true };
});
const groundAt = (o) => 100.3 - 0.6 * Math.max(0, -o - 4);
const designOf = (extendRight) => {
const result = computeCrossDesign(samples, 100, {
standard,
groundType: "soil",
sectionMode: "right_cut",
ditchSide: "right",
shoulderExtendRightM: extendRight,
});
// 화면 design 칸 이름(저장 · 응답과 같음)
return { ...result, shoulder_extend_right_m: extendRight, shoulder_extend_left_m: 0 };
};
const section = (extendRight) => ({
station_id: "S1",
chainage_m: 20,
samples,
design: designOf(extendRight),
});
// ① 멈춤 — 우측 노견 끝이 원지반을 지나는 한 걸음 사이 → 그 자리
const gapAt = (design, e) => {
const { left, right } = design.road_edges;
const o = -(Math.abs(right.offset_m) - (design.shoulder_extend_right_m ?? 0) + e);
const plane =
right.elevation_m +
((left.elevation_m - right.elevation_m) * (o - right.offset_m)) /
(left.offset_m - right.offset_m);
return groundAt(o) - plane;
};
const base = designOf(0);
let crossing = null;
for (let e = 0; e < 6; e = Math.round((e + 0.1) * 10) / 10) {
if (Math.sign(gapAt(base, e)) !== Math.sign(gapAt(base, e + 0.1))) {
crossing = [e, Math.round((e + 0.1) * 10) / 10];
break;
}
}
const stop = fit.extendStop(
samples,
designOf(crossing[0]).road_edges,
"right",
crossing[0],
crossing[1],
);
out.stop = {
crossing,
value: stop,
gap: Math.abs(gapAt(base, stop)),
noCross: fit.extendStop(samples, base.road_edges, "right", 0, 0.1),
// 멈춘 자리에서 다음 걸음은 그대로 감(다시 안 섬)
fromStop: fit.extendStop(
samples,
designOf(stop).road_edges,
"right",
stop,
Math.round((stop + 0.1) * 10) / 10,
),
leftNoGround: fit.extendStop([], base.road_edges, "left", 0, 0.1),
};
// ② 교차점 손잡이 — 쪽마다 원 · 늘 보임 · 키 toe:left|right · 고르면 ◀▶ 가 멈춤 자리로 보냄
const x = (o) => 300 - o * 20;
const y = (e) => 400 - (e - 100) * 20;
const attach = (sec, changes) => {
const layer = new FakeNode("g");
const from = layer.childElementCount;
appendCrossDesignOverlay(layer, sec.design, x, y, sec.samples, undefined);
layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
const picks = [];
const handle = pick.attachDesignLinePick({
layer,
design: sec.design,
x,
toDisplayY: y,
onPick: (i) => picks.push(i),
...pick.designLineMoves(sec, (_chainage, change) => changes.push(change)),
});
return { layer, handle, picks };
};
const changes = [];
const near = section(crossing[0]);
const { layer, handle, picks } = attach(near, changes);
const toes = layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
const keys = pick.designLineMoves(near, () => undefined).toes;
out.toe = {
count: toes.length,
centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")),
sides: ["left", "right"].filter((s) => keys[s]),
rightOnGround:
keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9,
rightCx: toes.length === 2 ? Number(toes[1].getAttribute("cx")) : null,
rightX: keys.right ? x(keys.right.offset_m) : null,
};
toes[1].click();
handle.setActive(picks[0]);
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(끝 = 값 글)
const cellOf = (h) => ({
title: h.cell.children[0].textContent,
value: h.cell.children[1].children.at(-1).textContent,
button: (slot) =>
h.cell.children[1].children.find((k) =>
k.classList.contains(`b06-cross-card__cell-btn--${slot}`),
),
});
const cell = cellOf(handle);
cell.button(x(-1) > x(0) ? "right" : "left").click(); // 화면 → 바깥(우 = −offset)
out.toePick = {
picked: picks,
active: toes[1].classList.contains("is-active"),
value: cell.value,
title: cell.title,
panelOpen: !handle.cell.classList.contains("is-empty"),
sent: changes.map((c) => c.shoulder_extend),
};
// ③ 고름 이어받기 — 우 절토 사면을 고른 뒤 마루 너머로 옮기면(키 cut → fill) 같은 쪽 손잡이
const cutIndex = handle.pieces.findIndex(
(p) =>
pick.pieceZone(near.design, p).zone === "cut" &&
pick.pieceZone(near.design, p).side === "right",
);
pick.setPickedLine("S1", null);
attach(near, []); // 키 적음
pick.setPickedLine("S1", cutIndex);
const before = pick.pickedLineAt("S1");
const far = section(crossing[1] + 1.0);
const after = attach(far, []);
const afterIndex = pick.pickedLineAt("S1");
out.inherit = {
cutIndex,
before,
farMode: far.design.section_mode,
afterIndex,
};
after.handle.setActive(afterIndex);
out.inherit.label = cellOf(after.handle).title;
process.stdout.write(JSON.stringify(out));
+310
View File
@@ -0,0 +1,310 @@
/* PLAN 370-3 단위검증 헬퍼 — B06 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸.
* `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_370_3_rock_cell.py`.
*/
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();
class Node {
constructor(tag) {
this.tagName = tag;
this.children = [];
this.attrs = {};
this.listeners = {};
this.hidden = false;
this.isConnected = true;
this._text = "";
this._cls = new Set();
const self = this;
this.classList = {
add: (...n) => n.forEach((c) => self._cls.add(c)),
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
contains: (c) => self._cls.has(c),
};
}
get descendants() {
return this.children.flatMap((c) => [c, ...c.descendants]);
}
/** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */
querySelector(sel) {
return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null;
}
getClientRects() {
return this._cls.has("is-hidden") ? [] : [{}];
}
set className(v) {
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
}
set textContent(v) {
this._text = String(v);
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
this.children.push(...kids);
}
insertBefore(kid) {
this.children.push(kid);
}
setAttribute(n, v) {
this.attrs[n] = String(v);
if (n === "class") this.className = v;
}
getAttribute(n) {
return this.attrs[n] ?? null;
}
hasAttribute(n) {
return n in this.attrs;
}
addEventListener(t, fn) {
(this.listeners[t] ??= []).push(fn);
}
click() {
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
}
}
const docListeners = {};
globalThis.document = {
createElement: (t) => new Node(t),
createElementNS: (_n, t) => new Node(t),
addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn),
removeEventListener: (t, fn) => {
docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn);
},
};
const key = (name) =>
[...(docListeners.keydown ?? [])].forEach((fn) =>
fn({ key: name, target: null, preventDefault: () => undefined }),
);
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 req = (r) =>
r.startsWith("@util/")
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
: loadTs(path.join(path.dirname(full), `${r}.ts`));
new Function("exports", "module", "require", js)(box.exports, box, req);
cache.set(full, box.exports);
return box.exports;
}
globalThis.Element = Node;
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
const y = (e) => 900 - (e - 100) * 12;
const line = (pts, structure = false) => {
const el = new Node("polyline");
el.setAttribute("class", "b06-chart__design-cross");
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, "");
return el;
};
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
const press = (name) => {
let prevented = false;
[...(docListeners.keydown ?? [])].forEach((fn) =>
fn({ key: name, target: null, preventDefault: () => (prevented = true) }),
);
return prevented;
};
// 끌기 — window 듣개(놓을 때 click 한 번 막음 포함)
const winListeners = {};
globalThis.window = {
addEventListener: (t, fn) => (winListeners[t] ??= []).push(fn),
removeEventListener: (t, fn) => {
winListeners[t] = (winListeners[t] ?? []).filter((f) => f !== fn);
},
setTimeout: () => 0,
clearTimeout: () => undefined,
setInterval: () => 0,
clearInterval: () => undefined,
};
globalThis.setTimeout = () => 0;
const fire = (type, event) => [...(winListeners[type] ?? [])].forEach((fn) => fn(event));
/** 암 측점 카드 — 좌 사면 · 노면 · 우 사면 + 암 경계선 점선 두 토막(결측으로 끊김). */
function card(rockOn = true) {
docListeners.keydown = [];
const design = {
road_edges: {
left: { offset_m: 2, elevation_m: 100 },
right: { offset_m: -2, elevation_m: 100 },
},
ditch: null,
ditch_side: "left",
ditch_enabled: false,
section_mode: "both_fill",
};
const layer = new Node("g");
layer.getScreenCTM = () => ({ a: 1, d: 1 });
layer.append(
line([
[6, 97],
[2, 100],
]),
line([
[2, 100],
[-2, 100],
]),
line([
[-2, 100],
[-6, 97],
]),
);
const rockLine = (pts) => {
const el = new Node("polyline");
el.setAttribute("class", "b06-chart__rock-boundary");
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
el.style = { setProperty: (k, v) => (el.styleSet = v) };
return el;
};
const rocks = [
rockLine([
[8, 96],
[1, 98],
]),
rockLine([
[-1, 98],
[-8, 96],
]),
];
layer.append(...rocks);
const offsets = [];
const resets = [];
const rock = {
legend: "암 경계선",
offsetM: -0.5,
stepM: 0.1,
upTitle: "위로",
downTitle: "아래로",
resetTitle: "기본",
adjust: (d) => offsets.push(Math.round(d * 100) / 100),
reset: () => resets.push(true),
};
const values = { left: 0, right: 0 };
const picks = [];
const handle = pick.attachDesignLinePick({
layer,
design,
x,
toDisplayY: y,
onPick: (i) => picks.push(i),
shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) },
stationId: "R1",
...(rockOn && { rock }),
});
const cell = handle.cell;
const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
const state = () => ({
empty: cell.classList.contains("is-empty"),
title: cell.children[0].textContent,
value: cell.children[1].children.at(-1).textContent,
slots: ["left", "up", "reset", "down", "right"].filter((n) => !slot(n).hidden),
arrowSlots: ["left", "up", "reset", "down", "right"].filter((n) =>
slot(n).classList.contains(`b06-structure-panel__btn--${n}`),
),
});
const hits = layer.children.filter((k) => k.classList.contains("b06-chart__design-hit"));
const rockHit = hits.find((k) => k.classList.contains("b06-chart__rock-hit"));
if (rockHit) rockHit.style = { setProperty: (k, v) => (rockHit.styleSet = v) };
return { handle, cell, slot, state, hits, rockHit, rocks, offsets, resets, values, picks };
}
const out = {};
// ① 안 고름 — 공용 칸 = 암 ▲▼↺ · 값 · 방향키는 안 먹음(종단 계획고로 그대로)
{
const c = card();
c.handle.setActive(null);
out.idle = { ...c.state(), upPassed: !press("ArrowUp") };
c.slot("up").click();
c.slot("down").click();
c.slot("reset").click();
out.idle.offsets = c.offsets;
out.idle.resets = c.resets.length;
}
// ② 판정 선 — 점선 위 path 하나(끊긴 두 토막 · M 둘) · 보라 선 판정 클래스 · 키 rock
{
const c = card();
const index = c.hits.indexOf(c.rockHit);
c.rockHit.click();
out.hit = {
found: !!c.rockHit,
tag: c.rockHit?.tagName,
moves: (c.rockHit?.getAttribute("d").match(/M/g) ?? []).length,
last: index === c.hits.length - 1,
picked: c.picks,
};
// ③ 고름 — 제목 · ▲▼↺ · ◀▶ 막음 · ▲▼ = 암만(종단으로 안 샘)
c.handle.setActive(index);
pick.setPickedLine("R1", index);
out.picked = {
...c.state(),
active: c.rockHit.classList.contains("is-active"),
leftBlocked: press("ArrowLeft"),
rightBlocked: press("ArrowRight"),
upTaken: press("ArrowUp"),
downTaken: press("ArrowDown"),
sideValues: { ...c.values },
key: pick.pickedLineAt("R1") === index,
};
out.picked.offsets = c.offsets;
// ④ 세로 끌기 — 위로 0.34 m(화면 px = m × 12) → 0.3 m 한 번 · 끄는 동안 점선 · 판정 선만 옮겨 보임
let stopped = false;
c.rockHit.listeners.pointerdown[0]({
button: 0,
clientX: 0,
clientY: 500,
preventDefault: () => undefined,
stopPropagation: () => (stopped = true),
});
fire("pointermove", { clientX: 0, clientY: 500 - 0.34 * 12 });
const during = { rock: c.rocks[0].styleSet, hit: c.rockHit.styleSet, value: c.state().value };
const before = c.offsets.length;
fire("pointerup", {});
out.drag = {
stopped,
during,
sent: c.offsets.slice(before),
clickSwallowed: (winListeners.click ?? []).length === 1,
};
// 풀면 다시 암 칸 · 방향키 안 먹음
c.handle.setActive(null);
out.released = { ...c.state(), upPassed: !press("ArrowUp") };
// 사면을 고르면 그 선 칸(제목 · ◀↺▶ · 값 한 줄)
const slope = c.handle.pieces.findIndex((p) => Math.max(p.from.offset, p.to.offset) > 3);
c.handle.setActive(slope);
out.slope = c.state();
pick.setPickedLine("R1", null);
}
// ⑤ 안 고른 끌기는 안 감(카드 팬) · 토사(암 없음) = 빈 칸 · 판정 선 없음
{
const c = card();
c.handle.setActive(null);
let stopped = false;
c.rockHit.listeners.pointerdown[0]({
button: 0,
clientY: 0,
preventDefault: () => undefined,
stopPropagation: () => (stopped = true),
});
out.idleDrag = { stopped };
const soil = card(false);
soil.handle.setActive(null);
out.soil = { ...soil.state(), rockHit: !!soil.rockHit };
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,120 @@
/* 371-2 관 글 기울기 헬퍼 — 짝 = `test_371_2_pipe_slope_text.py`.
* B07 픽스처 배관 측점에 `appendCulvertOverlay` 를 가짜 SVG 로 그려 관 글 · 툴팁을 읽음.
*/
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();
const noop = new Proxy({}, { get: () => () => "" });
/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */
class El {
constructor(tag) {
this.tag = tag;
this.attrs = {};
this.children = [];
this.parentNode = null;
this.ownerSVGElement = null;
this.textContent = "";
}
setAttribute(k, v) {
this.attrs[k] = String(v);
}
getAttribute(k) {
return this.attrs[k] ?? null;
}
appendChild(c) {
c.parentNode = this;
c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement;
this.children.push(c);
return c;
}
append(...cs) {
cs.forEach((c) => this.appendChild(c));
}
addEventListener() {}
all() {
return this.children.flatMap((c) => [c, ...c.all()]);
}
querySelectorAll(sel) {
return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1)));
}
querySelector(sel) {
return this.querySelectorAll(sel)[0] ?? null;
}
}
globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
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 resolve = (base) =>
fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`;
const req = (r) => {
if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop;
if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), r)));
};
new Function("exports", "module", "require", js)(box.exports, box, req);
cache.set(full, box.exports);
return box.exports;
}
const { computeCulvertLayout } = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Geom.ts"),
);
const { appendCulvertOverlay } = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert.ts"),
);
const sections = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
).cross_sections;
const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
/** 노폭 라벨이 있는 B06 카드 꼴 svg 에 그려 관 글 · 관 툴팁을 돌려줌. */
function draw(layout, hidePipe) {
const svg = new El("svg");
const road = new El("text");
road.setAttribute("class", "b06-chart__carriageway-label");
road.setAttribute("x", 200);
road.setAttribute("y", 180);
svg.appendChild(road);
const plot = new El("g");
svg.appendChild(plot);
appendCulvertOverlay(
plot,
layout,
(o) => o,
(z) => z,
undefined,
hidePipe,
false,
);
const titles = svg
.all()
.filter((e) => e.tag === "title")
.map((e) => e.textContent);
return {
infos: svg.querySelectorAll(".b06-chart__pipe-info").map((e) => e.textContent),
pipeTips: titles.filter((t) => t.startsWith("관매설")),
};
}
const layout = computeCulvertLayout(section, section.samples);
const reverse = { ...layout, pipe: { ...layout.pipe, slopePct: -0.43 } };
process.stdout.write(
JSON.stringify({
slopePct: layout.pipe.slopePct,
lengthM: layout.pipe.lengthM,
pipe: draw(layout, false),
reverse: draw(reverse, false),
hidden: draw(layout, true),
}),
);
@@ -1,6 +1,7 @@
/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 —
* `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다.
* 서버 묻기 · 그림 조각은 가짜로 채운다 — 「무슨 반폭을 묻고 · 무슨 반폭으로 선을 잡나」만 본다.
* 계산 묶음 · 그림 조각은 가짜로 채운다 — 「무슨 반폭으로 선을 잡나」만 본다(365-3 · 서버 길 없음 ·
* 묶음 그 측점 원지반 표본 = 좌우 20 m).
*/
const fs = require("fs");
const path = require("path");
@@ -56,7 +57,6 @@ globalThis.ResizeObserver = class {
disconnect() {}
};
const asked = [];
const STUBS = {
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} },
"../common_util/common_util_station": { stationLabel: (m) => String(m) },
@@ -65,17 +65,24 @@ const STUBS = {
CROSS_PLOT_PAD: 0,
summarizeCross: () => "",
},
// 서버 흉내 — 요청 반폭을 그대로 되돌려 주고, 없으면 기본 20.
"./B05_Profile_Api_Replan": {
fetchCrossPreview: async (_project, request) => {
asked.push(request.cross_half_width_m ?? null);
return {
chainage_m: request.chainage_m,
cross_half_width_m: request.cross_half_width_m ?? 20,
samples: [],
design: null,
};
},
// 계산 묶음 흉내 — 측점 하나(100 m) · 원지반 표본 좌우 20 m
"../A00_Common/design/design_pipeline_page": {
pagePipeline: () => ({
output: (name) =>
name === "ground" ? { stations: [{ key: "grid:5", chainageM: 100 }] } : null,
input: () => null,
subscribe: () => () => undefined,
}),
},
"../A00_Common/design/design_store": {
useDesignStore: () => ({ subscribe: () => () => undefined }),
},
"./logic/B05_Profile_Pipeline_Detail": {
stationSection: (station) => ({
chainage_m: station.chainageM,
label: null,
samples: [-20, 0, 20].map((offset_m) => ({ offset_m, elevation_m: 100, valid: true })),
}),
},
};
@@ -111,17 +118,17 @@ async function run() {
const out = {};
out.before = cross.watched();
await cross.open(100);
out.empty = { asked: asked.at(-1), watched: cross.watched() };
out.empty = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:3", "15");
await cross.refresh();
out.applied15 = { asked: asked.at(-1), watched: cross.watched() };
out.applied15 = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:12", "8.5");
session.setItem("aislo:draft:cross-display:p-2:99", "40");
await cross.refresh();
out.latestRoute = { asked: asked.at(-1), watched: cross.watched() };
out.latestRoute = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:12", "abc");
await cross.refresh();
out.badValue = { asked: asked.at(-1), watched: cross.watched() };
out.badValue = { watched: cross.watched() };
return out;
}
run().then((out) => console.log(JSON.stringify(out)));
@@ -125,7 +125,7 @@ const LOCALES = {
B06_Profile_Btn_Save: ["저장", "Save"],
B06_Profile_Btn_Confirm: ["확정", "Confirm"],
Design_Btn_ToCross: ["횡단 보기", "Cross view"],
Design_Btn_ToProfile: ["종단 보기", "Profile view"],
Design_Btn_ToProfile: ["노선 보기", "Route view"],
};
const STUBS = {
"@ui/ui_template_locale": { ui_locales: LOCALES, currentLanguageIndex: 0 },
@@ -138,10 +138,6 @@ def test_수치지형도_서피스_없으면_라이다만(tmp_path):
def test_허용_목록_밖_거절(bad):
with pytest.raises(ValidationError):
terrain_router.ElevationRequest(points=[(0.0, 0.0)], terrain=bad)
with pytest.raises(ValidationError):
terrain_router.CrossPreviewRequest(
vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad
)
with pytest.raises(ValidationError):
replan.RouteInitialRequest(terrain=bad)
@@ -167,8 +163,8 @@ def test_화면_고르기_배선():
assert '<option value="sheet">수치지형도</option>' in chrome
assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main
assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api
# 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 2
# 모달 지반고가 고른 지형을 실어 보냄 · 횡단 미리보기 = 묶음(365-3) · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 1
assert "terrain: analysisTerrain(projectId)" in initial_api
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
assert "terrain" not in replan_fn
+3 -2
View File
@@ -100,8 +100,9 @@ def test_카드_배선_하나만() -> None:
assert "if (activeRevet === null) revetOffset?.select(section.chainage_m, null);" in view
assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적
assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림
assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽
assert "chartWrap.append(linePick.panel);" in view # 사면 이동 패널(151-3)
# 치수 · 이동 = 아래 띠 왼쪽 공용 칸(370-3 · 옛 [+ − ⤢] 왼쪽 치수 · 떠 있는 이동 패널 걷음)
assert "linePick.label" not in view and "linePick.panel" not in view
assert "appendCardFooter(card, section, rockBoundary, cutSlope, linePick?.cell);" in view
css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css")
assert ".b06-chart__design-hit.is-active {" in css
+5 -2
View File
@@ -54,11 +54,14 @@ def test_3도_선이_원지반을_못_만나면_복합경사(out: dict) -> None:
assert len(study["cut"]) == 1
tail = study["cut"][0]
assert tail["rises"] is True and tail["grade"] == -1 and tail["endGap"] == 0
assert study["cutArea"] > study["cutAreaWithout"]
# 371-1 — 벽 자리 0.35 m 도로 쪽 · 오르는 선 0.3 m 라 면적 차가 백엔드 설계선 끝에 묻힘 → 폐회로에 듦만 봄
assert study["inTrim"] is True
def test_성토_끝은_그대로(out: dict) -> None:
assert out["pipeBase"] == ["fill", "fill"] # 묻히지 않은 기본 = 성토부선 그대로
# 371-1 — 다단 사이 시작 = 윗단 발끝 · 단이 0.5 m 내려가 기본 자료 끝 벽이 0.3 m 묻힘 → 3도 선
assert out["pipeBase"] == ["fill", "cut"]
assert out["pipeOpen"]["cut"] == [] # 묻히지 않은 끝 = 성토부선 그대로
def test_유출_절토선은_그리고_면적에_넣음() -> None:
+5 -6
View File
@@ -33,18 +33,17 @@ def test_화면_방향대로_그_쪽만_단추와_방향키(out: dict) -> None:
assert right == {"left": 0, "right": 0.1} # 우 사면 · ← = 안쪽 0 · ▶ = 바깥 0.1
def test_치수_줄과_패널에_이동_표기(out: dict) -> None:
def test_공용_칸_값에_이동_표기(out: dict) -> None:
"""370-3 — 치수 · 이동은 아래 띠 공용 칸 값 한 줄(옛 우측 위 치수 · 패널 읽기 줄)."""
assert "이동 +0.1m" in out["text"] and "노견 +" not in out["text"]
assert out["readout"] == "이동 0.1m"
assert out["text"].startswith("길이 ") and "\n" not in out["text"]
assert out["titleLeft"] == "사면 이동 (좌)"
def test_단추는_패널로_옮김_재설정_닫기(out: dict) -> None:
"""151-3 — 치수 칸 [◀ ▶] 지움 · 패널 ◀ ↺ ▶ · ↺ = 그 쪽 0 · ✕ = 고름 풂 · 숨으면 방향키 안 먹음."""
assert out["labelButtons"] == 0
def test_단추는_공용_칸_재설정(out: dict) -> None:
assert out["slots"] == ["◀", "↺", "▶"]
assert out["afterReset"] == {"left": 0, "right": 0}
assert out["picks"] == [out["pieceRight"]]
assert out["picks"] == [] # 칸 누름은 고름으로 안 번짐
assert out["hiddenAtStart"] is True and out["hiddenAfter"] is True
assert out["afterHidden"] == {"left": 0, "right": 0}
+4 -3
View File
@@ -40,10 +40,11 @@ def out(tmp_path_factory) -> dict:
def test_안_넓힌_측점은_종전_그대로(out: dict) -> None:
"""225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8)."""
"""225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8).
371-1 — 기본 자리 = 성토 위 이음(끝에서 0.1 m) 기준 · 벽 0.35 m 도로 쪽(5.772 → 5.422)."""
base = out["base"]
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8)
assert base["cut"] == pytest.approx(5.772)
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.4)
assert base["cut"] == pytest.approx(5.422)
@pytest.mark.parametrize("name, extend", [("shifted", 1.0), ("shifted2", 1.3)])
+1 -1
View File
@@ -107,7 +107,7 @@ def test_조합_줄_뒤에_옵션_줄_미지정도_옵션_줄():
"group_items": None,
"option_lines": ["옹벽: 높이 2.5m"],
}
assert structure_note_lines([thing], 40.0) == ["옹벽 조합 미지정", "옹벽: 높이 2.5m"]
assert structure_note_lines([thing], 40.0) == ["옹벽", "옹벽: 높이 2.5m"]
def test_화면은_구조물마다_옵션_줄을_붙임():
+12 -3
View File
@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험)."""
"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험).
370-3 — 카드 치수는 아래 띠 공용 칸으로 옮김 · 치수 칸 꼴은 상세 모달(`_Detail_Select`)이 씀.
"""
import os
import re
@@ -22,9 +25,15 @@ def test_좁은_카드_내림_규칙_없음():
]
def test_치수_칸은_줌_막대_맨_앞():
def test_카드_치수는_아래_띠_공용_칸():
"""370-3 — 카드의 고른 선 치수는 줌 막대 앞 라벨을 걷고 아래 띠 왼쪽 공용 칸 값 한 줄로(상세 모달 치수는 그대로)."""
view = _src("B06_Section/B06_Section_UI_Cross_View.ts")
assert "zoomBar.prepend(linePick.label);" in view
assert "linePick.label" not in view and "zoomBar.prepend(" not in view
assert "linePick?.cell" in view
assert "b06-cross-card__line-dims" not in _src(
"B06_Section/B06_Section_UI_Cross_Design_Pick.ts"
)
assert "b06-cross-card__line-dims" in _src("B06_Section/B06_Section_UI_Cross_Detail_Select.ts")
zoom = (
_src("B06_Section/B06_Section_UI_Style_Cross_Controls.css")
.split(".b06-cross-card__zoom {", 1)[1]
+4 -2
View File
@@ -61,10 +61,12 @@ def test_묻힌_유출_벽_설계선이_안_접힘(out: dict, case: str) -> None
def test_저장값_사본_면적(out: dict) -> None:
"""접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만."""
assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3)
# 371-1 — 성토 위 이음 = 윗면 끝에서 0.1 m · 기본 자리 새 이음 기준(34.0391 → 34.2539)
assert out["stored"]["cut"] == pytest.approx(34.2539, abs=1e-3)
assert out["stored"]["fill"] == 0
# 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929)
assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3)
# 371-1 — 기본 자리 = 새 이음 기준(16.0929 → 16.2613)
assert out["auto"]["cut"] == pytest.approx(16.2613, abs=1e-3)
def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None:
+3 -2
View File
@@ -298,7 +298,8 @@ def test_이동_셋_분리(ui: dict) -> None:
@node_only
def test_소단_고름_위아래만(ui: dict) -> None:
berm = ui["berm"]
assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m"
assert berm["title"] == "소단 높이 (좌)"
assert berm["readout"].startswith("도로 아래 1.0m · 길이 ") # 값 한 줄(370-3 공용 칸)
assert berm["moveHidden"] is True and berm["liftHidden"] is False
assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘
assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감
@@ -332,7 +333,7 @@ def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None:
assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3}
assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음
assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시
assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음
assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 없음
selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text(
encoding="utf-8"
)
@@ -115,6 +115,38 @@ def test_Backspace_와_Esc_취소(out):
assert out["enterOutsideDrawing"] is False
def test_자유지정_되돌리기_다시하기(out):
# 367-3 ⑥ 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · 큰 이력(apply · close) 안 건드림
assert out["undoEmpty"] == {"can": False, "backspace": False}
assert out["undo1"] == {"dots": [40, 80], "canRedo": True}
assert out["redo1"] == {"dots": [40, 80, 120], "canRedo": False}
assert out["newClick"] == {"dots": [40, 80, 100], "canRedo": False}
# 한 걸음 = 점 · 시작 · 범위 끝 · 범위 첫 · 같은 세로선 다시 누름은 안 쌓임
assert out["undoAll"] == ["drawing:2", "drawing:2", "armed:2", "range:1", "range:0"]
assert out["undoApplied"] == 0 and out["undoClosed"] == 0
assert out["redoTwice"] == {"phase": "armed", "band": [40, 160]}
assert out["resetClears"] == {"undo": False, "redo": False}
def test_적용_막히면_열린_채(out):
# 367-3 ⑦ Enter · 끝 세로선 둘 다 apply 시도(2) · close 없음 · 점 그대로
assert out["blocked"] == {"tries": 2, "phase": "drawing", "dots": 2, "canUndo": True}
def test_되돌리기_배선_자유지정_먼저():
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()' in panel
assert 'tools.mode() === "free" ? free.canUndo() : inner.canUndo()' in panel
keys = panel[panel.index('isCtrlKey(event, "z") || isCtrlKey(event, "y")') :]
free_at = keys.index('if (tools.mode() === "free") {')
assert (
free_at < keys.index("history.undo()")
and "return;" in keys[free_at : keys.index("flushHold();")]
)
profile = _read("A00_Common/design/design_profile.ts")
assert "function applyEdits(next: AlignmentEdits): boolean {" in profile
def test_도구줄_직선화_오른쪽_자유지정():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Tools.ts")
assert '"자유지정"' in tools
@@ -142,6 +174,28 @@ def test_오버레이_점선_빨강_글():
assert ".b05-profile-freemark__line.is-over" in css
def _css_rule(css: str, selector: str) -> str:
at = css.index(selector + " {")
return css[at : css.index("}", at)]
def test_자유지정_녹색_넘음_빨강_띠_보라():
# 367-4 선 · 미리 보기 · 점 = --color-success · 넘음 빨강 · 범위 띠 · 경계 보라 그대로
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
assert "stroke: var(--color-success);" in _css_rule(css, ".b05-profile-freemark__line")
assert "stroke: var(--color-success);" in _css_rule(css, ".b05-profile-freemark__dot")
assert "var(--color-danger" in _css_rule(css, ".b05-profile-freemark__line.is-over")
assert "var(--color-danger" in _css_rule(css, ".b05-profile-freemark__grade.is-over")
assert "--color-primary" in _css_rule(css, ".b05-profile-freemark__band")
assert "--color-primary" in _css_rule(css, ".b05-profile-freemark__edge")
# 밝음 · 어두움(명시 · OS 자동) 모두 success · danger 토큰 있음
theme = _read("ui_template/ui_template_theme.css")
auto = theme[theme.index("@media (prefers-color-scheme: dark)") :]
auto = auto[: auto.index("\n}\n")]
for block in (_css_rule(theme, ":root"), _css_rule(theme, ':root[data-theme="dark"]'), auto):
assert "--color-success:" in block and "--color-danger:" in block
def test_줄_수_늘리지_않음():
for relative in (
"A00_Common/design/design_profile.ts",
+20
View File
@@ -162,6 +162,26 @@ def test_바뀌면_0_5초_뒤_다시_뜸_끄면_복제_지움(r):
assert h["hidden"] and h["disconnected"] and h["scrollListen"] is False
assert h["noRecaptureAfterHide"] == 0
assert h["reuseRoot"] == 2 # 원 · 배율 글 하나씩을 다시 씀
assert h["bodyUntouched"] == 6 # 원 · 글은 html 바로 아래 — body 복제 · 관찰에 안 걸림(374-1)
def test_움직이는_동안_안_뜸_멈춘_뒤_한_번(r):
# 374-1 디바운스 — 바뀜 뒤 계속 움직이면 안 뜸 · 마지막 움직임 0.5 초 뒤 한 번
assert r["debounce"] == {"heldWhileMoving": True, "afterStop": True}
# 이동 = 바깥 판 translate 만(합성층) · 배율 = 안쪽 상자
assert r["capture"]["stageMoveOnly"] == "translate(-820px, -620px)"
css = _read("ui_template/ui_template_loupe.css")
stage = css[css.index(".ui-loupe__stage {") :]
assert "will-change: transform;" in stage[: stage.index("}")]
scale = css[css.index(".ui-loupe__scale {") :]
assert "transform-origin: 0 0;" in scale[: scale.index("}")]
def test_뜨는_자리_화면_전체_모달_포함():
src = _read("ui_template/ui_template_loupe.ts")
assert "sourceOf: () => Element | null = () => win.document.body," in src
assert "doc.documentElement.append(root, label);" in src
assert 'getElementById("app")' not in src
def test_테두리_그림자_토큰():
@@ -11,7 +11,6 @@
import json
import os
import re
import subprocess
from pathlib import Path
@@ -65,7 +64,5 @@ def test_끌기도_유토_판_안_셈():
def test_뗀_유토는_노선_전체_세로():
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
assert re.search(
r'viewRange: overlay\.classList\.contains\("is-popout"\) \? undefined : params\.axis\.viewRange',
mass,
)
# 367-1 붙음 · 뗌 모두 노선 전체 세로
assert "viewRange: undefined," in mass
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창.
- 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 단추 = 두 판 머리줄 맨 앞(유토는 펼친 때만 보임 · 367-2) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐).
- 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome.
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝.
@@ -359,30 +359,3 @@ def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> Non
with pytest.raises(RuntimeError):
sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False)
assert seen["station_snap"] is True
def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Terrain as terrain
from common_util.common_util_route_polyline import build_planned_polyline
monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat())
built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False)
points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64))
spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves])
assert len(spans) == 1
target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5)
assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음
def ask(snap: bool) -> float:
request = terrain.CrossPreviewRequest(
vertices=[{"x": x, "y": y} for x, y in _NODES],
chainage_m=target,
min_radius_m=40.0,
station_interval_m=20.0,
station_snap=snap,
)
params = {"source_filter": "f", "method": "m", "smooth": False}
return terrain._cross_preview(tmp_path, params, request)["chainage_m"]
assert ask(True) == target
assert ask(False) % 20 == 0
+2 -1
View File
@@ -72,7 +72,8 @@ def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None:
def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None:
"""유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음."""
lengths = [case["lengthM"] for case in out["moved"]]
assert lengths == [7.9, 8.0, 8.1, 8.2]
# 371-1 — 기본 자리 = 성토 위 이음 기준 · 벽 0.35 m 도로 쪽(7.9 → 7.5)
assert lengths == [7.5, 7.6, 7.7, 7.8]
for case in out["moved"]:
assert case["lengthM"] == round(case["cut"], 1)
@@ -264,8 +264,10 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동():
assert "캡처는 localhost" not in text, name
state = read("B08_Quantity_UI_Milestone_State.ts")
# 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림
assert 'if (image && seg.key === "route" && paths.has(from))' in state
assert "void autoRouteImage(projectId, from, routePaths(from));" in state
assert (
'if (image && seg.key === "route" && path) void autoRouteImage(projectId, from, [path]);'
in state
)
assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state
assert "user_only=true" in state and "showToast(reason," in state
route = read("B08_Quantity_UI_Milestone_Route.ts")
@@ -245,8 +245,8 @@ const writes = WRITES.n;
await findSegmentKm("p1", "b1"); // 같은 km = 캐시 안 씀
const live = liveMilestone("p1");
console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n,
route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("g"),
routeLines: routePaths("g"), baseLines: routePaths("b1"),
route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("p1", "g"),
routeLines: [segmentPath("p1", "g")], baseLines: [segmentPath("p1", "b1")],
start: await findSegmentKm("p1", "start") }}));"""
got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS)
assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km"
@@ -268,6 +268,8 @@ console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, a
assert got["routeLines"] == [got["path"]] and got["baseLines"] == [
got["path"]
] # 구간마다 길 선
assert got["route"][0]["path"] == [[35.68, 127.9], [35.66, 127.85]] # 373 길 선 = 구간 자료에
assert got["base"][0]["path"] == got["path"]
assert got["start"] == "" # 시점 → 중점 = 길찾기 없음
@@ -302,7 +304,7 @@ def test_화면_등록_모달_연결선_모달():
assert "길찾기" not in mid.replace("길찾기 없음", "") and "editMap" not in mid
assert 'if (from === "start") return openMidLeg(projectId);' in leg
assert "void reverseAddress(projectId, lon, lat)" in leg and "void run();" in leg
assert "if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to)" in leg
assert "!manual.input.checked && !segmentPath(projectId, from).length" in leg
for rel in (B08 + "UI_Milestone_Modal.ts", B08 + "UI_Milestone_Place.ts",
B08 + "UI_Milestone_LegModal.ts", KMAP, STATE, LAYOUT, CALC,
B08 + "UI_Milestone.ts", B08 + "UI_Milestone_Diagram.ts",
@@ -11,8 +11,10 @@ import subprocess
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
@@ -93,12 +95,15 @@ def test_깨진_좌표_km_그대로_길_선만_없음(monkeypatch):
def test_좌표_저장_안_함(tmp_path):
route = {"id": "r1", "name": "거창군청", "legs": [{"road": None, "auto_km": 10.5}]}
route["path"] = [[35.6866, 127.9095], [35.66, 127.85]] # 화면이 잘못 실어 보내도
route["path"] = [
[35.6866, 127.9095],
[35.66, 127.85],
] # 화면이 잘못 실어 보내도(거래처 칸 = 안 받음)
ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [route]}))
files = [p for p in tmp_path.rglob("*") if p.is_file()]
assert [p.name for p in files] == ["milestone.json"]
text = files[0].read_text(encoding="utf-8")
assert '"path"' not in text and "35.6866" not in text and '"auto_km": 10.5' in text
assert '"path": null' in text and "35.6866" not in text and '"auto_km": 10.5' in text
def test_JS_키_길(monkeypatch):
@@ -229,10 +234,34 @@ def test_화면_자리_캡처_키_서버만():
assert "routeMapView" not in route and "다음 지도에서 길찾기" not in route
assert "routeMapView" not in kmap
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감)
assert "putFound(projectId, from, res.km, path);" in state # 373 구간 자료(캐시 → [저장])
for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만
text = rel.read_text(encoding="utf-8")
assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel
for rel in (KMAP, "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", LAYOUT,
"B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip
assert len(_read(rel).splitlines()) <= 700
def test_373_구간_길_선_저장_소수_5자리_점_줄임(tmp_path):
line = [[35.6866123, 127.9095456], [35.686612, 127.909546], [35.66, 127.85]]
leg = {"road": None, "auto_km": 10.5, "path": line}
ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [{"id": "r1", "legs": [leg]}]}))
saved = ms.read_data(tmp_path)["routes"][0]["legs"][0]["path"]
assert [list(p) for p in saved] == [
[35.68661, 127.90955],
[35.68661, 127.90955],
[35.66, 127.85],
]
with pytest.raises(ValidationError):
ms.Milestone.model_validate({"routes": [{"id": "r1", "legs": [{"path": [[95.0, 127.0]]}]}]})
with pytest.raises(ValidationError): # 상한
ms.Milestone.model_validate(
{"routes": [{"id": "r1", "legs": [{"path": [[35.0, 127.0]] * (ms.PATH_MAX + 1)}]}]}
)
# 길찾기 응답 줄이기 — 같은 점 이어짐 뺌 · 상한 안 · 두 끝 유지
thin = ms.thin_path([[35.0, 127.0], [35.0, 127.0], [35.00001, 127.0]])
assert thin == [[35.0, 127.0], [35.00001, 127.0]]
wavy = [[35 + i * 1e-4, 127 + (i % 2) * 1e-5] for i in range(2000)]
small = ms.thin_path(wavy)
assert len(small) <= ms.PATH_MAX and small[0] == [35.0, 127.0] and small[-1][0] == wavy[-1][0]
@@ -113,8 +113,8 @@ def test_연결_점_왼쪽_위_안쪽_기준점도_돌기_취소():
ui = _read(B08 + "UI_Milestone_Diagram.ts")
dot = ui[ui.index("const connectDot") :]
dot = dot[: dot.index("return dot;")]
# 왼쪽 위 안쪽 — 가운데 (10, 10) · 반지름 6 + 테두리 2 = 상자 안
assert "const cx = box.x + 10;" in dot and "const cy = box.y + 10;" in dot and "r: 6" in dot
# 왼쪽 위 안쪽 — 가운데 (12, 12) · 반지름 9 + 테두리 2 = 상자 안(373)
assert "const cx = box.x + 12;" in dot and "const cy = box.y + 12;" in dot and "r: 9" in dot
assert (
'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui
)
@@ -113,7 +113,9 @@ def test_경로_탭_끌기_다시_길찾기():
assert "fresh.site.start_pin = { lon, lat };" in route
assert "await refind(touchingSegments(fresh, key));" in route
assert "findSegmentKm(projectId, id, false)" in route # 끌기 · 열기 = 그림 캡처 없음
assert "[routeId, ...chain].filter((id) => !segmentPath(id).length)" in route # 열 때 모든 구간
assert (
"[routeId, ...chain].filter((id) => !segmentPath(projectId, id).length)" in route
) # 열 때 모든 구간
assert "status.hidden = !bad.length;" in route # 실패 안내
calc = _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts")
assert "start_pin?: { lon: number; lat: number } | null;" in calc
+15 -3
View File
@@ -124,12 +124,12 @@ _LINES = [
"기슭막이 메쌓기 1.2m 기초무",
"돌붙임 찰붙임",
"옹벽 콘크리트 H=2.0",
"돌쌓기(찰) 조합 미지정",
"가드레일 조합 미지정",
"돌쌓기(찰)",
"가드레일",
]
def test_구조물_여럿_글은_조합_이름_미지정_배관_본체_뺌_TS_파이썬_같음():
def test_구조물_여럿_글은_조합_이름_미지정은_종류_이름만_배관_본체_뺌_TS_파이썬_같음():
culvert = {**_culvert_set(_PIPE), "chainage_m": 40.2}
cases = [
_case(_THINGS, 40.0, culvert, {"pipe_length_m": 8.5}),
@@ -229,3 +229,15 @@ def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, lab
py = note_entities(40.0, culvert, design, "NO.2", [])
assert _labels(ts["entities"])[2] == label
assert _labels(py)[2] == label
def test_미지정_같은_종류는_한_줄_조합_있는_같은_이름은_따로_TS_파이썬_같음():
things = [
{"name": "옹벽", "pipe": False, "at": 40.0},
{"name": "옹벽", "pipe": False, "at": 40.1},
{"name": "돌쌓기", "pipe": False, "at": 40.0, "summary_item": _B},
{"name": "돌쌓기", "pipe": False, "at": 40.0},
]
[ts] = _helper("mirror", [_case(things, 40.0, None, {})])
lines = structure_note_lines(things, 40.0)
assert ts["lines"] == lines == ["옹벽", "돌붙임 찰붙임", "돌쌓기"]
+26 -8
View File
@@ -1,10 +1,12 @@
"""288 맹암거 횡단 형상(요구 183-3 · 9).
못박는 것
- 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 암거 경사로 지대 낮은 쪽 한 경사(물넘이 286 · 287 길 —
서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted`) · 제원 얹기 서버 `attach_underdrains` ↔ TS
`attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과
만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림.
- 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 표준 경사로 지대 낮은 쪽 한 경사
(물넘이 286 · 287 길 — 서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted` · 암거 경사는
노면에 안 감 371-3) · 제원 얹기 서버 `attach_underdrains` ↔ TS `attachUnderdrainSpecs`
같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과 만남 ·
절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림.
- 371-3 암거 경사 = 형상 하단 경계 — 높은 쪽 노면 끝 − 높이에서 암거 경사로 낮은 쪽으로 내림.
- 288-2 맹암거 형상 누름 = 그 구조물 id 고름(구조물 목록 창구 · 알약 · 폼 · 측점선 따라옴) · 물넘이와 같은
판정면(보라 손잡이 아래 · 끌기 = 고름 아님 · 287-5) · 노면공 포장 범위 고름과 따로.
@@ -86,8 +88,9 @@ def ts(tmp_path_factory) -> dict:
def test_맹암거_자리_기울기_서버(project):
ranges = tilt.tilt_ranges(project)
assert ranges == [(40.0, 40.0, 4.0), (80.0, 80.0, None)]
assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": 4.0}
# 371-3 — 암거 경사(4 %)는 노면에 안 감 · 노면 = 표준 경사
assert ranges == [(40.0, 40.0, None), (80.0, 80.0, None)]
assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": None}
assert tilt.tilt_args(80.01, ranges) == {"tilted": True, "tilt_pct": None}
assert tilt.tilt_args(60.0, ranges) == {"tilted": False, "tilt_pct": None}
@@ -110,8 +113,9 @@ def test_맹암거_측점_설계_측구_없음_낮은_쪽_기울기():
for o in range(-24, 25)
]
kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"}
got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=4.0, **kwargs)
assert got["ditch_enabled"] is False and got["cross_slope_pct"] == 4.0
got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=None, **kwargs)
standard = compute_cross_design(samples, 100.4, **kwargs)["cross_slope_pct"]
assert got["ditch_enabled"] is False and got["cross_slope_pct"] == standard
left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right"))
assert left > right # 지반이 좌로 오름 = 우가 낮음
@@ -131,6 +135,20 @@ def test_형상_옵셋선_성토_절토와_만남(ts):
assert ts["drew"] is True and ts["stones"] > 0
def test_하단_경계는_암거_경사(ts):
"""371-3 — 노면 5 % · 암거 3 % · 높이 1 m: 하단 = 좌(높은 쪽) 끝 99.15 에서 3 % 로 우로 내림."""
outline = [(p["offset"], p["elevation"]) for p in ts["sloped"]]
expected = [
(3, 100.15),
(-3, 99.85),
(-4.391304, 98.928261), # 우 성토 사면 ∩ 하단(99.15 − 0.03 × 7.391)
(1.872180, 99.116165), # 좌 절토 사면을 늘인 선 ∩ 하단
]
assert len(outline) == len(expected)
for got, want in zip(outline, expected):
assert got == pytest.approx(want, abs=1e-5)
def test_양측절토_늘인_노면_원지반_교점에서_끊음(ts):
"""356-1 — 낮은 쪽 노면 끝도 절토(tilt_reach) = reach 에서 수직으로 끊음 · 너머 원지반 따라가지 않음."""
outline = [(p["offset"], p["elevation"]) for p in ts["bothCut"]]
+1 -1
View File
@@ -104,7 +104,7 @@ def test_B06_초안_쓰면_판이_한_번_다시_나눔():
def test_다시_나눔은_세션_초안으로_계산만_저장_안_함():
load = _body(PANEL, "const loadSaved = ", "const resetPipes")
load = _body(PANEL, "const loadSaved = ", "const analyze = ")
assert "readPendingPipes(projectId as string)" in load
assert "computeDetailBasins(projectId as string, pending)" in load
analyze = _body(PANEL, "const analyze = ", "analyzeButton.addEventListener")
+2 -2
View File
@@ -148,7 +148,7 @@ def test_칸_없음_파일_없음_DELETE_노선_바뀜은_자동_배치(api) ->
def test_빈_목록과_None_을_가르는_자리() -> None:
src = _read("common_util/common_util_drainage_detail.py")
assert "if confirmed_chainages is not None:" in src
assert 'if confirmed_chainages is not None and auto != "all":' in src
router = _read("B04_PreProcess/B04_PreProcess_Router_Basins.py")
assert "points = requested if requested is not None else stored" in router
assert "requested or stored" not in router
@@ -161,7 +161,7 @@ def _body(rel: str, start: str, end: str) -> str:
def test_화면_초안_빈_목록은_저장분으로_안_넘어감() -> None:
load = _body(
"B05_Profile/B05_Profile_UI_Drainage_Panel.ts", "const loadSaved = ", "const resetPipes"
"B05_Profile/B05_Profile_UI_Drainage_Panel.ts", "const loadSaved = ", "const analyze = "
)
assert "pending !== null" in load and "pending?.length" not in load
# B06 목록도 같은 규칙(`??`)
+5
View File
@@ -95,6 +95,11 @@ def test_footer_without_center_text(result: dict) -> None:
assert footer["rock_first"].split()[-1] == "b06-cross-card__rockb" # 암 경계 = 왼쪽 첫 칸
assert footer["rock_classes"][-1].split()[-1] == "b06-cross-card__cutslope"
assert footer["soil_exists"] and footer["soil_children"] == 0 # 토사도 띠는 남음(빈 띠)
# 370-3 — 선 공용 칸 = 왼쪽 첫 칸(암 꼴 클래스) · 암 절토 경사 = 오른쪽 그대로
assert footer["cell_classes"] == [
"linecell b06-cross-card__rockb",
"cutslope b06-cross-card__cutslope",
]
def test_center_wired_into_card_and_modal() -> None:
+1 -1
View File
@@ -24,7 +24,7 @@ def setup(tmp_path, monkeypatch):
basins._DETAIL_CACHE.clear()
calls = SimpleNamespace(count=0)
def build(stored_path, context, points):
def build(stored_path, context, points, auto=None):
calls.count += 1
chainages = [p.chainage_m for p in points] if points else [50.0]
return DrainageDetail(), [PipePoint(chainage_m=c, source="stream") for c in chainages]
@@ -52,13 +52,13 @@ def _patch(monkeypatch, analysis_signature, stored):
monkeypatch.setattr(basins, "load_pipe_points", lambda *args: stored)
built = SimpleNamespace(called=False)
def build(stored_path, context, points):
def build(stored_path, context, points, auto=None):
built.called = True
return SimpleNamespace(pipes=[], basins=["유역"]), points or []
monkeypatch.setattr(basins, "_build", build)
monkeypatch.setattr(
basins, "_build_cached", lambda context, points: build("x", context, points)
basins, "_build_cached", lambda context, points, auto=None: build("x", context, points)
)
return built
+3 -1
View File
@@ -48,7 +48,9 @@ def test_Ctrl_Z_는_사진첩_한_줄(out):
def test_종단도_Ctrl_Z_줄이_사진첩_하나():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
at = tools.index('if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {')
assert "undoOwner" not in tools and "history.undo()" in tools[at : at + 240]
assert (
"undoOwner" not in tools and "history.undo()" in tools[at : at + 600]
) # 367-3 자유지정 분기 앞에 섬
history = _read("B05_Profile/B05_Profile_UI_RouteEdit_History.ts")
assert '"keydown"' not in history and "onPageHistory" in history
@@ -46,9 +46,9 @@ def test_적용은_본문_평면에_되돌리기_한_칸():
assert "apply: (snapshot, message) => restoreSnapshot(snapshot, message, true)," in main
restore = main[main.index("function restoreSnapshot(") :]
restore = restore[: restore.index("\n }\n")]
assert "applyEdit(message, record);" in restore
assert "applyEdit(message, record, false);" in restore # 366-3 — 추천 적용은 측점 맞춤 안 함
apply_edit = main[main.index("function applyEdit(") :]
assert "if (record) history?.commit(snapshotNow());" in apply_edit[:600]
assert "if (record) history?.commit(snapshotNow());" in apply_edit[:900]
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
on_apply = initial[initial.index("onApply: (picked) => {") :]
assert on_apply[:500].count("params.apply(") == 1
@@ -125,16 +125,14 @@ def test_판은_덮이기만_따라_안_옮김() -> None:
assert ".b05-route-profile.is-collapsed {\n height: 0;" in style
def test_처음_열기와_맞춤_단추_같은_셈() -> None:
def test_처음_열기_맞춤_그대로_맞춤_단추_없음() -> None:
"""366-2 — [맞춤] 단추는 지움 · 처음 열 때 등고 영역 맞춤(fitView)은 그대로."""
view = (B05 / "B05_Profile_UI_RouteEdit_View.ts").read_text(encoding="utf-8")
assert "return { view: fitView(meta, view, nodes, contours), rotationRad: 0 };" in view
assert "unionArea([route, ...(contours?.features ?? [])])" in view
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
assert 'data-act="fit"' in chrome and ">맞춤</button>" in chrome
assert 'data-act="fit"' not in chrome and ">맞춤</button>" not in chrome
editor = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "openingView(projectId, meta, view, planned, contours?.layer ?? null)" in editor
at = editor.index("""overlay.querySelector('[data-act="fit"]')!""")
handler = editor[at : at + 260]
assert "view = fitView(meta, view, planned, contours?.layer ?? null);" in handler
assert "rotation.set(0);" in handler
assert 'data-act="fit"' not in editor and "fitView" not in editor
assert len(editor.splitlines()) <= 700
@@ -38,20 +38,20 @@ def out():
def test_노선_초안_동안_카드는_새_노선_상세(out):
cards = out["cards"]
assert cards["before"] == "saved"
assert cards["afterDraft"] == ["draft-profile-1", "draft-cross-1"]
assert cards["afterDraft"] == ["draft-cross-1"] # profile 차례 중간 상세는 안 그림(365-4)
assert cards["draftNow"] == "draft-cross-1"
def test_숨은_동안은_안_그림(out):
cards = out["cards"]
assert cards["whileHidden"] == ["draft-profile-1", "draft-cross-1"]
assert cards["whileHidden"] == ["draft-cross-1"]
assert cards["hiddenNow"] == "draft-cross-2"
def test_초안_버리면_저장_노선_카드(out):
cards = out["cards"]
assert cards["discarded"][-1] == "saved" and cards["savedNow"] == "saved"
assert cards["afterStop"] == 3 # 끊은 뒤엔 안 그림
assert cards["afterStop"] == 2 # 끊은 뒤엔 안 그림
def test_알약은_따라간_누가거리(out):
+8 -29
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 오목 보충 · 이월 열쇠 ·
"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 보충 없음(369) · 이월 열쇠 ·
초기값 규칙 ·
배수유역 낡음 · 실패 때 옛 노선 되살림.
@@ -41,14 +41,11 @@ 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,
@@ -60,7 +57,7 @@ OLD = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0), (400.0, 0.0)]
NEW = [(0.0, 0.0), (100.0, 0.0), (200.0, 60.0), (300.0, 0.0), (400.0, 0.0)]
# ── 관 따라감 · 교차점 · 오목 보충 ─────────────────────────────────────────────
# ── 관 따라감 · 교차점 ─────────────────────────────────────────────
def test_관은_지우지_않고_좌표를_따라감():
@@ -91,29 +88,11 @@ def test_새_교차점에만_기본_관():
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_노선_저장은_간격_보충_없음():
"""369 — 오목 · 100 m 간격 보충은 배수유역 [자동 제안]만 · 노선 저장 부품에 없음."""
import B05_Profile.B05_Profile_Route_Save_Parts as parts
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
assert not hasattr(parts, "fill_concave_pipes") and not hasattr(save, "_ground_along")
# ── 측점 열쇠 · 횡단 이월 ──────────────────────────────────────────────────────
@@ -366,7 +345,6 @@ def chain_world(monkeypatch, tmp_path):
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"))
@@ -416,7 +394,8 @@ def test_차례와_배수유역_뗌(chain_world):
"confirm",
]
assert result["cross_dropped"] == 2 and result["drainage_stale"] is True
assert result["pipes"] == {"kept": 1, "crossing": 0, "concave": 0}
# 456 m 노선 · 관 하나 — 100 m 간격 보충 없음(369)
assert result["pipes"] == {"kept": 1, "crossing": 0}
# 관 정본 = 새 노선 지문 · 배수유역 분석 지문(옛) 과 달라 B04 가 「낡음」
saved = json.loads(chain_world.pipes_file.read_text("utf-8"))
assert saved["route_signature"] == route_signature(vertices_of(NEW))
@@ -67,7 +67,7 @@ def test_저장_뒤_가게_초안_비움_결과_한_줄(out):
assert out["storeAfterSave"] is None
assert out["keptAfterSave"] == 1
assert out["summary"] == (
"노선 저장 — 관 지킴 3 · 새 교차 관 1 · 오목 보충 관 2 · 구조물 옮김 1 · 멀어짐 0 · "
"노선 저장 — 관 지킴 3 · 새 교차 관 1 · 구조물 옮김 1 · 멀어짐 0 · "
"노선 밖 고침 3 건 · 배수유역 낡음"
) # 서버 cross_dropped 1 + 화면 넘김 밖 2 = 한 줄
@@ -51,8 +51,7 @@ def test_suggestions_collected(r):
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 sorted(applied) == [100, 300, 500] # 관 자리 후보(200)는 안 더함 — [자동 제안](369)
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" # 이미 그 종류 — 그대로
+4
View File
@@ -95,3 +95,7 @@ def test_screen_reconcile_carries_id():
assert result["moved"][1]["reason"] == "confirmed"
assert [p.get("id") for p in result["added"]] == ["a1", "b2", None]
assert [p.get("id") for p in result["twoMoved"]] == [None, None]
adopt = result["adopt"]
assert [p["id"] for p in adopt["next"]] == ["a1", "b2"] # 369 되돌리기 — 그 걸음 초안 id
assert adopt["draft"][0]["facility"] == "ford_bridge"
assert result["adoptOther"] is None and result["adoptNone"] is None
@@ -221,6 +221,9 @@ def test_포장_기울임_하강_판정_서버와_같음(r, server):
hits["tilted"] += py["tilted"]
hits["drop"] += py["drop"] > 0
assert all(hits.values()), hits # 셋 다 실제로 걸림
# 371-3 — 맹암거 자리 = 기울임 · 경사 비움(노면 표준 · 암거 경사는 형상 하단 경계)
drain = [py for ts_at, py in zip(r["ts"]["at"], r["rules"]) if ts_at["chainage"] == 160.0]
assert drain == [{"paved": False, "tilted": True, "tilt_pct": None, "drop": 0.0}]
KEYS = (
@@ -64,10 +64,38 @@ def test_suggestions_and_apply(r):
assert len(r["suggest"]["rows"]) == 2
points = {p["chainage_m"]: p for p in r["applied"]["points"]}
assert points[100]["options"]["pipe_diameter_mm"] == 1200
assert points[200]["source"] == "spacing"
assert 200 not in points # 관 자리 후보는 [적용]이 안 더함 — [자동 제안] 몫(369)
assert r["applied"]["remembered"] == 1
def test_자동_제안_두_방식_초기화_세류선만_한_걸음(r):
assert r["proposeStale"] == {
"off": True,
"title": "B05_Drainage_Btn_Propose_Stale",
"calls": 0, # 낡은 동안 끔 — 눌러도 요청 없음
}
assert r["proposeFresh"] == {"off": False, "title": "B05_Drainage_Btn_Propose_Tip"}
assert r["propose"] == {
"autos": ["all", "add", None],
"points": [1, 1, None], # ① ② = 지금 관 목록 함께 · 초기화 = 목록 없이(세류선만)
"remembered": 3, # 셋 다 초안
"records": 3, # 답마다 사진첩 한 걸음
}
# 저장 분석이 옛 노선 — 지금 노선으로 셈 길(detail-basins 안 부름) · 같은 초안 · 한 걸음
assert r["proposeLive"] == {
"auto": "all",
"points": 1,
"resetPoints": None,
"resetAuto": None,
"detailCalls": 0,
"remembered": 6, # 앞 「409 → 다시」 한 번 포함
"records": 6,
}
fallback = r["proposeFallback"]
assert fallback["sent"] == 1 and fallback["auto"] == "all"
assert "낡음" not in fallback["status"] # 409 글을 오류로 안 띄움(조용히 다시)
def test_move_while_busy_cancels(r):
moved = r["movedWhileBusy"]
assert moved["aborted"] and moved["veil"] and not moved["busy"]
+19 -10
View File
@@ -26,10 +26,11 @@ new Function("exports", "module", js)(m.exports, m);
const L = m.exports;
const levels = [101, 102, 105, 102, null];
console.log(JSON.stringify({
hoverFree: L.lockButtonState(102, null),
hoverNone: L.lockButtonState(null, null),
hoverLocked: L.lockButtonState(102, 102),
hoverOther: L.lockButtonState(105, 102),
hitPicked: L.lockButtonState(102, null, true),
hitOnly: L.lockButtonState(102, null, false),
hitNone: L.lockButtonState(null, null, true),
hitLocked: L.lockButtonState(102, 102, false),
hitOther: L.lockButtonState(105, 102, true),
lock: L.toggleLock(null, 102),
unlock: L.toggleLock(102, 105),
pickFree: L.canPickContour(null),
@@ -50,14 +51,16 @@ def got() -> dict:
return json.loads(run.stdout)
def test_안_잠김_단추는_높이_있는_가닥_위에서(got) -> None:
assert got["hoverFree"] == {"visible": True, "pressed": False}
assert got["hoverNone"]["visible"] is False
def test_안_잠김_단추는_고른_가닥을_누를_때만(got) -> None:
"""366-1 — 지나기만 · 고르지 않은 가닥 = 안 뜸."""
assert got["hitPicked"] == {"visible": True, "pressed": False}
assert got["hitOnly"]["visible"] is False
assert got["hitNone"]["visible"] is False
def test_잠김_단추는_그_높이_위에서만_눌린_모양(got) -> None:
assert got["hoverLocked"] == {"visible": True, "pressed": True}
assert got["hoverOther"]["visible"] is False
def test_잠김_단추는_잠근_높이_아무_가닥을_누를_때_풀기(got) -> None:
assert got["hitLocked"] == {"visible": True, "pressed": True}
assert got["hitOther"]["visible"] is False
def test_누르면_잠금_다시_누르면_풂(got) -> None:
@@ -79,3 +82,9 @@ def test_편집기_이음() -> None:
lock = (B05 / "B05_Profile_UI_RouteEdit_ContourLock.ts").read_text("utf-8")
assert "hitPreparedLayer(" in lock and 'setAttribute("aria-pressed"' in lock
assert "sessionStorage" not in lock and "writeState" not in lock # 페이지 동안만
# 366-1 — 지나기(pointermove)로 안 띄움 · 뗀 자리 · 다음 클릭 · 휠 · 떠남에 숨김
assert '"pointermove"' not in lock
assert 'canvas.addEventListener("pointerup", showAt);' in lock
assert 'canvas.addEventListener("wheel", hide' in lock
assert 'canvas.addEventListener("pointerleave", hideLater);' in lock
assert "picked: pickedContour" in edit
@@ -3,8 +3,8 @@
- 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할]
(`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나.
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 머리줄만(손잡이 · [창 분할] 닿음).
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화는 그대로.
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 높이 0 · 손잡이만 매달림 · [창 분할] 숨김(367-2).
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화 없음(367-1 늘 전체 맞춤).
"""
import os
@@ -30,7 +30,7 @@ def test_종단_열_아래_자기_칸():
assert 'column.className = "b05-route-profile__column";' in overlays
assert "column.append(bodyWrap, massHaul.overlay);" in overlays
assert "bodyWrap.append(massHaul.overlay" not in overlays
assert "massHaul.attachScrollSync(body);" in overlays
assert "massHaul.attachScrollSync" not in overlays # 367-1 측점 연계 없앰
profile = read("A00_Common/design/design_profile.ts")
assert "content.append(column);" in profile
assert sum(1 for _ in profile.splitlines()) <= 700
@@ -57,6 +57,18 @@ def test_흐름_칸_자기_높이_접으면_머리줄():
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head)",
)
assert "display: none;" in hide
# 367-2 접음 = 높이 0 · 손잡이 판 위 경계에 매달림 · [창 분할] 숨김(뗀 창 [창 결합] 그대로)
folded = css_block(css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout)")
assert "height: 0;" in folded
head = css_block(
css,
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head",
)
assert "top: auto;" in head and "bottom: 100%;" in head
assert "display: none;" in css_block(
css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout"
)
assert ".is-popout .b05-subpanel__popout" not in css
assert "display: none;" in css_block(
css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay"
)
@@ -37,7 +37,7 @@ def test_저장된_종단_범례는_횡단으로(r):
assert r["longitudinal"] == ["cross"]
assert r["longitudinalBalance"] == ["balance", "cross"]
assert r["cross"] == ["cross"]
assert r["empty"] == ["cross"] and r["broken"] == ["cross"]
assert r["empty"] == ["balance", "cross"] and r["broken"] == ["balance", "cross"]
def test_기준_횡단만_계산(r):
+20 -20
View File
@@ -4,14 +4,13 @@
- 메아리: scroll 은 다음 틀에 옴 → 동기 플래그로 못 막아 한 틀 전 값을 되씀 → 뒤로 튐 →
휠 이동이 접힘. 공용 `linkScrollLeft`(Wheel.ts)가 써 넣은 값으로 온 scroll 을 무시.
가짜 창(다음 틀 scroll) 재현 — 옛 잇기는 뒤로 튐 · 멈춤, 새 잇기는 4 경우 × 휠 쪽 둘 모두 튐 0.
- 유토 스크롤 그리기 = 열림 · 분배 맞춤 아님 · 붙음일 때만 · 한 틀 한 번 · 다시 셈 없음.
- 유토곡선 = 늘 전체 맞춤 · 종단과 안 이음(367-1) — 휠 · 잇기 경우는 공용 도우미 시험으로 남김.
`helper_354_3_scroll_echo.cjs` 가 진짜 휠 · 잇기 TS 를 가짜 창 위에서 돌림.
"""
import json
import os
import re
import subprocess
from pathlib import Path
@@ -64,24 +63,25 @@ def test_새_잇기는_뒤로_튐_0_끝까지(r, case):
assert got["mass"] == (0 if balance else 1200)
def test_유토_표_같은_도우미():
for name in ("MassHaul", "TableOverlay"):
src = (B05 / f"B05_Profile_UI_Profile_{name}.ts").read_text(encoding="utf-8")
assert "linkScrollLeft(main, scroll" in src
assert "syncing" not in src
def test_유토_스크롤_그리기는_창_따를_때만_한_틀_한_번():
def test_표만_잇기_유토는_안_이음():
# 367-1 유토곡선 = 늘 전체 맞춤 · 종단 스크롤과 안 이음
table = (B05 / "B05_Profile_UI_Profile_TableOverlay.ts").read_text(encoding="utf-8")
assert "linkScrollLeft(main, scroll" in table
assert "syncing" not in table
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
body = mass[mass.index("function drawWindow(") :]
body = body[: body.index("\n }\n")]
assert '!open || fitted || overlay.classList.contains("is-popout") || !lastSeries' in body
assert "windowState.frame" in body and "frameOf(overlay" in body
assert "draw(lastParams, true)" in body
# 다시 셈 없음 · 총괄값 · 범례는 계열이 바뀔 때만
assert re.search(r"reuse && lastSeries\s*\? lastSeries", mass)
for gone in ("linkScrollLeft", "attachWheelHorizontalScroll", "attachScrollSync", "drawWindow"):
assert gone not in mass, gone
def test_유토는_늘_전체_맞춤_스크롤마다_안_그림():
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
assert "hideStations: true," in mass
assert "viewRange: undefined," in mass
assert "scroll.scrollLeft = 0;" in mass
assert "createMassHaulWindowState" not in mass and "scheduleMassHaulSettle" not in mass
assert "if (series !== lastSeries) emitSummary(series);" in mass
# 다시 그려도 제 자리 — 종단 값으로 되쓰는 것은 처음 · 맞춤 뒤 · 창 옮김 뒤만
assert "scroll.scrollLeft = syncedMain.scrollLeft" not in mass
render = (B05 / "B05_Profile_UI_Profile_Render.ts").read_text(encoding="utf-8")
assert "massHaul.drawWindow({" in render
assert "drawWindow" not in render and "massHaulUpdater" not in render
css = (B05 / "B05_Profile_UI_Style_MassHaul.css").read_text(encoding="utf-8")
block = css[css.index(".b05-profile__masshaul-scroll {") :]
assert "overflow-x: hidden;" in block[: block.index("}")]
+2 -4
View File
@@ -82,11 +82,9 @@ def test_배선():
pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8")
assert "center: section.chainage_m," in pick # designLineMoves → 카드 고름 끝 조각
assert 'if (axis === "center") return centerShift?.step(dir);' in pick
# 364-7 덧 — 중심점 ◀▶ 툴팁 = 노선 좌우 · ↺ · 소단 ▲▼ 숨김
# 364-7 덧 — 중심점 ◀▶ 툴팁 = 노선 좌우 · ↺ 없음 · 소단 ▲▼ 없음(370-3 공용 칸 = 축에 맞는 버튼만)
assert "`이 측점 노선을 화면 ${screen}으로 0.1 m`" in pick
assert '[toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")];' in pick
assert 'move.controls.children[1]?.classList.toggle("is-hidden", axis === "center");' in pick
assert 'lift.row.classList.toggle("is-hidden", axis !== "depth");' in pick
assert 'title: "중심점", value: centerPreviewText(0), buttons: sideways(false)' in pick
order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8")
assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order
edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
+205
View File
@@ -0,0 +1,205 @@
"""PLAN 365 sub7 — 평면 > 종단 <> 횡단 한 방향 · 묶음 주인 = 페이지 · 캐시 · 고리.
못박는 것(365-1)
- 묶음은 페이지(종단도 한 벌 `sharedProfile`)가 세움 — 종단 보기는 다리만 받음(옵션 안 넘김).
- 노선 초안 둔 채 다시 열기(새로고침 · B06 바로 열기 · 편집기 없음) = B05 길과 같은 초안 상세
(측점 · 계획고 · 면적) · 측점을 가게에 다시 냄 · 초안 전 = 저장 상세(열 때 맡김) → 버리면 saved.
- 초안 동안 측점 갈아끼움은 메모리만 — 세션 상세(저장 노선)를 새 측점 + 옛 설계로 덮지 않음.
못박는 것(365-2)
- 초안 동안 B06 고침(세션 칸 · 측점 열쇠) → `pipelineCross` → 묶음 cross 입력 · 카드에 보임.
- 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜 = 1 바퀴 · 안 맞으면 3 바퀴 뒤 「계산 불안정」 한 번.
못박는 것(365-3)
- B05 오른쪽 횡단 판 = 묶음 그 측점 — 계획고 · 절토 면적 · 표본 = B06 카드 · 서버 길 없음.
못박는 것(365-4)
- 저장 노선 ▲▼ 한 번 = 횡단 한 벌 — 묶음 cross 가 다시 셈 창구(상세 설계) 한 번 · 종단도 예약 끔.
- 중심점 좌우 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만).
- B06 다시 그림 = 겹친 신호(갈아끼움 · 묶음 cross · 초안) 한 번 · profile 차례는 안 그림.
짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) ·
`helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr).
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
import test_346_2_pipeline_cross_page as base # noqa: E402
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
pytestmark = base.pytestmark
def _job(folder: Path) -> tuple[Path, Path]:
npz, nodes = base._fake(folder)
line = base._line(nodes)
sampler = DtmGridSampler.from_npz(npz)
pipes = base._pipes(line)
saved = base._sections(line, sampler, pipes)
options = base.resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None)
policy = base.design_ground_following_profile(saved["longitudinal"], options)[0]["policy"]
job = {
"nodes": nodes,
"line": line,
"minRadius": base.MIN_RADIUS_M,
"policy": policy,
"types": base._types(),
"interval": base.INTERVAL_M,
"pipes": pipes,
"standard": base.STANDARD_CROSS_SECTION,
"conversion": base.ONES,
"detail": base._saved_detail(saved),
"haulDelayMs": 10,
}
path = folder / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
grid = folder / "grid.bin"
grid.write_bytes(dtm_grid_bytes(npz))
return path, grid
@pytest.fixture(scope="module")
def r() -> dict:
with tempfile.TemporaryDirectory() as folder:
path, grid = _job(Path(folder))
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_365_page_pipeline.cjs"), str(path), str(grid)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=180,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_365_1_묶음_주인_페이지():
shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8")
view = (ROOT / "A00_Common/design/design_view_profile.ts").read_text(encoding="utf-8")
assert "routeDraftBridge(projectId, profile, {" in shared # 종단도 한 벌과 함께 세움
assert "draft: routeDraftBridge(activeProjectId, profilePanel)," in view # 다리만 받음
assert "holdDraft" not in view and "holdDraft" in shared
def test_365_1_초안_둔_채_열기_같은_초안(r):
b05, b06 = r["b05"], r["b06"]
assert r["kept"] # 떠나도 세션 초안 남음
assert b05["rows"] and b06["rows"] == b05["rows"] # 측점 · 계획고 · 면적 같음
assert b06["outcome"] == "done" and "cross" in b06["stages"]
assert b06["stations"] == b05["stations"] == [row[0] for row in b05["rows"]] # 측점 다시 냄
assert b05["holds"] == ["saved"] and b06["holds"] == ["saved"] # 초안 전 = 저장 상세
def test_365_1_초안_버림_저장_정본(r):
d = r["dropped"]
assert d["detail"] is None and d["routeDraft"] is None
assert d["restoreWhy"] == "saved"
def test_365_2_초안_동안_횡단_고침_카드에(r):
e = r["edit"]
assert e["before"] != "blasting_rock"
assert e["input"] == "blasting_rock" and e["after"] == "blasting_rock"
assert e["runs"] == 1 and e["sources"] == ["cross"]
def test_365_2_고리_바퀴_수(r):
loop = r["loop"]
assert loop["same"] == 0 and loop["sameRuns"] == 0 # 같은 입력 = 안 돎
assert loop["once"] == 1 and loop["onceRuns"] == 1 and loop["onceGround"] == "ripping_rock"
assert loop["unstable"] == -1 and loop["unstableRuns"] == 3 and loop["alerts"] == 1
def test_365_3_평면_횡단_판_카드와_같음(r):
p = r["pane"]
assert p["count"] > 5 and p["same"] == p["count"]
assert p["elevation"] is not None
def test_365_3_서버_횡단_미리보기_길_없음():
router = (ROOT / "B05_Profile/B05_Profile_Router_Terrain.py").read_text(encoding="utf-8")
api = (ROOT / "B05_Profile/B05_Profile_Api_Replan.ts").read_text(encoding="utf-8")
edit = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "cross-preview" not in router and "cross-preview" not in api
assert "crossPreview.refresh()" not in edit # 묶음 cross 차례가 그림
def test_365_4_저장_노선_계획고_횡단_한_벌(r):
s = r["saved"]
assert s["owns"] and s["openCalls"] == 1
assert s["gradeCalls"] == 1 and s["runs"] == 1 and s["sources"] == ["grade"]
assert s["fromDetail"] # 묶음 cross = 상세 설계 그대로(엔진 따로 안 셈)
def test_365_4_중심점_좌우_횡단_한_번(r):
runs = r["shiftRuns"]
assert [x["outcome"] for x in runs] == ["dropped", "done"], runs
assert [x["cross"] for x in runs] == [False, True]
assert runs[1]["sources"] == ["route", "grade"] # 밀린 노선 범위 + 계획고 = 한 차례
def test_365_4_다시_그림_한_번():
swap = (ROOT / "B06_Section/B06_Section_UI_Page_Station_Swap.ts").read_text(encoding="utf-8")
view = (ROOT / "A00_Common/design/design_view_cross.ts").read_text(encoding="utf-8")
profile = (ROOT / "A00_Common/design/design_profile.ts").read_text(encoding="utf-8")
assert 'event.stage !== "cross") return;' in swap # profile 차례 중간 상세는 안 그림
body = view.split("async function refreshDetailForStructures")[1].split("loadSectionDetail")[0]
assert "setTimeout(next, 0)" in body # 겹친 신호는 seq 가 앞 차례를 버림 = 한 번
assert "if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview();" in profile
@pytest.fixture(scope="module")
def swap(tmp_path_factory) -> dict:
work = tmp_path_factory.mktemp("b365")
build = subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_365_swap_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_365_swap_entry.js", bundle)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_365_swap.mjs"), str(bundle)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_365_1_초안_동안_세션_상세_안_덮음(swap):
d = swap["draft"]
assert d["swapped"] and d["cache"] == [0, 20, 30, 40, 60] # 메모리 = 초안 측점
assert d["session"] == [0, 20, 40, 60] # 세션 = 저장 노선 그대로
s = swap["saved"]
assert s["swapped"] and s["session"] == s["cache"] == [0, 20, 50, 60] # 초안 없으면 세션도
@@ -0,0 +1,85 @@
"""366-3 측점 스냅 = 곡선 가운데(MC)를 일반측점에 — `logic/B05_Profile_RouteEdit_StationFit.ts`
를 Node(`helper_366_3_station_fit.cjs`)로 돌려 판정 + 편집기 이음(놓을 때 · applyEdit · 켤 때 ·
되돌리기 · 추천 적용은 안 맞춤 · 상태줄 「측점 밖 곡선 N개」).
./venv/Scripts/python.exe -m pytest resources/tester/test_366_3_station_fit.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_366_3_station_fit.cjs")
TOL = 0.05
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_맞추기_전_곡선은_측점_밖(got) -> None:
assert got["before"]["off"] == got["before"]["curves"] == 5
def test_MC_는_일반측점_위(got) -> None:
fit = got["all"]
assert fit["curves"] == 5 and fit["off"] == 0
assert max(fit["residues"]) <= TOL
stations = [round(mc / 20) for mc in fit["mcs"]]
assert all(n >= 1 for n in stations) and len(set(stations)) == len(stations)
def test_곡선_수는_측점_수_이하(got) -> None:
assert got["all"]["curves"] <= got["all"]["innerStations"]
def test_시점_종점_고정_IP_그대로_밀림은_간격_절반_안(got) -> None:
for case in ("all", "locked", "fromLater"):
moved = got[case]["moved"]
assert moved[0] == 0 and moved[-1] == 0
assert max(moved) <= 10 + 1e-6
assert got["locked"]["moved"][3] == 0
def test_못_놓은_곡선은_그대로_개수(got) -> None:
assert got["locked"]["off"] == 1 # 고정 IP 곡선 하나만 밖 · 나머지는 맞춤
assert got["everyLocked"]["off"] == 5 and max(got["everyLocked"]["moved"]) == 0
def test_고친_자리부터만(got) -> None:
moved = got["fromLater"]["moved"]
assert moved[1:4] == [0, 0, 0] and moved[4] > 0
assert got["fromLater"]["residues"][3:] == pytest.approx([0, 0], abs=TOL)
def test_맞춘_선은_다시_맞춰도_그대로_간격_0_은_안_맞춤(got) -> None:
assert max(got["again"]) == 0 and max(got["noInterval"]) == 0
def test_시작_자리는_고친_꺾임점_앞(got) -> None:
start = got["start"]
assert start["moved4"] == 3 and start["radius2"] == 1
assert start["dropLast"] == 4 # 지운 마지막 꺾임점 → 새 끝 앞 곡선부터
assert start["same"] == 1 and start["none"] == 1
def test_편집기_이음() -> None:
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8")
assert "fitCurveStations(" in edit
# 되돌리기 · 추천 [적용] · 초안 되살림이 지나는 restoreSnapshot 은 안 맞춤
at = edit.index("function restoreSnapshot(")
assert "applyEdit(message, record, false);" in edit[at : at + 700]
assert len(edit.splitlines()) <= 700
edits = (B05 / "logic" / "B05_Profile_UI_RouteEdit_Edits.ts").read_text("utf-8")
assert "측점 밖 곡선 " in edits
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8")
assert "곡선 가운데(MC)를 가까운 일반측점에" in chrome
+134
View File
@@ -0,0 +1,134 @@
"""369 배수 구조물 자리 — 기본 = 세류선 교차만 · [자동 제안] 두 방식(all · add).
- 기본(auto 없음) = 세류선 교차 관만 · 100 m 보충 없음 · 관 자리 후보는 셈.
- all = 세류선 + 100 m 보충으로 다시(지금 목록 무시) · 같은 자리(0.05 m) 관은 속성 · id 유지 ·
나머지 = 새 관(등록부 기본값).
- add = 지금 목록 그대로 + 빈 구간 보충 · 더한 관 = 새 관(등록부 기본값).
- 틀린 auto = 400 · 저장본 열쇠에 auto.
"""
import asyncio
from pathlib import Path
from uuid import uuid4
import numpy as np
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
from common_util.common_util_drainage_context import DrainageContext
from common_util.common_util_drainage_detail import RoadRouting, build_detail
from common_util.common_util_drainage_pipes import PipePoint
from common_util.common_util_route_geometry import RouteVertex, StructureCandidate
VERTICES = [RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)]
CONTEXT = DrainageContext(stored_path="x", project_root=Path("."), vertices=VERTICES)
def _routing() -> RoadRouting:
spec = GridSpec(x_min=-5.0, y_max=5.0, cell_m=1.0, n_rows=10, n_cols=310)
road_index = np.arange(5 * 310 + 5, 5 * 310 + 305, dtype=np.int32)
road_slot = np.full(spec.size, -1, dtype=np.int32)
road_slot[road_index] = np.arange(road_index.size)
return RoadRouting(
spec=spec,
road_slot=road_slot,
path_length=np.zeros(spec.size, np.float32),
elevation=np.full(spec.size, 95.0, np.float32),
road_cell_index=road_index,
road_chainage=np.arange(road_index.size, dtype=np.float64),
strength=np.ones(road_index.size, np.int64),
base_pipes=[StructureCandidate(chainage_m=150.0, x=150.0, y=0.0, reason="stream")],
)
def _detail(tmp_path, chainages=None, auto=None):
return build_detail(tmp_path / "없음", VERTICES, chainages, _routing(), [], auto)
def test_기본은_세류선_교차만(tmp_path):
detail = _detail(tmp_path)
assert [(p.chainage_m, p.reason) for p in detail.pipes] == [(150.0, "stream")]
assert len(detail.pipe_candidates) == 2 # 0~150 · 150~300 이 100 m 넘음 — 제안만
def test_all_은_세류선_더하기_100m_보충_지금_목록_무시(tmp_path):
detail = _detail(tmp_path, [10.0, 150.02], "all")
reasons = [p.reason for p in detail.pipes]
places = [p.chainage_m for p in detail.pipes]
assert reasons.count("stream") == 1 and reasons.count("spacing") == 2
assert 150.02 in places and 10.0 not in places # 세류선 150 m 0.5 m 안의 지금 관 = 그 자리
gaps = np.diff([0.0, *places, 300.0])
assert gaps.max() <= 100.0 and not detail.pipe_candidates
def test_add_는_지금_목록_더하기_빈_구간_보충(tmp_path):
detail = _detail(tmp_path, [20.0], "add")
places = [p.chainage_m for p in detail.pipes]
assert 20.0 in places and len(places) == 3 # 20 ~ 300 = 280 m → 보충 둘
assert np.diff([0.0, *places, 300.0]).max() <= 100.0
# auto 없음 = 목록 그대로(보충은 후보만)
plain = _detail(tmp_path, [20.0])
assert [p.chainage_m for p in plain.pipes] == [20.0] and len(plain.pipe_candidates) == 2
def _build(tmp_path, monkeypatch, points, auto):
monkeypatch.setattr(basins, "drainage_dir", lambda stored: tmp_path / "없음")
return basins._build("x", CONTEXT, points, _routing(), [], auto=auto)
def test_all_같은_자리_관은_속성_id_유지_새_관은_기본값(tmp_path, monkeypatch):
kept = PipePoint(
chainage_m=150.3, # 세류선 150 m 에서 0.3 m — 노선 따라감으로 어긋난 같은 관
source="user",
facility="ford_bridge",
options={"ford_width_m": 4.0},
id="keep",
)
gone = PipePoint(chainage_m=10.0, source="user", options={"pipe_diameter_mm": 600}, id="gone")
_, carried = _build(tmp_path, monkeypatch, [gone, kept], "all")
ford = [p for p in carried if p.id == "keep"]
assert len(ford) == 1 and ford[0].facility == "ford_bridge" and ford[0].source == "user"
assert ford[0].chainage_m == 150.3 # 그 자리 그대로
assert all(p.id != "gone" for p in carried)
fresh = [p for p in carried if p.id != "keep"]
assert len(fresh) == 2 and all(p.source == "spacing" for p in fresh)
assert all((p.options or {}).get("pipe_diameter_mm") for p in fresh) # 등록부 관경
def test_add_더한_관은_기본값_지금_관은_그대로(tmp_path, monkeypatch):
mine = PipePoint(chainage_m=20.0, source="user", id="mine")
_, carried = _build(tmp_path, monkeypatch, [mine], "add")
assert [p.id for p in carried].count("mine") == 1
assert next(p for p in carried if p.id == "mine").options is None # 옛 빈칸 = 미확정 유지
added = [p for p in carried if p.id != "mine"]
assert len(added) == 2 and all(p.source == "spacing" for p in added)
assert all((p.options or {}).get("pipe_diameter_mm") for p in added)
def test_틀린_auto_는_400_열쇠에_auto(tmp_path, monkeypatch):
async def load(project_id):
return CONTEXT, ""
monkeypatch.setattr(basins, "load_drainage_context", load)
monkeypatch.setattr(basins, "drainage_dir", lambda stored: tmp_path)
result = asyncio.run(basins._resolve(uuid4(), {"auto": "spacing"}, use_stored=False))
assert result.status_code == 400
points = [PipePoint(chainage_m=20.0)]
keys = {basins._detail_key(CONTEXT, points, auto) for auto in basins.AUTO_MODES}
assert len(keys) == 3
def test_낡은_분석에_auto_는_409(monkeypatch):
async def load(project_id):
return CONTEXT, ""
monkeypatch.setattr(basins, "load_drainage_context", load)
monkeypatch.setattr(basins, "analysis_route_signature", lambda stored: "옛 노선")
points = [{"chainage_m": 20.0}]
for auto in ("all", "add"):
result = asyncio.run(
basins._resolve(uuid4(), {"points": points, "auto": auto}, use_stored=False)
)
assert result.status_code == 409 # 말없이 그대로 돌려주지 않음 — 낡음 까닭
kept = asyncio.run(basins._resolve(uuid4(), {"points": points}, use_stored=False))
assert kept[-1] is True # auto 없음 = 관 자리만 낡음으로(그대로)
+79
View File
@@ -0,0 +1,79 @@
"""PLAN 370-2 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기(요구 240-14).
짝: `B06_Section/logic/B06_Section_UI_Cross_Fit.ts`(`extendStop` · `toePoints`) ·
`B06_Section/B06_Section_UI_Cross_Design_Pick.ts`(손잡이 · 키 이어받기). 실제 TS 를
`helper_370_2_toe_stop.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out(tmp_path_factory) -> dict:
standard = tmp_path_factory.mktemp("std") / "standard.json"
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_370_2_toe_stop.cjs"), str(standard)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_노견_끝이_원지반을_지나면_그_자리에_한_번_섬(out) -> None:
stop = out["stop"]
low, high = stop["crossing"]
assert low < stop["value"] < high
assert stop["gap"] < 2e-3 # 노견 끝 = 원지반
assert stop["noCross"] == 0.1 # 안 지나면 걸음 그대로
assert abs(stop["fromStop"] - round(stop["value"] + 0.1, 1)) < 1e-9 # 멈춘 뒤엔 다시 안 섬
assert stop["leftNoGround"] == 0.1 # 지반 없으면 그대로
def test_교차점_손잡이_쪽마다_늘_보임(out) -> None:
toe = out["toe"]
assert toe["count"] == len(toe["sides"]) == 2
assert toe["centerClass"] is True # 중심점 원과 같은 고르기 차례
assert toe["rightOnGround"] is True
assert abs(toe["rightCx"] - toe["rightX"]) < 1e-9
def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None:
got = out["toePick"]
assert got["active"] is True and got["panelOpen"] is True
assert got["title"] == "교차점 이동 (우)" # 아래 띠 공용 칸(370-3)
assert got["value"].startswith("노선에서 ")
assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right"
low, high = out["stop"]["crossing"]
assert abs(got["sent"][0]["valueM"] - out["stop"]["value"]) < 1e-9 # ◀▶ 도 멈춤 자리
assert low < got["sent"][0]["valueM"] < high
def test_절에서_성으로_넘어가도_고름_유지(out) -> None:
inherit = out["inherit"]
assert inherit["cutIndex"] >= 0 and inherit["before"] == inherit["cutIndex"]
assert inherit["farMode"] == "left_cut" # 우 = 성토로 넘어감
assert inherit["afterIndex"] is not None
assert inherit["label"] == "교차점 이동 (우)"
+93
View File
@@ -0,0 +1,93 @@
"""PLAN 370-3 — B06 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸(요구 240-22).
짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts`(rock 축 · 판정 선) ·
`B06_Section/B06_Section_UI_Cross_Line_Cell.ts`(공용 칸) · `B06_Section_UI_Cross_Center.ts`(`bindAxisDrag`).
실제 TS 를 `helper_370_3_rock_cell.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_370_3_rock_cell.cjs"
B06 = ROOT / "B06_Section"
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_안_고르면_공용_칸은_암_버튼과_값(out: dict) -> None:
idle = out["idle"]
assert idle["title"] == "암 경계선" and idle["value"] == "-0.5m"
assert idle["slots"] == ["up", "reset", "down"]
assert idle["upPassed"] is True # 안 고르면 ▲▼ = 종단 계획고 그대로
assert idle["offsets"] == [0.1, -0.1] and idle["resets"] == 1
def test_암_경계선_판정_선(out: dict) -> None:
hit = out["hit"]
assert hit["found"] and hit["tag"] == "path" and hit["moves"] == 2 # 끊긴 두 토막 한 판정
assert hit["last"] and hit["picked"] == [3] # 조각 셋 뒤 · 누르면 고름
def test_암_고름은_위아래만_암_키는_암만(out: dict) -> None:
got = out["picked"]
assert got["active"] and got["key"] # 키 rock 으로 기억
assert got["title"] == "암 경계선" and got["slots"] == ["up", "reset", "down"]
assert got["leftBlocked"] and got["rightBlocked"] # ◀▶ 막음
assert got["upTaken"] and got["downTaken"] # ▲▼ = 암(종단으로 안 샘)
assert got["sideValues"] == {"left": 0, "right": 0}
assert got["offsets"][:2] == [0.1, -0.1]
def test_세로_끌기는_놓을_때_한_번(out: dict) -> None:
drag = out["drag"]
assert drag["stopped"] is True # 카드 팬으로 안 번짐
assert drag["during"]["rock"] == drag["during"]["hit"] # 점선 · 판정 선 같이
dy = float(drag["during"]["rock"].split()[1].removesuffix("px"))
assert abs(dy - (-0.3 * 12)) < 1e-9 # 위로 0.3 m(화면 px = m × 12)
assert drag["during"]["value"] == "-0.2m — 놓으면 반영"
assert drag["sent"] == [0.3] # 0.34 m → 걸음 0.1 맞춤 · 한 번
assert drag["clickSwallowed"] is True
assert out["idleDrag"]["stopped"] is False # 안 고르면 끌기 아님
def test_풀면_암_칸_사면_고르면_그_선_칸(out: dict) -> None:
assert out["released"]["title"] == "암 경계선" and out["released"]["upPassed"]
slope = out["slope"]
assert slope["title"] == "사면 이동 (좌)" and slope["slots"] == ["left", "reset", "right"]
assert slope["value"].startswith("길이 ") and "\n" not in slope["value"]
assert slope["arrowSlots"] == slope["slots"] # 숨은 버튼은 방향키 슬롯 없음
def test_토사는_빈_칸_판정_선_없음(out: dict) -> None:
soil = out["soil"]
assert soil["empty"] is True and soil["slots"] == [] and soil["rockHit"] is False
def test_배선_떠_있는_선_조정창_걷음() -> None:
pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8")
assert "buildPanelShell" not in pick and "b06-cross-card__line-dims" not in pick
view = (B06 / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
assert "rockBoundary && rockCellSpec(section, rockBoundary)" in view
chrome = (B06 / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8")
assert "if (lineCell) {" in chrome
detail = (B06 / "B06_Section_UI_Cross_Detail_Pick.ts").read_text(encoding="utf-8")
assert ".b06-chart__design-hit:not(.b06-chart__rock-hit)" in detail # 상세 표 강조에 안 섞임
css = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8")
assert ".b06-cross-card__linecell.is-empty {" in css and "text-overflow: ellipsis;" in css
@@ -0,0 +1,49 @@
"""PLAN 371-2 — B06 횡단 카드 관 글 L 뒤 기울기(요구 240-16).
규칙: 「관매설 {관종}Φ…m/m, L=8 m (7.6), S=2.5%」(소수 한 자리 · 역경사 0.0) ·
툴팁 「매설 경사」 지움 · 세월교 · 박스 흙막이 세트(hidden_pipe = 관 숨김)는 관 글 없음 ·
B07 덧그림(노폭 라벨 없음)도 없음(225-1).
짝 = `helper_371_2_pipe_slope.cjs`.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_371_2_pipe_slope.cjs"
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_관_글_L_뒤에_기울기_소수_한_자리(out: dict) -> None:
slope = f"{out['slopePct']:.1f}"
assert out["pipe"]["infos"] == [f"관매설 파형강관 Φ1000m/m, L=8 m (7.5), S={slope}%"]
def test_역경사는_0_0(out: dict) -> None:
assert out["reverse"]["infos"][0].endswith(", S=0.0%")
def test_툴팁에_매설_경사_없음(out: dict) -> None:
tips = out["pipe"]["pipeTips"]
assert tips and all("매설 경사" not in tip for tip in tips)
def test_관_숨김_세트는_관_글_없음(out: dict) -> None:
assert out["hidden"] == {"infos": [], "pipeTips": []}
@@ -0,0 +1,78 @@
"""373 B08 거리이정표 — 연결 없이 소재지 넣기 · 구간 길 선 저장 · 다시 이음 · 시점 미연결 · 녹색 점.
화면 코드는 소스 확인 + node 로 실제 TS(`findSegmentKm` · `reachesSite`)를 돌림. 서버 쪽 `path` 칸은
`test_244_9_b08_milestone_route_map.py`.
"""
from resources.tester.test_244_13_b08_milestone_place_leg import (
CALC,
LAYOUT,
STATE,
STATE_STUBS,
_node,
_read,
)
B08 = "B08_Quantity/B08_Quantity_"
ROW = [{"item": "철근", "dest": "start", "note": ""}]
DATA = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"bases": [
{"id": "b1", "name": "병곡리", "address": "", "dir": None, "lon": 127.85, "lat": 35.66,
"legs": [{"road": None, "km": None}], "to": "site", "linked": False},
],
"routes": [
{"id": "g", "name": "g", "address": "", "to": "b1", "dir": None, "lon": 127.9,
"lat": 35.68, "linked": True, "legs": [{"road": "2차포장", "km": 23.8}], "rows": ROW},
],
} # fmt: skip
FAIL_STUBS = STATE_STUBS.replace(
'if (url.includes("/directions")) return {',
'if (url.includes("/directions") && (globalThis as any).FAIL)'
' return { km: null, reason: "카카오 실패", path: [] };\n'
' if (url.includes("/directions")) return {',
)
def test_373_안_이은_기준점에_이으면_시점_미연결_실패면_옛_거리_선_지움():
script = f"""const DATA = {__import__("json").dumps(DATA)};
await loadMilestone("p1");
const a = await findSegmentKm("p1", "g", false);
const first = liveMilestone("p1");
const open = reachesSite(first, "g");
first.bases[0].linked = true;
const closed = reachesSite(first, "g");
(globalThis as any).FAIL = true;
const b = await findSegmentKm("p1", "g", false);
const after = liveMilestone("p1").routes[0].legs;
console.log(JSON.stringify({{ a, b, open, closed, first: first.routes[0].legs, after,
line: segmentPath("p1", "g") }}));"""
got = _node([CALC, LAYOUT, STATE], script, FAIL_STUBS)
assert got["a"] == "g → 병곡리 10.5 km — 시점 미연결" and got["open"] is False
assert got["closed"] is True
assert got["first"][0]["path"] == [[35.68, 127.9], [35.66, 127.85]] # 구간 자료에 길 선
assert got["b"] == "g → 병곡리 — 카카오 실패"
assert got["after"] == [{"road": "2차포장", "km": 23.8, "auto_km": None}] # 사용자 km 만 남음
assert got["line"] == []
def test_373_화면_소재지_지도_누름_저장된_선_녹색_점():
route = _read(B08 + "UI_Milestone_Route.ts")
assert (
'label: "소재지"' in route
and "openPlaceModal(projectId, { add: false, id: routeId })" in route
)
assert "onClick: (lon, lat) => void moveTo(routeId, lon, lat)," in route # 연결 없어도 누름
assert "center: () => startPoint(liveMilestone(projectId))," in route # 점 없으면 시점 쪽
assert "reachesSite(d, routeId)" in route and "시점 미연결" in route
leg = _read(B08 + "UI_Milestone_LegModal.ts")
assert "path: leg.path," in leg and "...(path ? { path } : {})" in leg # 모달이 선 안 지움
assert "{ ...l, auto_km: null, path: null }" in leg # 수기 = 선 지움
diagram = _read(B08 + "UI_Milestone_Diagram.ts")
assert "route.legs = [{ road: null, km: null }];" in diagram # 다시 이음 = 옛 거리 · 선 지움
state = _read(B08 + "UI_Milestone_State.ts")
assert "paths" not in state.replace("path:", "").replace("segmentPath", "") # 메모리 표 없음
assert "path?: Array<[number, number]> | null;" in _read(CALC)
css = _read(B08 + "UI_Milestone.css")
dot = css[css.index(".b08-milestone__connect {") :]
assert "opacity: 0.85;" in dot[: dot.index("}")]
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증.
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 서버 기본값.
선 길이는 서버가 덮은 반폭(= 횡단 미리보기 원지반 선 범위)을 그대로 쓴다.
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 원지반 선 범위
(계산 묶음 그 측점 표본 · 365-3).
Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다.
"""
@@ -31,21 +31,17 @@ def test_측점을_고르기_전에는_선이_없다(results):
assert results["before"] is None
def test_적용한_적_없으면_서버_기본값_양쪽(results):
assert results["empty"]["asked"] is None
def test_적용한_적_없으면_원지반_범위_양쪽(results):
assert results["empty"]["watched"] == {"chainageM": 100, "leftM": 20, "rightM": 20}
def test_적용값이_있으면_그_값_양쪽(results):
assert results["applied15"]["asked"] == 15
assert results["applied15"]["watched"] == {"chainageM": 100, "leftM": 15, "rightM": 15}
def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(results):
assert results["latestRoute"]["asked"] == 8.5
assert results["latestRoute"]["watched"]["leftM"] == 8.5
def test_망가진_값은_기본값으로(results):
assert results["badValue"]["asked"] is None
assert results["badValue"]["watched"]["rightM"] == 20
+28 -5
View File
@@ -208,11 +208,34 @@ def test_노선_추천_다듬기_23_3():
return open(os.path.join(page, name), encoding="utf-8").read()
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
# 345-4 — [노선 추천]은 좌측 칸 · 머리 구분선은 [3D](tools) 뒤 · 되돌리기 앞 둘 그대로
tools = chrome.index('data-slot="tools"')
divider = chrome.index("b05-route__view-separator", tools) # 352-1 — 3D 도구줄 구분선 그대로
assert tools < divider < chrome.index('data-act="contour-minor"')
assert chrome.count("b05-route__view-separator") == 2
# 366-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형(맨 오른쪽)
bar = chrome[
chrome.index("b05-routeedit__tools") : chrome.index('<div class="b05-routeedit__hint">')
]
order = [
'data-act="hint-toggle"',
"|",
'data-act="undo"',
'data-act="redo"',
"|",
'data-slot="tools"',
"|",
'data-act="rotate-ccw"',
'data-act="rotate-cw"',
'data-act="contour-minor"',
'data-act="footprint"',
'data-act="measure"',
"|",
'data-act="station-snap"',
'class="b05-routeedit__terrain"',
]
at = 0
for mark in order:
key = "b05-route__view-separator" if mark == "|" else mark
at = bar.index(key, at) + 1
assert bar.count("b05-route__view-separator") == 4
assert chrome.count("b05-route__view-separator") == 4
assert "b05-routeedit__spin" not in chrome # 회전 = 도구줄(추천 모달 CSS 는 남음)
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
assert f"{name}(" in shared
@@ -61,6 +61,22 @@ def _dip_ground() -> list[dict]:
return samples
def _ridge_ground() -> list[dict]:
"""우측 −4 m 까지 노면보다 높은 평지(절토) · 그 너머 급히 내려가는 마루(370-1)."""
return [
{"offset_m": o, "elevation_m": round(100.3 - 0.6 * max(0.0, -o - 4.0), 4), "valid": True}
for o in (-12.0 + i * 0.5 for i in range(49))
]
def _rise_ground() -> list[dict]:
"""좌측 +3 m 까지 노면보다 낮은 평지(성토) · 그 너머 오르막(370-1)."""
return [
{"offset_m": o, "elevation_m": round(99.6 + 0.4 * max(0.0, o - 3.0), 4), "valid": True}
for o in (-12.0 + i * 0.5 for i in range(49))
]
# 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교
# 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간.
CASES = [
@@ -214,8 +230,8 @@ CASES = [
},
},
{
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮기기 전 노견에서
"name": "노견 확장 — 판정은 옮기기 전 노견",
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮긴 노견에서(370-1)
"name": "노견 확장 — 판정은 옮긴 노견",
"samples": _ground(-0.05),
"design_elevation_m": 100.0,
"options": {
@@ -226,6 +242,30 @@ CASES = [
"shoulder_extend_left_m": 2.5,
},
},
{
# 370-1 — 우 절토 마루를 넘겨 옮김 = 그 쪽 성토 1:n 으로 원지반까지(원지반 직선 아님)
"name": "마루 너머 = 성토 1:n",
"samples": _ridge_ground(),
"design_elevation_m": 100.0,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"shoulder_extend_right_m": 3.0,
},
},
{
# 370-1 — 좌 성토를 오르막으로 옮김 = 그 쪽 절토(측구 자동 판정 따름)
"name": "성 → 절 넘어감",
"samples": _rise_ground(),
"design_elevation_m": 100.0,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"shoulder_extend_left_m": 2.5,
},
},
{
# 151-2 — 곡선부 확폭과 사면 이동이 같은 쪽에 겹침(확폭 출력엔 이동 몫 안 섞임)
"name": "확폭 좌 + 사면 이동 좌 0.4",
@@ -490,3 +530,33 @@ def test_사면_이동은_노견째_평행이동_늘어난_폭은_차도() -> No
both = by_name["확폭 좌 + 사면 이동 좌 0.4"]
assert both["widening_left_m"] == curve["widening_left_m"] > 0
assert abs(both["carriageway_width_m"] - (curve["carriageway_width_m"] + 0.4)) < 1e-9
def _outer_slope(result: dict, side: str, skip: float = 0.0) -> float:
"""노면 끝에서 `skip`(측구 폭) 바깥 설계선 한 마디의 기울기(dz / 바깥 거리)."""
sign = 1.0 if side == "left" else -1.0
start_offset = result["road_edges"][side]["offset_m"] + sign * skip
line = sorted(result["design_line"], key=lambda p: sign * p["offset_m"])
index = next(i for i, p in enumerate(line) if abs(p["offset_m"] - start_offset) < 1e-6)
start, nxt = line[index], line[index + 1]
return (nxt["elevation_m"] - start["elevation_m"]) / abs(nxt["offset_m"] - start["offset_m"])
def test_절성_넘어가면_그_쪽_사면_기울기로_원지반까지() -> None:
"""370-1 — 옮긴 노견에서 절/성 판정 · 마루 너머 = 성토 1:n · 성 → 절 = 절토 1:n + 측구 자동."""
by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True))
soil = STANDARD_CROSS_SECTION["soil"]
ridge = by_name["마루 너머 = 성토 1:n"]
assert ridge["section_mode"] == "left_cut" # 우 = 성토
assert ridge["ditch_enabled"] is False # 우 측구 자동 판정 따라 빠짐
assert ridge["fill_area_m2"] > 0
assert abs(_outer_slope(ridge, "right") + 1.0 / soil["fill_slope_ratio"]) < 1e-3
rise = by_name["성 → 절 넘어감"]
assert rise["section_mode"] == "left_cut" # 좌 = 절토
assert rise["ditch_enabled"] is True and rise["ditch_side"] == "left"
assert (
abs(
_outer_slope(rise, "left", soil["ditch"]["top_width_m"]) - 1.0 / soil["cut_slope_ratio"]
)
< 1e-3
)
@@ -36,6 +36,9 @@ writeFileSync(process.argv[2], JSON.stringify({
under: buildRevetWallGeometry({
anchor, outward: 1, height: 2, material: "dry", role: "outlet", roadElevation: 103,
}).topJoint.elevation,
fill: (({ fillTop, topJoint, points }) => [fillTop.offset, fillTop.elevation, topJoint.offset, points[2].offset, points[2].elevation])(
buildRevetWallGeometry({ anchor, outward: 1, height: 2.5, material: "dry", role: "outlet" }),
),
dry: revetWallSpec({ revet_form: "돌쌓기(메)" }, { h: 3.0 }, true, 0.3).pureHeight,
concrete: revetWallSpec({ revet_form: "콘크리트" }, { h: 7.5 }, true, 0.3).pureHeight,
}));
@@ -84,6 +87,11 @@ def test_벽_외곽_한_벌_높이_입력_그대로_노면_멈춤(tmp_path: Path
assert got["under"] == pytest.approx(102.0), "노면 아래 벽이 잘림"
assert got["dry"] == pytest.approx(3.0), "돌쌓기(메) 3.0 이 잘림"
assert got["concrete"] == pytest.approx(7.5), "콘크리트 7.5 가 잘림"
# 371-1 — 성토 위 이음 = 윗면 사면 쪽 끝에서 0.1 m 안 · 무늬 이음선(topJoint)은 그대로
fill_offset, fill_elevation, joint_offset, front_offset, front_elevation = got["fill"]
assert fill_offset == pytest.approx(front_offset - 0.1), "성토 위 이음이 끝에서 0.1 m 아님"
assert fill_elevation == pytest.approx(front_elevation), "성토 위 이음이 윗면 표고 아님"
assert joint_offset == pytest.approx(front_offset - 0.45), "무늬 이음선이 옮겨짐"
def test_네_벽이_공용_함수로_서고_형태_한도가_없다() -> None:
+1 -1
View File
@@ -95,7 +95,7 @@ def test_아래_단추는_네_개_보기_전환_글자만_따라감(results):
# [확정]은 종단 보기에서도 켜진다(한 벌 저장이 횡단 자료를 같은 페이지에서 얻음).
assert views[name]["dockDisabled"] == [False, False, False, False]
assert views["profile"]["dockLabels"][3] == "횡단 보기"
assert views["cross"]["dockLabels"][3] == "종단 보기"
assert views["cross"]["dockLabels"][3] == "노선 보기"
assert views["back"]["dockLabels"][3] == "횡단 보기"
+13 -2
View File
@@ -287,7 +287,7 @@ def test_엑셀_보기_창_행과_호표():
assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음
assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O)
assert sheet["last"] == ["AA", "A"]
assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체
assert sheet["rows"] == ["B4345:F4345", "B93:F101"] # 단가산출 행 B~F 열
# ── 소스 ──────────────────────────────────────────────────────────────
@@ -348,7 +348,10 @@ def test_좌측_판_자리와_권한():
assert "size:" not in up and "엑셀 올리기" not in hopyo_side
assert "buildHopyoView" not in hopyo_side
assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side
assert "onClose:" in hopyo_side and "onDeleted: () => void refresh()," in hopyo_side
assert (
"onClose:" in hopyo_side
and "onDeleted: opt.canWrite ? () => void refresh() : undefined," in hopyo_side
)
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
@@ -524,3 +527,11 @@ def test_명사형_단추와_별칭_장부_풀이_359():
'label: "지우기"',
):
assert gone not in every, gone
def test_372_2_저장은_개인_층에_삭제는_화면_층_권한():
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
open_ = side.split("sheet.openHopyoSheet({")[1].split("});")[0]
assert 'target: { layer: "personal", owner: null },' in open_
assert "canWrite: true," in open_ # 만들기 = 개인 층 · 화면 층 무관
assert "onDeleted: opt.canWrite ? () => void refresh() : undefined," in open_
+1 -1
View File
@@ -53,7 +53,7 @@ def test_실제_응답_화면_도우미(client):
assert states == {"맞음": 122, "다른 점": 86, "후보": 60} # 268 줄 · 셈 끝
assert got["count"]["top"] == 9 # 「후보」 줄 1순위 근거 맞음(근거 맞는 줄은 거의 숫자로 풀림)
assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄
assert got["span"] == "B20:O24" and got["rows"] == "4229:4244"
assert got["span"] == "B20:O24" and got["rows"] == "B4229:F4244"
by = {x["이름"]: x for x in got["lines"]}
dig = by["구조물터파기(인력100%)"]
assert dig["source"] == "출처 건설품셈 3-2-1 · 직종 특별인부 · 단가산출 4229–4244행"
@@ -120,3 +120,15 @@ def test_확인_창_세_칸_만들기_값(client):
assert seoul["대상"][0]["세칸"]["자재구분"] == "사급" # 고른 자재 이름(-사급) 먼저
rows = (M02 / "M02_MasterTemplete_Hopyo_CheckRows.ts").read_text(encoding="utf-8")
assert "...markCells(p?.세칸 ??" in rows
def test_복사_때_실무자료_본뜬과_전국자재_지킴():
from M02_MasterTemplete import M02_Labor_Ref as refs
ref = {"층": "personal", "주인": "7/42", "키": "SC-000001"}
mine = {"층": "실무자료", "키": "HP-000001 제1호표", "전국자재": [REMICON]}
kept = refs.origin_mark({"본뜬": mine}, ref)["본뜬"]
assert kept["키"] == "HP-000001 제1호표" and kept["전국자재"] == [REMICON] # 덮지 않음
assert kept["복사원본"] == ref # 원본 참조는 다른 칸
assert refs.origin_mark({"본뜬": None}, ref)["본뜬"] == ref # 보통 문서 = 옛 그대로
assert refs.origin_mark({"본뜬": {"층": "system", "키": "A"}}, ref)["본뜬"] == ref