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

This commit is contained in:
2026-10-18 09:56:20 +09:00
57 changed files with 1995 additions and 167 deletions
@@ -46,6 +46,7 @@ const out = {
return logic.buildStations(c.line, c.curves, job.pipes, job.structures, {
types: job.types,
stationSnap: c.snap,
stationSnapStep: c.step ?? null, // 390-5 · 252-2 곡선 측점 값 자릿수
});
}),
text: [0, 12.345, 40, 699.996].map(logic.stationText),
@@ -116,7 +116,11 @@ function realStages(savedBase, remote = null) {
return {
route: routeGround.createRouteStage({
minRadiusM: () => job.minRadius,
savedLine: () => ({ line: job.line, curves: [] }),
// 저장 노선 곡선표 = 같은 노드의 편집 곡선(407 곡선 측점 · 빈 표면 옮긴 뒤에만 MC 측점이 생김)
savedLine: () => ({
line: job.line,
curves: routeGround.draftLine(draftOf(job.nodes), job.minRadius).curves,
}),
}),
follow: grade.followStage,
stations: stationsMod.createStationsStage({ types: () => job.types }),
@@ -69,6 +69,8 @@ function editor(snap) {
apexLock: Array(n).fill(false),
limitRadiusM: LIMITS.limitRadiusM,
limitArcM: LIMITS.limitArcM,
line: [],
snap: snap ? { stepM: LIMITS.intervalM, stationM: LIMITS.intervalM } : null,
};
const status = { textContent: "" };
const markEdited = () => {
@@ -139,11 +141,14 @@ for (const snap of [false, true]) {
ed.pick(1);
h.onRadius(40);
row.r40 = round(ed.drawn(1).radius_m);
const later = () => [2, 3].map((k) => round(ed.mc(k))); // 뒤쪽 곡선 MC(408-1)
row.r40Later = later();
h.onRadius(60);
row.r60 = round(ed.drawn(1).radius_m);
row.r60Shown = round(ed.lastShow().radiusShown);
row.r60Status = ed.status.textContent;
row.r60Mc = round(ed.mc(1));
row.r60Later = later();
h.onArcLength(18);
row.l18 = round(ed.arcOf(1));
h.onArcLength(25);
@@ -36,10 +36,19 @@ global.window = {
clearTimeout: () => {},
clearInterval: () => {},
};
let prevented = 0;
const key = (type, name) =>
(listeners[type] || []).forEach((fn) =>
fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }),
fn({
key: name,
repeat: false,
target: null,
preventDefault: () => (prevented += 1),
stopPropagation() {},
}),
);
/** 공용 고름에 적은 것(409 풀기) — [값, 낸 쪽]. */
const written = [];
const STUBS = {
"../A00_Common/touch_bridge": {
@@ -50,7 +59,16 @@ const STUBS = {
"../A00_Common/input_prefs": { isTrackpadPinch: () => false },
"../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 },
"../A00_Common/design/design_store": { draftProjectId: () => "p1" },
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection },
"../A00_Common/design/design_selection": {
PIPE_MATCH_M: 0.51,
NO_SELECTION: { chainageM: null, structureId: null },
readSelection: () => selection,
selectDesign: (projectId, value, options) => {
written.push([value.chainageM, options.from]);
selection = value.chainageM === null ? null : value;
},
},
"../A00_Common/design/design_pick_escape": { PLAN_PICK_FROM: "b05-plan" },
"../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true },
"./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 },
"./B05_Profile_Api_Structures": {
@@ -315,7 +333,24 @@ const was = redrawn;
marks.planMarksChanged("p1");
out.redrawnOnChange = redrawn - was;
// ⑥ 순수 셈
// ⑥ 고름 풀기(409) — 고른 것 없으면 아무 일 없음 · 있으면 공용 고름을 b05-plan 으로 비움 · Esc 도
selection = null;
out.deselectNone = [input.deselect(), written.length];
selection = { chainageM: 40, structureId: "A" };
out.deselectPicked = [input.deselect(), ...written.slice(-1), selection];
selection = { chainageM: 80.17, structureId: null }; // 관 고름(누가거리만)
prevented = 0;
key("keydown", "Escape");
out.escapePicked = [...written.slice(-1), selection, prevented];
const writes = written.length;
key("keydown", "Escape"); // 고른 것 없음 = 공용 Esc 몫(안 막음)
out.escapeNone = [written.length - writes, prevented];
// 마커 누름 = 고름(공용 고름 길) · 법선 · 창 — 그린 차례는 `_Render` 맨 끝(시험이 원본으로 봄)
input.press(80, 0);
input.release();
out.pressAfterRelease = selects[selects.length - 1];
// ⑦ 순수 셈
out.logic = {
round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)],
clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)],
@@ -0,0 +1,35 @@
/* 408-2 평면 상태줄 올림 — `logic/B05_Profile_RouteEdit_InfoLift.ts` 를 Node 로 돌려 JSON 한 줄
* (`test_408_2_status_lift.py` 가 판정).
* node helper_408_2_status_lift.cjs
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(
__dirname,
"..",
"..",
"B05_Profile",
"logic",
"B05_Profile_RouteEdit_InfoLift.ts",
);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const { infoLiftPx, bottomRowNarrow } = box.exports;
// 화면 1078 px · 지도 칸 0~1078
process.stdout.write(
JSON.stringify({
open: infoLiftPx(1078, 1078, 430), // 종단 판 열림(648 px)
collapsed: infoLiftPx(1078, 1078, 1077), // 접힘(테두리 1 px)
popout: infoLiftPx(1078, 1078, null), // 창 분할 = 판 없음
below: infoLiftPx(600, 600, 700), // 횡단 보기 = 판이 지도 아래 흐름
tall: infoLiftPx(1078, 1078, 20), // 판을 끝까지 올림 = 지도 안에 남김
// 408-4 지도 칸 1172 px · 횡단 판 접힘(손잡이 32) · 460 열림 · 891 넓힘 — 반 폭 200 아래면 쌓음
narrow: [32, 492, 891, 736, 737].map((side) => bottomRowNarrow(1172, side)),
}),
);
@@ -0,0 +1,161 @@
/* 408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — `_CurveBar` 배선을 Node 로 돌려 JSON 한 줄
* (`test_408_radius_downstream.py` 가 판정). 창(`_Label`)은 손잡이만 잡는 가짜 · 편집 창 `applyEdit` 는
* 같은 길(스냅 켬 = `fitStart` → `fitCurveStations` → 길이 고정 R · 하한 → `buildEditedPolyline`)로 흉내.
* node helper_408_radius_downstream.cjs
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B05 = path.join(ROOT, "B05_Profile");
const cache = new Map();
let handlers = null;
const STUBS = {
"./B05_Profile_UI_RouteEdit_Label": {
createCurveLabel: (given) => {
handlers = given;
return { show: () => {}, hide: () => {}, destroy: () => {} };
},
centerDirectionOf: () => null,
},
};
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) =>
STUBS[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 curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
const edits = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Edits.ts"));
const frame = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Frame.ts"));
const fit = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StationFit.ts"));
const bar = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_CurveBar.ts"));
// 곡선 넷(IP1~4) · 직선이 길어 맞춤이 접선 절반에 안 막힘
const ROUTE = [
[0, 0],
[110, 0],
[210, 60],
[320, 60],
[420, -10],
[540, -10],
];
const LIMITS = { minRadiusM: 12, limitRadiusM: 12, limitArcM: 5, intervalM: 20 };
const KEYS = ["planned", "curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"];
const copy = (state) =>
Object.fromEntries(
KEYS.map((key) => [key, state[key].map((v) => (Array.isArray(v) ? [...v] : v))]),
);
function editor(snap, apexLock = []) {
const n = ROUTE.length;
const state = {
picked: -1,
planned: ROUTE.map((v) => [v[0], v[1]]),
nodeInfo: [],
curveInfo: [],
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(30),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
apexLock: Array.from({ length: n }, (_, i) => apexLock.includes(i)),
limitRadiusM: LIMITS.limitRadiusM,
limitArcM: LIMITS.limitArcM,
line: [],
snap: snap ? { stepM: LIMITS.intervalM, stationM: 20 } : null,
};
const status = { textContent: "" };
const markEdited = () => {
edits.applyArcLocks(state.planned, state.curveLock, state.curveArc, state.curveRadius);
edits.applyCurveLimits(
state.planned,
state.curveOn,
state.curveRadius,
LIMITS.limitRadiusM,
LIMITS.limitArcM,
);
const built = curve.buildEditedPolyline(
state.planned,
state.curveOn,
state.curveRadius,
LIMITS.minRadiusM,
);
state.line = built.vertices;
state.curveInfo = built.curves;
state.nodeInfo = built.nodes;
};
let committed = null;
const applyEdit = (message) => {
const from = fit.fitStart(committed, state);
if (snap) state.planned = fit.fitCurveStations(state, LIMITS, from);
markEdited();
status.textContent = `예상노선 — ${message} 꼬리`;
committed = copy(state);
};
bar.createCurveBar({
canvas: { getBoundingClientRect: () => ({ left: 0, top: 0, right: 800, bottom: 600 }) },
container: { querySelector: (sel) => (sel === ".b05-routeedit__status" ? status : null) },
state: () => state,
toScreen: (v) => [v[0], v[1]],
applyEdit,
onUnselect: () => {},
});
// 처음 = 스냅 켤 때처럼 전체 맞춤 한 걸음
markEdited();
applyEdit("측점 스냅을 켰습니다.");
const mcs = () =>
frame
.curveSpans(state.line, state.curveInfo)
.map(([a, b]) => Math.round(((a + b) / 2) * 1000) / 1000);
const off = () => fit.offStationCount(state.line, state.curveInfo, LIMITS.intervalM);
const pick = (k) => {
state.picked = k;
};
return { state, status, h: () => handlers, mcs, off, pick };
}
const out = {};
const row = (ed) => ({
mcs: ed.mcs(),
off: ed.off(),
status: ed.status.textContent,
planned: ed.state.planned.map((v) => [...v]),
});
for (const snap of [true, false]) {
const key = snap ? "on" : "off";
const ed = editor(snap);
out[`${key}Start`] = row(ed);
ed.pick(1);
ed.h().onRadius(60); // 앞쪽 곡선 R 키움
out[`${key}R60`] = row(ed);
ed.h().onArcLength(50); // 곡선 길이 입력도
out[`${key}L50`] = row(ed);
ed.h().onRadius(null); // 빈 값 = 자동
out[`${key}Auto`] = row(ed);
}
// IP3 고정 = 그 곡선은 못 맞춤 → 상태줄 「못 맞춘 곡선 No.…」
{
const ed = editor(true, [3]);
out.lockStart = row(ed);
ed.pick(1);
ed.h().onRadius(60);
out.lockR60 = row(ed);
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,102 @@
/* 409-2 평면 마커 고름 풂(b05-plan · 값 없음) = 좌측 폼 · 목록 강조도 풂 — 진짜 `design_selection` ·
* `design_pick_escape` · `design_pick_profile` 을 Node 로 돌림. 종단도 · 폼 · 오른쪽 횡단 판은 흉내.
* 표준출력 JSON 한 벌 · 짝 = test_409_mark_top_deselect.py.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
globalThis.window = { addEventListener() {}, removeEventListener() {} };
globalThis.document = { querySelector: () => null };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
},
});
const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel });
const IRR = { irregularStationId: (id) => `irregular:${id}`, isPipeStation: () => true };
const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
"../A00_Common/design/design_selection": DSel,
"./B05_Profile_UI_IrregularStations": IRR,
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` },
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
});
const PID = "p409";
const sel = (chainageM, structureId, from) =>
DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, {
from,
});
/* 폼 — 관 폼을 닫으면 진짜 폼처럼 관 고름도 비움(`resetForm` → onPipeSelect(null) → b05 풂) */
const form = { open: null, resets: 0 };
const profile = { structure: null };
PP.bindProfilePick(
PID,
{
profile: {
setSelectedStation: () => (profile.structure = null),
setSelectedStructure: (id) => (profile.structure = id),
},
form: {
hasSelection: () => form.open !== null,
selectById: () => {
const wasPipe = form.open === "pipe";
form.open = null;
form.resets += 1;
if (wasPipe) sel(null, null, "b05");
},
},
},
{ visible: () => true, onDispose() {} },
{ irregular: () => [], pipes: () => [{ chainage_m: 80.17 }], sections: () => [] },
);
/* 오른쪽 횡단 판 흉내 — 받은 알림(낸 쪽) */
const crossSaw = [];
DSel.subscribeSelection(PID, (selection, from) => crossSaw.push([selection.chainageM, from]));
const out = {};
// 관 마커 고름 → 폼 열림 → 평면 마커 고름 풂
sel(80.17, null, "b05");
form.open = "pipe";
crossSaw.length = 0;
sel(null, null, ESC.PLAN_PICK_FROM);
out.pipeRelease = { form: { ...form }, crossSaw: [...crossSaw], session: DSel.readSelection(PID) };
// 구조물 고름 → 폼 → 풂
sel(40, "A", "b05");
form.open = "A";
sel(null, null, ESC.PLAN_PICK_FROM);
out.structureRelease = { form: { ...form }, pill: profile.structure };
// 평면 눈금 클릭(값 있음 · b05-plan) = 폼 안 닫음(옛 그대로)
form.open = "A";
sel(20, null, ESC.PLAN_PICK_FROM);
out.tickKeepsForm = form.open;
// 폼 없음 = 안 부름
form.open = null;
const resets = form.resets;
sel(null, null, ESC.PLAN_PICK_FROM);
out.noFormNoReset = form.resets - resets;
out.planName = ESC.PLAN_PICK_FROM;
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,160 @@
/* 411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단 · 옮긴 뒤에도 열쇠로 따라감.
* `_RouteEdit_Cross` 를 Node 로 돌려 JSON 한 줄(`test_411_a_cross_structure.py` 가 판정). 묶음 · 그림 · 가게는 가짜 ·
* 고름 구독 · 측점 짚기 · 판 채우기는 진짜.
* node helper_411_a_cross_structure.cjs
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
const out = {};
/* 가짜 DOM — 판(제목 · 상자 · 발) 글만 봄 */
function fakeElement() {
const parts = new Map();
const element = {
textContent: "",
children: [],
isConnected: true,
clientHeight: 150,
setAttribute() {},
append: (...items) => element.children.push(...items),
replaceChildren: (...items) => (element.children = items),
getBoundingClientRect: () => ({ width: 240, height: 150 }),
querySelector: (selector) => {
if (!parts.has(selector)) parts.set(selector, fakeElement());
return parts.get(selector);
},
};
return element;
}
global.document = { createElement: fakeElement };
global.ResizeObserver = class {
observe() {}
disconnect() {}
};
let listener = null;
let stations = [];
const route = { id: 1 };
const subscribers = [];
const pipe = {
output: (stage) => (stage === "ground" ? { stations } : {}),
input: () => route,
subscribe: (fn) => (subscribers.push(fn), () => {}),
};
const crossStage = () => subscribers.forEach((fn) => fn({ kind: "stage", stage: "cross" }));
const STUBS = {
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan() {} },
"../A00_Common/design/design_pipeline_page": { pagePipeline: () => pipe },
"../A00_Common/design/design_selection": {
nearestWithin: (items, at, of) => {
let best;
let gap = 0.51;
for (const item of items)
if (Math.abs(of(item) - at) < gap) [best, gap] = [item, Math.abs(of(item) - at)];
return best;
},
selectDesign() {},
subscribeSelection: (_projectId, fn) => ((listener = fn), () => {}),
},
"../A00_Common/design/design_pick_escape": { PLAN_PICK_FROM: "b05-plan" },
"../A00_Common/design/design_store": {
useDesignStore: () => ({ subscribe: () => () => {}, get: () => null }),
},
"./logic/B05_Profile_Pipeline_Detail": {
stationSection: (station) => ({ chainage_m: station.chainageM, samples: [] }),
},
"./B05_Profile_UI_RouteEdit_Cross_Draw": {
buildCrossSvg: () => ({ svg: fakeElement(), layer: null, width: 240, height: 150 }),
CROSS_PLOT_PAD: 0,
summarizeCross: (preview) => `횡단 ${preview.chainage_m}`,
},
"../A00_Common/b_page_state": { readStateRaw: () => null },
"../A00_Common/design/design_page_route": { pageRouteId: () => null },
"../common_util/common_util_station": { stationLabel: (chainageM) => String(chainageM) },
"./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new Map() },
"./B05_Profile_UI_RouteEdit_Cross_Card": {
routeCrossCard: () => fakeElement(),
routeCrossSource: () => null,
},
};
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 (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const cross = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Cross.ts"));
const station = (key, chainageM, kind, id = null) => ({ key, chainageM, kind, id });
const grid = [
station("end:BP", 0, "end"),
station("grid:1", 20, "grid"),
station("grid:2", 40, "grid"),
];
const side = fakeElement();
const preview = cross.createCrossPreview({
projectId: "p1",
side,
request: () => ({ vertices: [], min_radius_m: 15, station_interval_m: 20 }),
});
const [current] = side.children;
const shownNow = () => ({
station: current.querySelector(".b05-routeedit__cross-station").textContent,
foot: current.querySelector(".b05-routeedit__cross-foot").textContent,
});
const pick = (chainageM, structureId, from) => listener({ chainageM, structureId }, from);
// ① 구조물 고름(id) = 그 측점 횡단 · 법선은 마커 몫(structure)
stations = [
...grid,
station("structure:A", 45, "structure", "A"),
station("pipe:p1", 80.17, "pipe", "p1"),
];
pick(45, "A", "b05");
out.structurePick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
// ② 옮김 — 묶음이 새 자리로(같은 열쇠) · cross 차례 = 새 자리 횡단(고름은 옛 45 그대로)
stations = [
...grid,
station("structure:A", 52, "structure", "A"),
station("pipe:p1", 80.17, "pipe", "p1"),
];
crossStage();
out.structureMoved = { ...shownNow(), at: preview.watched()?.chainageM ?? null };
// ③ 관 고름(누가거리만) = 그 측점 횡단
pick(80.17, null, "b05");
out.pipePick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
// ④ 평면 마커 고름 풂(b05-plan) = 판 그대로
pick(null, null, "b05-plan");
out.planRelease = shownNow();
// ⑤ 일반측점 고름 = 그 측점 횡단 · 법선(structure 없음)
pick(20, null, "b05");
out.gridPick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
// ⑥ 묶음 전 구조물 고름 — 열쇠 없이 기다렸다 묶음 뒤 열쇠로 붙듦 · 그 뒤 옮김도 따라감
stations = [];
pick(60, "B", "b05");
out.beforePipeline = shownNow();
stations = [...grid, station("structure:B", 60, "structure", "B")];
crossStage();
out.afterPipeline = shownNow();
stations = [...grid, station("structure:B", 66, "structure", "B")];
crossStage();
out.latchedMoved = shownNow();
// ⑦ 다른 보기가 풂(null · b05) = 판 비움(382-2 그대로)
pick(null, null, "b05");
out.otherRelease = { ...shownNow(), watched: preview.watched() };
console.log(JSON.stringify(out));
+9 -1
View File
@@ -241,7 +241,15 @@ def test_켬은_640_없고_631_있음() -> None:
assert 640.0 not in by_chainage and 631.0 in by_chainage
assert by_chainage[631.0]["kind"] == "regular"
assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경
assert len(by_chainage) == len(_stations(False))
# 끔 = 640 남고 MC(631)에 곡선 측점 하나 더(252-2) · 켬 = 640 을 631 로 옮김(측점 수 그대로)
assert len(by_chainage) == len(_stations(False)) - 1
def test_끔은_MC_에_곡선_측점() -> None:
by_chainage = {s["chainage_m"]: s for s in _stations(False)}
assert by_chainage[640.0]["kind"] == "regular"
assert by_chainage[631.0]["kind"] == "curve"
assert by_chainage[631.0]["label"].endswith(" (MC)")
def test_631_에_관이면_grid() -> None:
@@ -192,7 +192,13 @@ def _close(a, b) -> bool:
return abs(float(a) - float(b)) <= _TOL
_KIND = {"bp": {"end"}, "ep": {"end"}, "regular": {"grid"}, "irregular": {"pipe", "structure"}}
_KIND = {
"bp": {"end"},
"ep": {"end"},
"regular": {"grid"},
"irregular": {"pipe", "structure"},
"curve": {"curve"}, # 252-2 곡선 측점(MC)
}
@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES])
@@ -47,6 +47,9 @@ CASES = [
("irregular", 1.005, 20.0, None, "structure"),
("pipe", 12.344, 20.0, "pipe-s1", "structure"),
("structure", 12.345, 20.0, None, "structure"),
# 252-2 곡선 측점(MC) — curve:<누가거리 0.01 m>
("curve", 107.0, 20.0, None, "structure"),
("curve", 107.35, 20.0, None, "structure"),
]
DEDUPE = (["grid:1", "structure:st-1", "grid:1", "structure:st-1"], [20.0, 25.0, 20.004, 25.006])
LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"]
@@ -56,9 +56,10 @@ def test_상태_범례_접기_한_줄_기억() -> None:
def test_상태_글판_작게_반투명_자리() -> None:
info = _rule(".b05-routeedit__info")
# 왼쪽 아래 제자리 — 뜬 판이 덮으면 가려진 채(349-0) · 폭 줄임
assert "bottom: var(--spacing-12);" in info
assert "max-width: min(460px, 46%);" in info and "grid-template-columns:" in info
# 왼쪽 아래 제자리 — 뜬 판이 덮으면 그 위로(408-2) · 폭 줄임
assert "bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));" in info
assert "max-width: min(460px, 46%, var(--b05-bottom-half));" in info # 408-4 반 폭
assert "grid-template-columns:" in info
# 상태 글 두 줄에서 자름 · 범례는 옆 칸 두 줄
assert "-webkit-line-clamp: 2;" in _rule(".b05-routeedit__status")
assert "flex-direction: column;" in _rule(".b05-routeedit__legend")
+15 -3
View File
@@ -38,7 +38,7 @@ def test_반지름_40_60_곡선이_바뀜(got, snap) -> None:
row = got[snap]
assert row["r40"] == pytest.approx(40) and row["r60"] == pytest.approx(60)
assert row["r60Shown"] == pytest.approx(60)
assert "반지름을 바꿨습니다." in row["r60Status"]
assert "반지름을 바꿨습니다" in row["r60Status"]
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
@@ -47,7 +47,7 @@ def test_곡선_길이_18_25_곡선이_바뀜(got, snap) -> None:
tol = 0.1 if snap == "snapOn" else 1e-6 # 스냅 켬 = IP 맞춤으로 교각이 조금 바뀐 뒤 값
assert row["l18"] == pytest.approx(18, abs=tol) and row["l25"] == pytest.approx(25, abs=tol)
assert row["l25Shown"] == pytest.approx(row["l25"])
assert "곡선 길이를 바꿨습니다." in row["l25Status"]
assert "곡선 길이를 바꿨습니다" in row["l25Status"]
def test_스냅_켬_MC_가_측점에(got) -> None:
@@ -55,6 +55,18 @@ def test_스냅_켬_MC_가_측점에(got) -> None:
assert got["snapOff"]["r60Mc"] != pytest.approx(120, abs=0.05)
def test_스냅_켬_R_바꾼_뒤_뒤쪽_곡선_MC(got) -> None:
"""408-1 — 고친 곡선 뒤 곡선도 맞춤 · 측점 위였던 곡선은 R 바꾼 뒤에도 측점 위."""
on = got["snapOn"]
def hit(mc: float) -> bool:
return abs(mc - round(mc / 20) * 20) <= 0.05
assert [hit(mc) for mc in on["r60Later"]] >= [hit(mc) for mc in on["r40Later"]]
assert all(hit(mc) for mc in on["r60Later"])
assert "함께 밀린 교점 " in on["r60Status"] and "함께 밀린" not in got["snapOff"]["r60Status"]
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -> None:
row = got[snap]
@@ -62,7 +74,7 @@ def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -
want = f"반지름 60m → {round(row['capR'], 1)}m(앞뒤 직선이 짧아 접선 자리 모자람)"
assert want in row["capStatus"]
assert "곡선 길이 40m → " in row["capArcStatus"]
assert "반지름을 자동으로 되돌렸습니다." in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
assert "반지름을 자동으로 되돌렸습니다" in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
def test_고정(got) -> None:
@@ -65,18 +65,23 @@ def test_공용_고름과_같은_길() -> None:
assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수
assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view
render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts")
labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks")
labels = render.index("drawContourLabels(context, scene, band, style);")
nodes = render.index("scene.planned.forEach((vertex, index) => {")
assert labels < nodes # 라벨 위 · 교각점 아래
hover = render.index("drawPickHover(context, scene, line, style, NODE_R);")
marks = render.index("drawStructureMarks(context, scene, line, style);")
turned = render.index("context.restore(); // 회전 끝")
assert labels < nodes < hover < marks < turned # 맨 위(409 — 교각점 · 손잡이 · 맴돔 위)
def test_집기_차례는_교각점_손잡이_다음() -> None:
def test_집기_차례는_재기_다음_맨_앞() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
measure = pointer.index("void params.measure.pick(px, py);")
mark = pointer.index("if (marks.press(px, py)) {")
release = pointer.index("marks.deselect();")
node = pointer.index("if (hitNode >= 0) {")
handle = pointer.index("} else if (dragHandle) {")
mark = pointer.index("} else if (marks.press(px, py)) {")
tick = pointer.index('if (!pick || !("chainage" in pick)) return false;')
assert node < handle < mark < tick
assert measure < mark < release < node < handle < tick # 409
def test_끌기_1m_정수_놓을_때_한_번(got) -> None:
@@ -0,0 +1,130 @@
"""PLAN 407-3 — 구조물을 MC 위에 놓았다 빼면 서버 종단 정본의 곡선 측점이 되살아남.
[저장] 차례(관 · 구조물 → 횡단) 의 `ensure_structure_stations` 길 — `_curve_marks`(어긋남 판정) ·
`sample_structure_sections(with_curves=True)`(곡선 측점도 뜸) · `_merge_irregular_into_longitudinal`
(옛 곡선 측점도 새 목록이 갈음). 노선 저장을 안 기다림 · 놓은 동안엔 그 자리 구조물 측점 하나.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections as sections # noqa: E402
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import ( # noqa: E402
_merge_irregular_into_longitudinal,
)
from B06_Section.B06_Section_Router_Stations import _curve_marks # noqa: E402
LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]]
SHORT = {"start": [101.0, 0.0], "apex": [107.3, 2.0], "end": [113.6, 0.0], "radius_m": 30.0}
LONG = {"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}
class _Flat:
def sample_xy(self, xy):
count = len(np.asarray(xy, dtype=np.float64))
return np.full(count, 100.0), np.ones(count, dtype=bool)
@pytest.fixture
def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
"""노선 · 곡선표 · 관 · 구조물 정본 대신 — 바꿀 수 있는 구조물 측점 하나(`extras`)."""
state: dict = {"extras": ()}
monkeypatch.setattr(sections, "_load_route_polyline", lambda *_: LINE)
monkeypatch.setattr(sections, "_load_design_curves", lambda *_: [SHORT, LONG])
monkeypatch.setattr(sections, "load_station_snap", lambda *_: False)
monkeypatch.setattr(sections, "load_station_snap_step", lambda *_: None)
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_, **__: _Flat())
monkeypatch.setattr(sections, "_load_pipe_points", lambda *_: [])
monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_: state["extras"])
# 노선 저장 때 정본 = 전체 생성(곡선 측점 107 · 631)
full = core.generate_sections(LINE, _Flat(), design_curves=[SHORT, LONG])["longitudinal"]
folder = tmp_path / "B06_Section" / "longitudinal"
folder.mkdir(parents=True)
(tmp_path / "B06_Section" / "cross_sections").mkdir()
(folder / "longitudinal.json").write_text(json.dumps(full), encoding="utf-8")
state["root"] = tmp_path
return state
def _save(state: dict) -> list[dict]:
"""[저장] 의 횡단 차례 — 어긋나면 다시 떠 병합(`ensure_structure_stations` 와 같은 길)."""
root = state["root"]
options = core.SectionGenerationOptions()
if _curve_marks(root, "route.json", options) == 0 and not state["extras"]:
return _stations(root)
fresh = sections.generate_irregular_sections(
root, "route.json", "f", "m", False, extra_stations=state["extras"], options=options
)
_merge_irregular_into_longitudinal(root, "B06_Section/longitudinal/longitudinal.json", fresh)
return _stations(root)
def _stations(root: Path) -> list[dict]:
path = root / "B06_Section" / "longitudinal" / "longitudinal.json"
return json.loads(path.read_text(encoding="utf-8"))["stations"]
def _at(stations: list[dict], chainage: float) -> list[str]:
return [s["kind"] for s in stations if abs(s["chainage_m"] - chainage) < 0.01]
def test_처음은_맞음(project) -> None:
assert _curve_marks(project["root"], "route.json", core.SectionGenerationOptions()) == 0
assert _at(_stations(project["root"]), 107.0) == ["curve"]
def test_MC_위_구조물_놓음_뺌(project) -> None:
root = project["root"]
project["extras"] = ((107.0, "관"),)
assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 1
placed = _save(project)
assert _at(placed, 107.0) == ["irregular"] # 놓은 동안 = 구조물 측점 하나
assert _at(placed, 631.0) == ["curve"] # 다른 곡선 측점은 그대로
assert list((root / "B06_Section" / "cross_sections").glob("cross_*.json"))
project["extras"] = ()
assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 1
removed = _save(project)
assert _at(removed, 107.0) == ["curve"] # 노선 저장 없이 되살아남
assert _at(removed, 631.0) == ["curve"]
assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 0
assert len(removed) == len(_stations_full())
def _stations_full() -> list[dict]:
return core.generate_sections(LINE, _Flat(), design_curves=[SHORT, LONG])["longitudinal"][
"stations"
]
def test_planned_curve_stations_는_생성과_같음() -> None:
extras = ((107.0, "관"), (300.0, "옹벽"))
for snap, step in [(False, None), (True, None), (True, 0.1)]:
options = core.SectionGenerationOptions(extra_stations=extras)
made = core.generate_sections(
LINE,
_Flat(),
options,
design_curves=[SHORT, LONG],
station_snap=snap,
station_snap_step=step,
)["longitudinal"]["stations"]
planned = core.planned_curve_stations(
LINE,
options,
design_curves=[SHORT, LONG],
station_snap=snap,
station_snap_step=step,
)
assert planned == [s["chainage_m"] for s in made if s["kind"] == "curve"]
@@ -0,0 +1,96 @@
"""PLAN 407-4 — 종단 측점 · 구조물 이름표 비켜 그리기(`logic/B06_Section_Label_Tiers.ts`) ·
407-5 — B05 종단 아래 표 겹친 칸 세우기(`crowdedThickness` · 두께 = 이웃 사이 틈 − 1).
가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단 · 안 겹치면 제자리(0) · 이름은 지우지 않음 ·
단 수 위 끝(`MAX_LABEL_TIER`) 넘으면 마지막 단 · 글 폭 짐작(한글 = 글자 크기).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS = ROOT / "B06_Section" / "logic" / "B06_Section_Label_Tiers.ts"
TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript"
SCRIPT = r"""
const ts = require(process.argv[1]);
const fs = require("fs");
const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const m = box.exports;
const cases = JSON.parse(process.argv[3]);
process.stdout.write(JSON.stringify({
max: m.MAX_LABEL_TIER,
tiers: cases.map((items) => m.labelTiers(items)),
widths: ["19+18 (MC)", "20+0", "배수관"].map((t) => m.labelWidthPx(t, 10)),
thick: [
m.crowdedThickness([0, 50, 100], 40, 16),
m.crowdedThickness([100, 108, 200], 40, 16),
m.crowdedThickness([100, 130, 200], 40, 16),
m.crowdedThickness([100, 104, null, 106], 40, 16),
m.crowdedThickness([100, null, 103], 40, 16),
],
}));
"""
W = 40.0 # 이름표 폭(px)
CASES = [
[{"x": 100, "width": W}, {"x": 200, "width": W}, {"x": 300, "width": W}], # 안 겹침
[{"x": 100, "width": W}, {"x": 108, "width": 24}], # 1.6 m 이웃 — 겹침
[{"x": 108, "width": 24}, {"x": 100, "width": W}], # 차례 뒤바뀜(x 로 셈)
[{"x": 100 + i * 2, "width": W} for i in range(6)], # 여섯 겹 — 위 끝
[{"x": 100, "width": W}, {"x": 110, "width": W}, {"x": 150, "width": W}], # 셋째는 제자리
]
@pytest.fixture(scope="module")
def r() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS), json.dumps(CASES)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_안_겹치면_제자리(r) -> None:
assert r["tiers"][0] == [0, 0, 0]
def test_겹치면_위_단(r) -> None:
assert r["tiers"][1] == [0, 1]
assert r["tiers"][2] == [1, 0] # 왼쪽 것이 제자리
def test_위_끝_넘으면_마지막_단(r) -> None:
assert r["tiers"][3] == [0, 1, 2, 3, 3, 3] # 이름은 남음(지우지 않음)
def test_빈_단은_다시_씀(r) -> None:
assert r["tiers"][4] == [0, 1, 0]
def test_글_폭(r) -> None:
mc, plain, korean = r["widths"]
assert mc == pytest.approx(60.0) and plain == pytest.approx(24.0)
assert korean == pytest.approx(30.0)
def test_표_칸_세우기(r) -> None:
no, near, wide, empty, floor = r["thick"]
assert no == [None, None, None] # 안 겹침 = 그대로
assert near == [7, 7, None] # 1.6 m 이웃(8 px) = 둘 다 세움 · 두께 7
assert wide == [16, 16, None] # 틈 넓으면 위 끝 16
assert empty == [6, 6, None, 6] # 빈 칸(null) 건너 이웃 · 아래 끝 6
assert floor == [6, None, 6]
+173
View File
@@ -0,0 +1,173 @@
"""PLAN 407 · 요구 252-2 — 곡선 측점(MC 에 측점이 없으면 하나 더함).
짝: 파이썬 `B05_Profile_Engine_Sections_Core.curve_stations` · `generate_sections` ↔ TS
`logic/B05_Profile_UI_RouteEdit_Frame.ts` `curveStations` · `logic/B05_Profile_Sections_Stations.ts`
`buildStations`(헬퍼 `helper_344_3_pipeline_stations.cjs`). 열쇠 `curve:<누가거리>` ·
이름 뒤 「 (MC)」 ·
값 = 스냅 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 · 스냅 끔 = 정수) · 일반측점 값과 같으면 안 더함 ·
스냅 켬 · 호 안 일반측점 = 224-2 대로 옮김(곡선 측점 없음) · BC · EC 안 더함.
"""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
from common_util.common_util_station_key import station_key # noqa: E402
LINE = [[0.0, 0.0], [1000.3, 0.0]]
LONG = {"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}
SHORT = {"start": [101.0, 0.0], "apex": [107.3, 2.0], "end": [113.6, 0.0], "radius_m": 30.0}
ON_GRID = {"start": [195.0, 0.0], "apex": [200.1, 2.0], "end": [205.2, 0.0], "radius_m": 30.0}
CURVES = [LONG, SHORT, ON_GRID]
GRID = [float(value) for value in range(0, 1001, 20)]
class _Flat:
def sample_xy(self, xy):
count = len(np.asarray(xy, dtype=np.float64))
return np.full(count, 100.0), np.ones(count, dtype=bool)
def _server(snap: bool, step: float | None = None, extras: tuple = ()) -> list[dict]:
data = core.generate_sections(
LINE,
_Flat(),
core.SectionGenerationOptions(extra_stations=extras),
design_curves=CURVES,
station_snap=snap,
station_snap_step=step,
)
return data["longitudinal"]["stations"]
def _curves(stations: list[dict]) -> list[float]:
return [s["chainage_m"] for s in stations if s["kind"] == "curve"]
# ── 파이썬 규칙 ────────────────────────────────────────────────────────────────
def test_정수_MC_하나() -> None:
assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3) == [107.0]
def test_스냅_간격_0_1_이면_소수_한_자리() -> None:
assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3, 0.1) == [107.3]
assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3, 10.0) == [107.0]
def test_일반측점_값이면_안_더함() -> None:
assert core.curve_stations(GRID, [(195.0, 205.2)], 1000.3) == []
def test_MC_옆_관_측점이면_안_더함() -> None:
assert core.curve_stations([*GRID, 107.003], [(101.0, 113.6)], 1000.3) == []
def test_노선_밖_MC_는_뺌() -> None:
assert core.curve_stations([0.0], [(995.0, 1010.0)], 1000.3) == [] # MC 1003
def test_스냅_끔_MC_마다_곡선_측점() -> None:
stations = _server(False)
assert _curves(stations) == [107.0, 631.0] # 200 = 일반측점 위 → 안 더함
by = {s["chainage_m"]: s for s in stations}
assert by[640.0]["kind"] == "regular" and by[200.0]["kind"] == "regular"
assert by[107.0]["label"] == "STA.0+107.000 (MC)"
assert len(stations) == len(_server(False, extras=())) == 51 + 1 + 2 # 격자 · EP · MC 둘
assert 101.0 not in by and 113.6 not in by # BC · EC 안 더함
def test_스냅_켬_짧은_호만_곡선_측점() -> None:
stations = _server(True)
by = {s["chainage_m"]: s for s in stations}
assert _curves(stations) == [107.0]
assert by[631.0]["kind"] == "regular" and 640.0 not in by # 224-2 옮김 그대로
def test_스냅_켬_0_1_간격() -> None:
stations = _server(True, 0.1)
by = {s["chainage_m"]: s for s in stations}
assert _curves(stations) == [107.3]
assert 631.3 in by and by[200.1]["kind"] == "regular" # 옮긴 일반측점
def test_MC_에_구조물이면_구조물_측점_하나() -> None:
stations = _server(False, extras=((107.0, "관"),))
by = {s["chainage_m"]: s for s in stations}
assert by[107.0]["kind"] == "irregular" and _curves(stations) == [631.0]
def test_열쇠() -> None:
assert station_key("curve", 107.0, 20.0) == "curve:107.00"
assert station_key("curve", 107.3, 20.0) == "curve:107.30"
# ── 파이썬 ↔ TS 같은 목록 ─────────────────────────────────────────────────────
CASES = [
{"name": "끔", "snap": False, "step": None},
{"name": "켬", "snap": True, "step": None},
{"name": "켬0.1", "snap": True, "step": 0.1},
{"name": "끔0.1", "snap": False, "step": 0.1}, # 끔이면 간격 무시 = 정수
]
@pytest.fixture(scope="module")
def ts_rows() -> list[list[dict]]:
job = {
"cases": [{"line": LINE, "curves": CURVES, **case} for case in CASES],
"pipes": [],
"structures": [],
"types": [],
"move": {
"line": LINE,
"curves": CURVES,
"movedLine": LINE,
"movedCurves": CURVES,
"fromM": 0.0,
},
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_344_3_pipeline_stations.cjs"), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)["cases"]
@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES])
def test_파이썬과_TS_같은_목록(ts_rows, index) -> None:
case = CASES[index]
server = _server(case["snap"], case["step"])
ours = ts_rows[index]
assert [s["chainage_m"] for s in server] == pytest.approx(
[s["chainageM"] for s in ours], abs=1e-6
)
for py, ts in zip(server, ours, strict=True):
assert (py["kind"] == "curve") == (ts["kind"] == "curve"), py["chainage_m"]
if ts["kind"] == "curve":
assert ts["key"] == station_key("curve", py["chainage_m"], 20.0)
assert any(s["kind"] == "curve" for s in ours)
# 곡선 측점은 격자 번호를 안 가져감 — 격자 열쇠는 그대로 1 ~ 50 하나씩(0 = BP)
grid = sorted(int(s["key"][5:]) for s in ours if s["kind"] == "grid")
assert grid == list(range(1, 51))
@@ -0,0 +1,47 @@
"""408-2 창 나눔(종단 판 열림) 때 평면 상태줄이 종단 표 밑에 깔림 — 판이 덮은 높이만큼 상태 · 범례를
위로(`logic/B05_Profile_RouteEdit_InfoLift.ts` · `_Chrome` 이 판 크기 관찰로 `--b05-profile-cover` 적음).
TS 셈은 `helper_408_2_status_lift.cjs`(Node).
./venv/Scripts/python.exe -m pytest resources/tester/test_408_2_status_lift.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_408_2_status_lift.cjs")
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_올릴_높이(got) -> None:
assert got["open"] == 648 # 판 윗변 위로
assert got["collapsed"] == 1 and got["popout"] == 0 and got["below"] == 0
assert got["tall"] == 1078 - 120 # 지도 안에 120 px 남김
def test_상태_글판이_올림_값을_씀() -> None:
css = (B05 / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8")
block = css[css.index(".b05-routeedit__info {\n bottom") :]
assert block.startswith(
".b05-routeedit__info {\n bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));"
)
def test_편집기가_판_크기를_관찰() -> None:
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
assert "new ResizeObserver(liftInfo)" in chrome and "lift.observe(overlay)" in chrome
assert '"--b05-profile-cover"' in chrome and ".b05-route-profile" in chrome
assert "lift.disconnect" not in chrome # 붙기 전 첫 알림에 끊으면 영영 안 올라감
@@ -0,0 +1,28 @@
"""408-3 잰 값 창 · 오른쪽 횡단 판(손잡이)도 뜬 종단 판 위로 — 408-2 올림 값(`--b05-profile-cover`) 재사용.
판 열림 때 잰 값 창은 판 밑에 깔리고, 횡단 판 손잡이(판 가운데 높이)도 판 밑이라 횡단을 못 열었음.
./venv/Scripts/python.exe -m pytest resources/tester/test_408_3_measure_side_lift.py -q
"""
from pathlib import Path
import pytest
CSS = Path(__file__).resolve().parents[2] / "B05_Profile" / "B05_Profile_UI_Style_RouteEdit.css"
LIFT = "bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));"
def _rules(selector: str) -> list[str]:
"""그 선택자의 규칙 덩이 모두(같은 선택자가 여러 덩이에 나뉨)."""
css = CSS.read_text(encoding="utf-8")
parts = css.split(f"{selector} {{")[1:]
return [part[: part.index("}")] for part in parts]
@pytest.mark.parametrize(
"selector",
[".b05-routeedit__info", ".b05-routeedit__measure", ".b05-routeedit__side-wrap"],
)
def test_지도_아래_글판_판은_종단_판_위로(selector) -> None:
assert any(LIFT in rule for rule in _rules(selector))
+65
View File
@@ -0,0 +1,65 @@
"""408-4 횡단 판이 넓을 때 상태 글판 · 잰 값 창 겹침 — 횡단 판 왼쪽 빈 폭을 반씩 나눠 쓰고, 반이 200 px
아래면(`bottomRowNarrow`) 잰 값 창을 상태 글판 위로 쌓음(둘 다 빈 폭 전부 · 좁으면 줄바꿈).
TS 셈은 `helper_408_2_status_lift.cjs`(Node · `logic/B05_Profile_RouteEdit_InfoLift.ts`).
./venv/Scripts/python.exe -m pytest resources/tester/test_408_4_bottom_row.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
B05 = Path(__file__).resolve().parents[2] / "B05_Profile"
CSS = B05 / "B05_Profile_UI_Style_RouteEdit.css"
HELPER = Path(__file__).with_name("helper_408_2_status_lift.cjs")
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def _rules(selector: str) -> list[str]:
css = CSS.read_text(encoding="utf-8")
return [part[: part.index("}")] for part in css.split(f"{selector} {{")[1:]]
def test_반_폭이_모자라면_쌓음(got) -> None:
# (1172 - 36 - 736) / 2 = 200 → 나란히 · 737 → 쌓음
assert got["narrow"] == [False, False, True, False, True]
def test_두_글판이_반_폭까지만() -> None:
assert any(
"max-width: min(460px, 46%, var(--b05-bottom-half));" in r
for r in _rules(".b05-routeedit__info")
)
assert any("max-width: var(--b05-bottom-half);" in r for r in _rules(".b05-routeedit__measure"))
half = _rules(".b05-routeedit")[0]
assert (
"--b05-bottom-half: max(0px, calc((100% - 3 * var(--spacing-12) - var(--b05-side-open)) / 2));"
in half
)
def test_좁으면_잰_값_창이_상태_글판_위로() -> None:
narrow = _rules(".b05-routeedit.is-narrow-bottom")[0]
assert "calc(100% - 2 * var(--spacing-12) - var(--b05-side-open))" in narrow
measure = _rules(".b05-routeedit.is-narrow-bottom .b05-routeedit__measure")[0]
assert "var(--b05-info-h, 0px)" in measure and "var(--b05-profile-cover, 0px)" in measure
def test_편집기가_횡단_판_글판_크기를_관찰() -> None:
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
assert (
"lift.observe(info)" in chrome
and 'lift.observe(overlay.querySelector(".b05-routeedit__side-wrap")!)' in chrome
)
assert '"is-narrow-bottom"' in chrome and '"--b05-info-h"' in chrome
@@ -0,0 +1,75 @@
"""408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — 반지름 · 곡선 길이 입력 = 교점 끌기와 같은
`fitCurveStations` 로 고친 곡선부터 끝 곡선까지 MC 를 측점에 다시 맞춤 · 상태줄 「함께 밀린 교점 N곳 ·
못 맞춘 곡선 No.…」(`KeyNudge.fitNote`) · 스냅 끔 = 맞춤 · 보고 없음.
TS 셈은 `helper_408_radius_downstream.cjs`(Node · `_CurveBar` 배선 그대로).
./venv/Scripts/python.exe -m pytest resources/tester/test_408_radius_downstream.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
HELPER = Path(__file__).with_name("helper_408_radius_downstream.cjs")
TOL = 0.05 # MC_TOL_M
STEPS = ["R60", "L50", "Auto"]
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def _on_station(mc: float) -> bool:
return abs(mc - round(mc / 20) * 20) <= TOL
@pytest.mark.parametrize("step", STEPS)
def test_스냅_켬_뒤쪽_곡선_MC_가_측점_위(got, step) -> None:
row = got[f"on{step}"]
assert len(row["mcs"]) == 4 and all(_on_station(mc) for mc in row["mcs"])
assert row["off"] <= got["onStart"]["off"]
@pytest.mark.parametrize("step", STEPS)
def test_스냅_켬_뒤_교점이_밀림_시점은_그대로(got, step) -> None:
before = got["onStart"]["planned"]
after = got[f"on{step}"]["planned"]
assert after[0] == before[0] and after[-1] == before[-1]
assert all(after[k] != before[k] for k in (2, 3, 4)) # 고친 곡선 뒤 교점이 맞춤으로 밀림
def test_상태줄_보고(got) -> None:
assert "반지름을 바꿨습니다 · 함께 밀린 교점 4곳." in got["onR60"]["status"]
assert "곡선 길이를 바꿨습니다 · 함께 밀린 교점 4곳." in got["onL50"]["status"]
assert "반지름을 자동으로 되돌렸습니다 · 함께 밀린 교점 " in got["onAuto"]["status"]
assert all("못 맞춘 곡선" not in got[f"on{step}"]["status"] for step in STEPS)
@pytest.mark.parametrize("step", STEPS)
def test_스냅_끔_맞춤_보고_없음(got, step) -> None:
row = got[f"off{step}"]
assert "함께 밀린" not in row["status"]
assert row["planned"] == got["offStart"]["planned"] # 교점 그대로
assert not all(_on_station(mc) for mc in row["mcs"])
def test_고정_교점_곡선은_못_맞춤으로(got) -> None:
row = got["lockR60"]
assert row["off"] == got["lockStart"]["off"] == 1
assert row["planned"][3] == got["lockStart"]["planned"][3] # 고정 IP3 그대로
mc = row["mcs"][2]
n, rest = int(mc // 20), round(mc - int(mc // 20) * 20)
assert f"함께 밀린 교점 3곳 · 못 맞춘 곡선 No.{n}+{rest}." in row["status"]
assert all(_on_station(row["mcs"][i]) for i in (0, 1, 3)) # 고정 곡선 뒤도 맞춤
@@ -0,0 +1,122 @@
"""409 B05 평면 구조물 마커 맨 위 · 다른 곳 누르면 고름 풀림(요구 252-4).
그림 = 맨 마지막(교각점 · 손잡이 · 맴돔 위) · 누름 · 맴돔 · 커서 = 재기 다음 마커 먼저 ·
마커 누르면 고른 교각점 풂 ·
마커 아닌 곳 누름 · Esc = 공용 고름을 `b05-plan` 으로 비움(오른쪽 횡단 판 그대로).
TS 셈은 `helper_399_plan_structure_marks.cjs`(⑥ 고름 풀기).
./venv/Scripts/python.exe -m pytest resources/tester/test_409_mark_top_deselect.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs")
def _read(*parts: str) -> str:
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_풀기_고른_것_없으면_아무_일_없음(got) -> None:
assert got["deselectNone"] == [False, 0]
def test_풀기_공용_고름을_b05_plan_으로_비움(got) -> None:
assert got["deselectPicked"] == [True, [None, "b05-plan"], None]
def test_Esc_마커_고름만_풂(got) -> None:
assert got["escapePicked"] == [[None, "b05-plan"], None, 1] # 관 고름도 · 공용 Esc 막음
assert got["escapeNone"] == [0, 1] # 고른 것 없음 = 공용 Esc 몫
def test_마커_누름은_그대로_고름(got) -> None:
assert got["pressAfterRelease"] == "A"
def test_누름_마커_먼저_교각점_풂() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
down = pointer[pointer.index("if (marks.press(px, py)) {") :]
branch = down[: down.index("marks.deselect();")]
assert "params.setPicked(-1);" in branch and "params.syncCurveBar();" in branch
assert "canvas.setPointerCapture(event.pointerId);\n return;" in branch
assert "else if (marks.press(" not in pointer # 옛 차례(교각점 · 손잡이 다음) 걷힘
def test_맴돔_커서_마커_먼저() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
over = pointer.index("const onMark = !measuring && marks.over(px, py);")
pick = pointer.index("const over = measuring || onMark ? null : pickAt(px, py);")
cursor = pointer.index("canvas.style.cursor = onMark")
assert over < pick < cursor
def test_오른쪽_판은_제_이름_알림을_거름() -> None:
cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts")
assert "if (from !== PLAN_PICK_FROM) follow(selection);" in cross
marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts")
assert "selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });" in marks
# ── 409-2 마커 고름 풂 = 좌측 목록 강조 · 폼도 풂 · 패널 접힘 · 펼침 그대로 · 오른쪽 판 그대로 ──
HELPER_2 = Path(__file__).with_name("helper_409_2_plan_release_form.cjs")
@pytest.fixture(scope="module")
def got2() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER_2)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_풂_관_폼_닫힘_오른쪽_판은_제_알림만(got2) -> None:
release = got2["pipeRelease"]
assert release["form"] == {"open": None, "resets": 1}
assert release["crossSaw"] == [[None, "b05-plan"]] # 폼이 낸 b05 풂은 알림 안 감(되울림 막이)
assert release["session"] is None
def test_풂_구조물_폼_닫힘(got2) -> None:
assert got2["structureRelease"] == {"form": {"open": None, "resets": 2}, "pill": None}
def test_눈금_클릭은_폼_그대로(got2) -> None:
assert got2["tickKeepsForm"] == "A"
assert got2["noFormNoReset"] == 0
assert got2["planName"] == "b05-plan"
def test_폼_닫기는_패널_안_폄() -> None:
panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
head = panel[panel.index(" selectById(structureId) {") :]
assert head.index("if (structureId !== null) callbacks.onReveal?.();") < head.index(
"resetForm();"
)
assert "is-collapsed" not in head[: head.index(" addAt(")] # 접힘 · 펼침 안 건드림
pick = _read("A00_Common", "design", "design_pick_profile.ts")
assert "from === PLAN_PICK_FROM && selection.chainageM === null" in pick
@@ -0,0 +1,58 @@
"""411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단(요구 252-6) ·
옮긴 뒤에도 그 측점.
TS 셈은 `helper_411_a_cross_structure.cjs`(Node · `_RouteEdit_Cross` 진짜 · 묶음 · 그림 가짜).
./venv/Scripts/python.exe -m pytest resources/tester/test_411_a_cross_structure.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
HELPER = Path(__file__).with_name("helper_411_a_cross_structure.cjs")
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_구조물_고름_그_측점_횡단(got) -> None:
assert got["structurePick"] == {"station": "45", "foot": "횡단 45", "structure": True}
def test_옮긴_뒤_열쇠로_새_자리(got) -> None:
assert got["structureMoved"] == {"station": "52", "foot": "횡단 52", "at": 52}
def test_관_고름_그_측점_횡단(got) -> None:
assert got["pipePick"] == {"station": "80.17", "foot": "횡단 80.17", "structure": True}
def test_평면_마커_풂은_판_그대로(got) -> None:
assert got["planRelease"] == {"station": "80.17", "foot": "횡단 80.17"}
def test_일반측점_그대로(got) -> None:
assert got["gridPick"] == {"station": "20", "foot": "횡단 20", "structure": None}
def test_묶음_전_고름도_열쇠로_붙듦(got) -> None:
assert got["beforePipeline"]["foot"] == "계산 중…"
assert got["afterPipeline"] == {"station": "60", "foot": "횡단 60"}
assert got["latchedMoved"] == {"station": "66", "foot": "횡단 66"}
def test_다른_보기_풂은_판_비움(got) -> None:
assert got["otherRelease"]["station"] == "" and got["otherRelease"]["watched"] is None