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

This commit is contained in:
2026-10-10 15:38:08 +09:00
33 changed files with 1938 additions and 111 deletions
@@ -0,0 +1,113 @@
/**
* Ctrl 단축키 — 한글 자판에서도 되돌리기 · 다시 하기가 먹는다(PLAN 214-1).
* 한글 자판은 key 가 「ㅋ」·「ㅛ」 · Shift 는 대문자 — 자판 자리(code)로 판정한다.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad ctrl-shortcut-hangul
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
undo: vi.fn(),
redo: vi.fn(),
}));
import { ctrlShortcut } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-shortcut";
import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
import { redo, undo } from "../../../B07_DesignDetail/openwebcad/src/state";
const press = (controller: InputController, extra: Partial<KeyboardEvent>) => {
const preventDefault = vi.fn();
controller.handleKeyStroke({
key: "",
code: "",
keyCode: 0,
isComposing: false,
shiftKey: false,
ctrlKey: true,
altKey: false,
metaKey: false,
repeat: false,
target: null,
preventDefault,
stopPropagation: vi.fn(),
...extra,
} as unknown as KeyboardEvent);
return preventDefault;
};
describe("ctrlShortcut — 자판 자리(code)로 판정", () => {
const k = (code: string, extra = {}) =>
ctrlShortcut({ ctrlKey: true, altKey: false, shiftKey: false, code, ...extra });
it("KeyZ = 되돌리기 · Shift+KeyZ · KeyY = 다시 하기", () => {
expect(k("KeyZ")).toBe("undo");
expect(k("KeyZ", { shiftKey: true })).toBe("redo");
expect(k("KeyY")).toBe("redo");
});
it("KeyS = 저장 · KeyA = 전부 고르기 · Shift 면 아님", () => {
expect(k("KeyS")).toBe("save");
expect(k("KeyA")).toBe("selectAll");
expect(k("KeyS", { shiftKey: true })).toBeNull();
expect(k("KeyA", { shiftKey: true })).toBeNull();
});
it("Ctrl 없음 · Alt(AltGr) 함께 · 다른 자리는 아님", () => {
expect(k("KeyZ", { ctrlKey: false })).toBeNull();
expect(k("KeyZ", { altKey: true })).toBeNull();
expect(k("KeyX")).toBeNull();
});
});
describe("키 입력 — 한글 자판", () => {
beforeEach(() => {
vi.mocked(undo).mockClear();
vi.mocked(redo).mockClear();
});
it("Ctrl+「ㅋ」(KeyZ) = 되돌리기", () => {
const controller = new InputController();
const prevented = press(controller, { key: "ㅋ", code: "KeyZ" });
expect(undo).toHaveBeenCalledTimes(1);
expect(redo).not.toHaveBeenCalled();
expect(prevented).toHaveBeenCalled();
});
it("Ctrl+「ㅛ」(KeyY) = 다시 하기", () => {
const controller = new InputController();
press(controller, { key: "ㅛ", code: "KeyY" });
expect(redo).toHaveBeenCalledTimes(1);
expect(undo).not.toHaveBeenCalled();
});
it("Ctrl+Shift+「ㅋ」(KeyZ) = 다시 하기", () => {
const controller = new InputController();
press(controller, { key: "ㅋ", code: "KeyZ", shiftKey: true });
expect(redo).toHaveBeenCalledTimes(1);
expect(undo).not.toHaveBeenCalled();
});
it("영문 자판 Ctrl+Shift+Z(key 「Z」) = 다시 하기 · Ctrl+Z = 되돌리기", () => {
const controller = new InputController();
press(controller, { key: "Z", code: "KeyZ", shiftKey: true });
expect(redo).toHaveBeenCalledTimes(1);
press(controller, { key: "z", code: "KeyZ" });
expect(undo).toHaveBeenCalledTimes(1);
});
it("한글 조합 중(keyCode 229 · key Process)에도 Ctrl+Z 는 먹음", () => {
const controller = new InputController();
press(controller, { key: "Process", code: "KeyZ", keyCode: 229, isComposing: true });
expect(undo).toHaveBeenCalledTimes(1);
});
it("조합 중 Ctrl 없는 키는 여전히 IME 에 넘김", () => {
const controller = new InputController();
const prevented = press(controller, {
key: "Process",
code: "KeyZ",
keyCode: 229,
isComposing: true,
ctrlKey: false,
});
expect(undo).not.toHaveBeenCalled();
expect(prevented).not.toHaveBeenCalled();
});
});
@@ -35,6 +35,8 @@ const STUBS = {
svgText,
},
"./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} },
// 선 조각 고르기(209-1)는 줌과 무관 — 따로 시험(`test_209_1_detail_select.py`)
"./B06_Section_UI_Cross_Detail_Select": { attachDetailSelect: () => ({}), PIECE_HIT_CLASS: "" },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
function loadTs(full) {
@@ -25,6 +25,8 @@ const store = () => {
};
};
globalThis.window = { sessionStorage: store(), localStorage: store() };
// 조정창 공용 부품(Select → Design_Pick → Cross_Panel_Base)이 불러올 때 문서에 귀를 닮
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
const STUBS = {
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
@@ -38,6 +40,9 @@ function loadTs(full) {
const box = { exports: {} };
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@util/"))
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
@@ -85,12 +90,10 @@ const px = ([offset, elevation]) => [
(AXES.displayMax - (AXES.elevationMid + (elevation - AXES.elevationMid) * AXES.exaggeration)) *
AXES.pixelsPerMeter,
];
const points = input.pick
.map(px)
.map((p) => p.join(","))
.join(" ");
out.length = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points }), AXES);
out.lengthBad = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points: "1,2" }), AXES);
// 화면 px → m(209-1 `_Cross_Detail_Select.pxToM`) · 길이 = 옛 저장 꼴(두 끝) 그대로 읽음
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
out.pxBack = input.pick.map(px).map(S.pxToM(AXES));
out.length = { src: "length", start: input.pick[0], end: input.pick[1] };
out.lengthValue = T.localCellValue(out.length, input.design);
// ③ 우클릭 메뉴 항목
@@ -0,0 +1,117 @@
/* PLAN 209-1 단위검증 헬퍼 — [상세] 선 조각 고르기(`_Cross_Detail_Geom` · `_Cross_Detail_Select`).
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes} ·
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_209_1_detail_select.py`(파이썬 `cell_value` 거울).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B06 = path.join(ROOT, "B06_Section");
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
globalThis.window = { sessionStorage: new Map(), localStorage: new Map() };
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
const STUBS = {
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
"./B06_Section_UI_Section_Common": { CROSS_PAD },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
function loadTs(full) {
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@util/"))
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts"));
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts"));
const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8"));
const out = {};
// ① 그림 요소(화면 px) → 조각(m) — 설계선(직선 토막 셋) · 원지반선 · 구조물 선 아님
const px = ([offset, elevation]) =>
[
CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter,
CROSS_PAD.top +
(axes.displayMax -
(axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) *
axes.pixelsPerMeter,
].join(",");
const el = (classes, points, attrs = {}) => ({
tagName: "polyline",
classList: { contains: (name) => classes.includes(name) },
hasAttribute: (name) => name in attrs,
getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)),
});
const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]);
const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]);
const pieces = S.collectPieces(
[
el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }),
el(["b06-chart__cross-profile"], groundPts),
el(
["b06-chart__grid"],
[
[0, 0],
[1, 1],
],
),
],
axes,
);
out.pieces = pieces;
// ② 여러 고름 — 더하기 · 빼기 · 하나 · 다시 누르면 비움
out.toggle = {
single: S.toggleSelection([1, 2], 3, false),
again: S.toggleSelection([3], 3, false),
add: S.toggleSelection([1], 3, true),
remove: S.toggleSelection([1, 3], 3, true),
};
// ③ 닫힌 영역 — 땅 아래 설계 골(경사 둘 · 바닥) + 원지반 가운데 조각
const isGround = (p) => p.line === "ground";
const mid = (p) => (p.points[0][0] + p.points[p.points.length - 1][0]) / 2;
const below = pieces.filter(
(p) => !isGround(p) && Math.max(...p.points.map((q) => q[1])) <= 100.0001,
);
const groundMid = pieces.filter((p) => isGround(p) && Math.abs(mid(p)) < 1);
const ring = [...below, ...groundMid];
out.ringCount = ring.length;
out.label = {
ring: S.selectionLabel(design, ring),
open: S.selectionLabel(design, below),
single: S.selectionLabel(design, [below.find((p) => Math.abs(mid(p)) < 0.5)]),
ground: S.selectionLabel(design, groundMid),
};
out.area = G.ringArea(ring.map((p) => p.points));
out.open = G.ringArea(below.map((p) => p.points));
// ④ 칸 넣기 출처(저장 꼴) · 즉시 칸 값(짝 ①)
out.sourceArea = S.selectionSource("area", ring);
out.sourceOpen = S.selectionSource("area", below);
out.sourceLength = S.selectionSource("length", [...below, ...groundMid]);
const withStructure = {
src: "length",
pieces: [...out.sourceLength.pieces, { line: "structure", start: [20, 0], end: [23, 4] }],
};
const offLine = {
src: "length",
pieces: [{ line: "ground", start: [0, 101], end: [1, 100] }],
};
out.sources = [out.sourceArea, out.sourceLength, withStructure, offLine];
out.local = out.sources.map((source) => T.localCellValue(source, design, samples));
out.lengthSum = [...below, ...groundMid].reduce((sum, p) => sum + G.pathLength(p.points), 0);
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,193 @@
/* PLAN 216-1 단위검증 헬퍼 — B05 종단 측점선 고름 → 좌측 구조물 폼이 B06 카드 고름과 같게.
* 화면 TS(`B05_Profile_UI_Selection` · `design_selection`)를 그 자리에서 트랜스파일해 Node 로 돌림 ·
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_216_1_b05_station_panel.py`.
* B05 포트 배선은 `design_view_profile.ts` 와 같은 꼴 · B06 은 `showAtChainage` 와 같은 꼴(resolveSelection + coverable).
*/
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 session = new Map();
const fakeStorage = {
getItem: (k) => session.get(k) ?? null,
setItem: (k, v) => session.set(k, String(v)),
removeItem: (k) => session.delete(k),
};
globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage };
globalThis.sessionStorage = fakeStorage;
globalThis.localStorage = fakeStorage;
// 비정규 측점 모듈은 설정 별칭(@config)을 끌어오므로 쓰는 두 함수만 같은 꼴로 흉내
const STUBS = {
"./B05_Profile_UI_IrregularStations": {
irregularStationId: (id) => `irregular:${id}`,
isPipeStation: (s) => s.origin === "pipe",
},
};
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 sel = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"));
const sync = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Selection.ts"));
const P = "proj-216";
// 측점 = 일반(s0~s200) + 관 투영(irregular:p1 · 33.4) + A군 수동 횡단수로(irregular:x1 · 140)
const cards = [0, 20, 40, 60, 80, 100, 120, 160, 180, 200].map((c) => ({
station_id: `s${c}`,
chainage_m: c,
}));
const irregular = [
{ id: "p1", chainage_m: 33.4, origin: "pipe", structure: "배관" },
{ id: "x1", chainage_m: 140, structure: "노출형 횡단수로" },
];
const pipes = [{ chainage_m: 33.4 }];
const types = [
{ type_id: "retaining_wall", group: "C" },
{ type_id: "cross_channel", group: "A" },
{ type_id: "pavement", group: "G" },
];
const structures = [
{ structure_id: "wall", type_id: "retaining_wall", start_m: 60, end_m: 80, chainage_m: 60 },
{ structure_id: "chan", type_id: "cross_channel", chainage_m: 140 },
{ structure_id: "pave", type_id: "pavement", start_m: 150, end_m: 190, chainage_m: 150 },
];
const groupOf = (typeId) => types.find((t) => t.type_id === typeId)?.group;
/** 좌측 폼 흉내 — B05 `_Structures_Panel` 의 selectPipeByChainage · selectById · selectAt 꼴 그대로. */
function makeForm(onSelect) {
const form = { pipe: null, structure: null };
const panel = {
form,
selectPipeByChainage(c) {
if (c === null) {
form.pipe = null;
return;
}
const hit = pipes.find((p) => Math.abs(p.chainage_m - c) < sel.PIPE_MATCH_M);
if (!hit) return;
form.pipe = hit.chainage_m;
form.structure = null;
onSelect?.(null);
},
selectById(id) {
if (id === null) {
form.pipe = null;
form.structure = null;
return;
}
form.pipe = null;
form.structure = id;
onSelect?.(structures.find((s) => s.structure_id === id) ?? null);
},
selectAt(selection, coverable) {
const hit = sel.resolveSelection(selection, pipes, structures, coverable);
if (hit.pipeChainageM !== null) panel.selectPipeByChainage(hit.pipeChainageM);
else panel.selectById(hit.structureId);
return hit;
},
};
return panel;
}
const formOf = (panel) =>
panel.form.pipe !== null ? `pipe-${panel.form.pipe.toFixed(2)}` : panel.form.structure;
// ── B05 종단 보기 — design_view_profile 의 포트 배선 그대로 ─────────────────────
let syncing = false;
const rememberPick = (c, sid) =>
sel.selectDesign(P, c === null ? sel.NO_SELECTION : { chainageM: c, structureId: sid }, {
from: "b05",
});
// 폼이 구조물을 열면 view 의 onStructureSelect 가 그 구조물을 고름 한 칸에 적음(동기화 중이면 그 뒤 return)
const b05Panel = makeForm((structure) => {
if (structure) rememberPick(structure.chainage_m, structure.structure_id);
});
const sb = sync.createSelectionSync({
stations: () => irregular,
isSyncing: () => syncing,
setSyncing: (v) => {
syncing = v;
},
selectMarker: () => undefined,
selectGraph: () => undefined,
selectSidebar: (c) => b05Panel.selectPipeByChainage(c),
openAtStation: (c) =>
b05Panel.selectAt({ chainageM: c, structureId: null }, (entry) =>
sel.coversAtStation(groupOf(entry.type_id)),
),
selectBasin: () => undefined,
regularChainage: (id) => cards.find((c) => c.station_id === id)?.chainage_m,
remember: (c) => rememberPick(c, null),
});
// ── B06 카드 고름 — showAtChainage 와 같은 꼴(카드 = 관 투영은 가까운 카드) ──────────
const b06Panel = makeForm();
const b06Coverable = (entry) => sel.coversAtStation(groupOf(entry.type_id));
const b06Card = (chainageM) => {
const hit = sel.resolveSelection(
{ chainageM, structureId: null },
pipes,
structures,
b06Coverable,
);
if (hit.pipeChainageM !== null) b06Panel.selectPipeByChainage(hit.pipeChainageM);
else b06Panel.selectById(hit.structureId);
};
const chainageOf = (id) =>
irregular.find((s) => `irregular:${s.id}` === id)?.chainage_m ??
cards.find((c) => c.station_id === id)?.chainage_m;
/** 차례대로 고름 — 매 걸음 B05 폼 · B06 폼 · 고름 한 칸. */
function run(ids) {
b05Panel.form.pipe = b05Panel.form.structure = null;
b06Panel.form.pipe = b06Panel.form.structure = null;
sel.selectDesign(P, sel.NO_SELECTION);
return ids.map((id) => {
sb.syncIrregularSelection(id);
const at = id === null ? null : chainageOf(id);
if (at !== undefined) b06Card(at); // 모르는 측점은 B06 카드가 없음
return { id, b05: formOf(b05Panel), b06: formOf(b06Panel), pick: sel.readSelection(P) };
});
}
const out = {
pipe: run(["irregular:p1"]),
// 관 폼이 열린 채 일반 측점 = 폼 비움(새 배치 준비) — 옛 B05 는 관 폼이 남았음
pipeThenPlain: run(["irregular:p1", "s100"]),
// 옹벽 범위 안 일반 측점 = 옹벽 폼 · 고름 한 칸은 그 측점(구조물 id 없음)
wallStation: run(["s20", "s60"]),
wallMiddle: run(["s80"]),
// A군 수동 횡단수로 측점 = 그 구조물 폼
channel: run(["irregular:x1"]),
// 노면공만 덮는 측점 = 노면공 안 엶(189-1 · 152-5) — 연 폼은 풂
pave: run(["s60", "s160"]),
// 같은 측점 다시 = 풂(null)
release: run(["s60", null]),
// 모르는 측점 = 폼 · 고름 한 칸 안 건드림
unknown: run(["s60", "ghost"]),
covers: {
G: sel.coversAtStation("G"),
C: sel.coversAtStation("C"),
none: sel.coversAtStation(undefined),
},
};
process.stdout.write(JSON.stringify(out));
+4 -5
View File
@@ -113,10 +113,8 @@ def test_그래프_고르기_띠_키_판정_선_m(out):
assert area["fill"] == {"src": "area", "key": "embankment"}
assert area["spoil"] == {"src": "area", "key": "spoil_fill"}
assert area["other"] is None
length = out["length"]
assert length["src"] == "length"
assert length["start"] == pytest.approx(PICK[0]) and length["end"] == pytest.approx(PICK[1])
assert out["lengthBad"] is None
# 화면 px → m(209-1 `pxToM`) · 옛 저장 꼴(두 끝) 길이 = hypot 그대로
assert out["pxBack"][0] == pytest.approx(PICK[0]) and out["pxBack"][1] == pytest.approx(PICK[1])
assert out["lengthValue"] == pytest.approx(hypot(4.0, 2.0))
@@ -261,4 +259,5 @@ def test_저장_patch_배선_초안_등록():
assert '"detail-table": { bucket: "draft", scope: "route" }' in state
detail = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail.ts").read_text(encoding="utf-8")
assert "designForTable(state.section, projectId, routeId)" in detail
assert "const detachTable = attachDetailTable({" in detail
assert "const tablePick = attachDetailTable({" in detail
assert "tablePick.redraw();" in detail
@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
"""PLAN 209-1 — B06 [상세] 그래프 고르기(교차점 분할 · 여러 고름 · 원지반 포함 · 닫힌 넓이).
정보창(고른 것 길이 · 면적) · 칸 넣기 · 서버 검산 · 옛 꼴 읽기.
못박는 것
- 선(설계선 · 원지반선)이 만나는 점에서 조각으로 나뉨 · 화면 px → m · 격자 등은 안 고름.
- 여러 고름(Shift/Ctrl 더하기 · 빼기) · 원지반 조각 포함 닫힌 영역 넓이 · 안 닫히면 null.
- 정보창 두 줄(하나 = 카드 꼴 길이 · 경사비율 / 여럿 = 길이 합 · 면적 · 「닫히지 않음」).
- 칸 넣기 출처(조각 두 끝 목록) → 스키마 → 서버 `cell_value` 거울(① 파이썬 · TS) ·
서버 검산(선 밖 · 설계선 바뀜 → 다시 고름 · 구조물 선 = 두 끝 직선) · 옛 두 끝 꼴 그대로.
./venv/Scripts/python.exe -m pytest resources/tester/test_209_1_detail_select.py -q
"""
import json
import os
import subprocess
from math import hypot
from pathlib import Path
import pytest
from B06_Section import B06_Section_Router_CrossTable as table_mod
from B06_Section.B06_Section_Schema import CrossSectionPatch
from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import (
cell_value,
ground_runs,
ring_area,
sub_path,
)
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)]
DESIGN = {
"design_elevation_m": 98.0,
"road_edges": {
"left": {"offset_m": 2.0, "elevation_m": 98.0},
"right": {"offset_m": -2.0, "elevation_m": 98.0},
},
"design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE],
}
SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] + [
{"offset_m": 12.0, "elevation_m": None, "valid": False},
{"offset_m": 14.0, "elevation_m": 101.0, "valid": True},
]
AXES = {
"widthPx": 600,
"heightPx": 300,
"pixelsPerMeter": 10,
"maxOffset": 14,
"displayMax": 110,
"elevationMid": 100,
"exaggeration": 2,
}
CROSS_X = 2 + 4 / 3 # 경사(1:0.667)가 원지반 100 m 와 만나는 offset
AREA = (2 * CROSS_X + 4) / 2 * 2 # 사다리꼴(윗변 2·CROSS_X · 아랫변 4 · 높이 2)
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_209_1_detail_select.cjs")],
input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}),
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
cwd=os.fspath(ROOT),
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _ends(piece: dict) -> list[tuple[float, float]]:
pts = piece["points"]
return [tuple(round(v, 4) for v in pts[0]), tuple(round(v, 4) for v in pts[-1])]
# ── 분할 · 여러 고름 ─────────────────────────────────────────────────────
def test_교차점에서_조각으로_나뉨(out):
pieces = out["pieces"]
ground = [p for p in pieces if p["line"] == "ground"]
design = [p for p in pieces if p["line"] == "design"]
assert len(ground) == 3 and len(design) == 5 # 원지반 = 두 교점에서 셋 · 설계 경사 둘이 둘씩
c = round(CROSS_X, 4)
assert sorted(_ends(p)[0][0] for p in ground) == pytest.approx([-10.0, -c, c])
# 원지반 가운데 조각은 사이 샘플 꼭짓점(0 m)을 지남 — 꺾은선 그대로
middle = next(p for p in ground if abs(p["points"][0][0]) < 4)
assert [0.0, 100.0] in [[round(v, 6) for v in q] for q in middle["points"]]
# 경사 조각 끝 = 교점(설계선 위 · 원지반 위)
assert any(_ends(p)[1] == (c, 100.0) or _ends(p)[0] == (c, 100.0) for p in design)
def test_여러_고름_더하기_빼기(out):
toggle = out["toggle"]
assert toggle == {"single": [3], "again": [], "add": [1, 3], "remove": [1]}
# ── 닫힌 넓이 · 정보창 ──────────────────────────────────────────────────
def test_원지반_포함_닫힌_영역_넓이(out):
assert out["ringCount"] == 4 # 경사 둘 · 바닥 · 원지반 가운데
assert out["area"] == pytest.approx(AREA)
assert out["open"] is None # 원지반 빼면 안 닫힘
def test_정보창_값(out):
label = out["label"]
length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X
assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡"
assert label["open"].endswith("면적 닫히지 않음")
assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴
assert label["ground"] == (
f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m"
)
# ── 칸 넣기 · 짝 · 서버 검산 ─────────────────────────────────────────────
def test_칸_넣기_출처_저장_꼴(out):
area = out["sourceArea"]
assert area["src"] == "area" and len(area["pieces"]) == 4
assert {p["line"] for p in area["pieces"]} == {"design", "ground"}
assert out["sourceOpen"] is None # 안 닫힌 면적은 칸에 못 넣음
assert out["sourceLength"]["src"] == "length"
patch = CrossSectionPatch(
chainage_m=40.0,
detail_table={"cells": {"cut_soil": area, "fence": out["sourceLength"]}},
).model_dump()
assert patch["detail_table"]["cells"]["cut_soil"]["pieces"][0]["line"] in ("design", "ground")
def test_칸_값_파이썬_TS_거울(out):
for source, local in zip(out["sources"], out["local"], strict=True):
number, _text, again = cell_value(source, DESIGN, SAMPLES)
if number is None:
assert local is None and again is True, source
else:
assert local == pytest.approx(number, abs=1e-6), source
assert out["local"][0] == pytest.approx(AREA, abs=1e-3)
assert out["local"][1] == pytest.approx(out["lengthSum"], abs=1e-3)
assert out["local"][2] == pytest.approx(out["lengthSum"] + 5.0, abs=1e-3) # 구조물 = 두 끝 직선
assert out["local"][3] is None # 원지반 밖 끝
def test_서버_검산_선_밖이나_설계선_바뀌면_다시_고름(out):
cells = {"cut_soil": out["sourceArea"], "fence": out["sourceLength"]}
stored = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": cells}).model_dump()
design = {**DESIGN, "detail_table": stored["detail_table"]}
source = {"chainage_m": 40.0, "center_z": 100.0, "samples": SAMPLES}
sheet = table_mod.cross_quantity_sheet(source, design)
assert sheet["quantity_table"]["cut_soil"] == pytest.approx(AREA, abs=1e-3)
assert sheet["quantity_table"]["fence"] == pytest.approx(out["lengthSum"], abs=1e-3)
assert sheet["repick"] == []
# 원지반 샘플이 없으면(검산 못 함) 다시 고름 — 브라우저 값 안 받음
blind = table_mod.cross_quantity_sheet({"chainage_m": 40.0}, design)
assert sorted(blind["repick"]) == ["cut_soil", "fence"]
# 설계선이 내려가면 조각 끝이 선 밖 → 다시 고름
moved = {**design, "design_line": [{"offset_m": x, "elevation_m": y - 1} for x, y in LINE]}
again = table_mod.cross_quantity_sheet(source, moved)
assert again["quantity_table"]["cut_soil"] is None
assert sorted(again["repick"]) == ["cut_soil", "fence"]
def test_옛_두_끝_꼴_그대로_읽음():
old = {"src": "length", "start": [2.0, 98.0], "end": [6.0, 104.0]}
assert cell_value(old, DESIGN, SAMPLES)[0] == pytest.approx(hypot(4.0, 6.0))
assert cell_value({"src": "area", "key": "cut_soil"}, {"cut_soil_area_m2": 1.5}) == (
1.5,
None,
False,
)
def test_서버_조각_길_넓이_기본():
assert ground_runs(SAMPLES) == [
[(-10, 100.0), (-5, 100.0), (0, 100.0), (5, 100.0), (10, 100.0)]
]
run = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0)]
assert sub_path(run, 3.0, 1.0) == [(2.0, 1.0), (2.0, 0.0), (1.0, 0.0)]
square = [
[(0.0, 0.0), (1.0, 0.0)],
[(1.0, 1.0), (1.0, 0.0)],
[(1.0, 1.0), (0.0, 1.0), (0.0, 0.0)],
]
assert ring_area(square) == pytest.approx(1.0)
assert ring_area(square[:2]) is None
assert ring_area([*square, [(5.0, 5.0), (6.0, 6.0)]]) is None # 남는 조각
@@ -0,0 +1,214 @@
"""213-1 — 한 자리에 측점 둘이 숨는 흠(엄상준_검증 d2245e3b · No.13+3).
까닭: 자동설계 관 측점 263.206(0.001m) → 화면 저장이 관을 0.01m 로 반올림(263.21) → 측점은
263.21 로 다시 섰는데 계획선 꼭짓점(base_pvi) · 편집 키는 옛 글자 「263.206」 그대로 → 새 측점
편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단).
고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의
꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도
옛 글자를 같은 측점으로 봄. 사용자 storage 는 읽기만 — 사본(tmp_path).
"""
from __future__ import annotations
import copy
import json
import shutil
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
AlignmentPolicy,
build_alignment,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
SOURCE = (
PROJECT_ROOT
/ "storage/1/3/d2245e3b-0468-4a90-ac5c-50b2adcb56e7/B06_Section/longitudinal/longitudinal.json"
)
REL = "B06_Section/longitudinal/longitudinal.json"
needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="엄상준_검증 storage 없음")
# 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998)
STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998]
BASE = [
(0.0, 100.0),
(20.0, 99.0),
(40.0, 98.5),
(263.206, 90.0),
(280.0, 89.0),
(300.0, 88.0),
(1000.0, 60.0),
]
EDITS = {
"station_offsets": {"263.206": 1.5, "263.210": 0.9, "40.000": 0.2, "500.000": 0.3},
"curve_radii": {"263.206": 300.0},
}
def _synthetic() -> dict:
chainage = np.arange(0.0, 1000.0, 1.0)
return {
"base_s": np.array([s for s, _ in BASE]),
"base_z": np.array([z for _, z in BASE]),
"chainage": np.r_[chainage, 1000.0],
"ground": np.r_[100.0 - chainage * 0.04, 60.0],
"stations": [
{"station_id": f"st{index}", "chainage_m": value}
for index, value in enumerate(STATIONS)
],
}
def _python(edits: dict) -> dict:
data = _synthetic()
return build_alignment(**data, policy=AlignmentPolicy.from_dict({}), edits=copy.deepcopy(edits))
def _close_pairs(alignment: dict, limit: float = 0.006) -> list[tuple[float, float]]:
pvi = [node["chainage_m"] for node in alignment["pvi"]]
return [(a, b) for a, b in zip(pvi, pvi[1:], strict=False) if b - a < limit]
def test_rounded_station_keys_collapse_onto_station():
alignment = _python(EDITS)
pvi = {round(node["chainage_m"], 3): node for node in alignment["pvi"]}
assert _close_pairs(alignment) == []
assert 263.206 not in pvi and 263.21 in pvi
# 측점 위 편집(263.210 = 0.9)이 이김 · 반경도 측점으로 옮김 · 측점에서 먼 키(500)는 그대로
assert alignment["edits"]["station_offsets"] == {"263.210": 0.9, "40.000": 0.2, "500.000": 0.3}
assert alignment["edits"]["curve_radii"] == {"263.210": 300.0}
# 양 끝 꼭짓점은 그대로(끝 1000.0 을 측점 999.998 로 당기지 않음)
assert [node["chainage_m"] for node in alignment["base_pvi"]][-1] == 1000.0
assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100
def test_snapped_base_keeps_station_elevation():
"""편집 없는 측점 — 꼭짓점을 측점으로 옮겨도 그 측점 계획고(탄젠트)는 그대로."""
alignment = _python({"station_offsets": {}, "curve_radii": {}})
node = next(n for n in alignment["base_pvi"] if abs(n["chainage_m"] - 263.21) < 1e-9)
expected = float(np.interp(263.21, [s for s, _ in BASE], [z for _, z in BASE]))
assert node["elevation_m"] == pytest.approx(expected, abs=1e-6)
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { buildAlignment } from "./B05_Profile_UI_Profile_Alignment.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const result = buildAlignment(input.base, input.edits);
writeFileSync(process.argv[3], JSON.stringify(result));
"""
@pytest.fixture(scope="module")
def ts_alignment(tmp_path_factory):
if not TSC.is_file():
pytest.skip("프론트엔드 의존성 미설치")
tmp = tmp_path_factory.mktemp("align213")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
data = _synthetic()
base = {
"policy": AlignmentPolicy.from_dict({}).as_dict(),
"onlyExplicitCurves": False,
"basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in BASE],
"chainage": data["chainage"].tolist(),
"ground": data["ground"].tolist(),
"stations": data["stations"],
}
src, dst = tmp / "in.json", tmp / "out.json"
src.write_text(json.dumps({"base": base, "edits": EDITS}), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
)
return json.loads(dst.read_text(encoding="utf-8"))
def test_mirror_python_ts(ts_alignment):
py = _python(EDITS)
ts = ts_alignment
assert ts["edits"] == py["edits"]
assert len(ts["base_pvi"]) == len(py["base_pvi"])
for a, b in zip(ts["base_pvi"], py["base_pvi"], strict=True):
assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-6)
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6)
assert [n["chainage_m"] for n in ts["pvi"]] == pytest.approx(
[n["chainage_m"] for n in py["pvi"]], abs=1e-6
)
assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx(
[n["elevation_m"] for n in py["pvi"]], abs=1e-6
)
for a, b in zip(ts["stations"], py["stations"], strict=True):
assert a["plan_elevation_m"] == pytest.approx(b["plan_elevation_m"], abs=1e-6)
def _copy(tmp_path: Path) -> Path:
target = tmp_path / REL
target.parent.mkdir(parents=True)
shutil.copyfile(SOURCE, target)
return target
@needs_copy
def test_d2245e3b_no13_3_spike_gone(tmp_path):
data = json.loads(_copy(tmp_path).read_text(encoding="utf-8"))
edits = data["profile_alignment"]["edits"]
assert {"263.206", "263.210"} <= set(edits["station_offsets"]) # 재현 조건
alignment, _ = rebuild_alignment_profile(data, edits)
assert _close_pairs(alignment) == []
assert "263.206" not in alignment["edits"]["station_offsets"]
assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100
@needs_copy
def test_d2245e3b_station_regen_drops_rounded_key(tmp_path):
"""[측점 만들기] 로 관 측점 263.21 → 263.31 — 옛 글자 263.206 꼭짓점 · 편집도 함께 지워짐."""
path = _copy(tmp_path)
data = json.loads(path.read_text(encoding="utf-8"))
irregular = []
for station in data["stations"]:
if station.get("kind") != "irregular":
continue
item = copy.deepcopy(station)
if abs(item["chainage_m"] - 263.21) < 1e-6:
item["chainage_m"] = 263.31
irregular.append(item)
_merge_irregular_into_longitudinal(tmp_path, REL, irregular)
saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]
leftovers = [
value
for value in [n["chainage_m"] for n in saved["base_pvi"]]
+ [float(key) for key in saved["edits"]["station_offsets"]]
if 263.2 < value < 263.22
]
assert leftovers == []
assert _close_pairs(saved, 0.05) == []
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""PLAN 216-1 — B05 종단 측점선 고름 → 좌측 구조물 폼이 B06 카드 고름과 같게.
까닭: `syncIrregularSelection` 이 일반 측점이면 폼을 안 건드리고 끝났고(옛 폼이 남음 · 덮는 옹벽도
안 열림), 비정규 측점도 관 전용 `selectPipeByChainage` 만 불러 A군 수동 구조물은 안 열렸음.
B06 카드 고름은 `showAtChainage` = `resolveSelection`(관 → 덮는 구조물 · 노면공 뺌 → 없으면 비움).
고침: B05 측점 고름도 같은 `resolveSelection` + 같은 `coversAtStation` 으로 폼을 엶(`openAtStation`).
실제 TS 를 `helper_216_1_b05_station_panel.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_216_1_b05_station_panel.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
def _forms(steps: list) -> list:
return [(step["b05"], step["b06"]) for step in steps]
def test_관_지점_측점선은_그_관_폼(out) -> None:
assert _forms(out["pipe"]) == [("pipe-33.40", "pipe-33.40")]
def test_관_폼이_열린_채_빈_일반_측점이면_폼을_비움(out) -> None:
assert _forms(out["pipeThenPlain"])[-1] == (None, None)
def test_일반_측점은_그_측점을_덮는_구조물_폼(out) -> None:
assert _forms(out["wallStation"]) == [(None, None), ("wall", "wall")]
assert _forms(out["wallMiddle"]) == [("wall", "wall")]
assert _forms(out["channel"]) == [("chan", "chan")] # A군 수동 구조물 측점
def test_노면공은_측점_고름으로_안_엶(out) -> None:
assert _forms(out["pave"]) == [("wall", "wall"), (None, None)]
assert out["covers"] == {"G": False, "C": True, "none": True}
def test_다시_누름_풂과_모르는_측점(out) -> None:
assert _forms(out["release"])[-1] == (None, None)
assert out["release"][-1]["pick"] is None
assert out["unknown"][-1]["b05"] == "wall" # 모르는 측점은 폼 그대로
assert out["unknown"][-1]["pick"] == {"chainageM": 60, "structureId": None}
def test_고름_한_칸은_측점_구조물_id_없음(out) -> None:
# 폼이 옹벽을 열어도(목록 고름 알림이 구조물을 적어도) 마지막은 그 측점(188-1 · B06 카드와 같음)
for key in ("wallStation", "wallMiddle", "channel", "pave", "pipeThenPlain"):
for step in out[key]:
assert step["pick"]["structureId"] is None, key
assert step["pick"]["chainageM"] in (20, 33.4, 60, 80, 100, 140, 160), key
def test_배선() -> None:
sync = _read("B05_Profile", "B05_Profile_UI_Selection.ts")
assert "(ports.openAtStation ?? ports.selectSidebar)(at);" in sync
assert "if (stationId !== null && !stationId.startsWith(prefix)) return;" not in sync
# 고름 한 칸은 폼 동기화 뒤에 적음
assert sync.index("ports.remember?.(at);") > sync.index("ports.setSyncing(false);")
view = _read("A00_Common", "design", "design_view_profile.ts")
assert "openAtStation: (chainageM) =>" in view
assert "panel.structures.selectAt({ chainageM, structureId: null }, (entry) =>" in view
assert "coversAtStation(bridge.typeGroup(entry.type_id))" in view
b06 = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group)" in b06
assert "resolveSelection(selection, pipeFacilities, structures, coverable)" in b06
panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
assert "resolveSelection(selection, pipeFacilities, structures, coverable)" in panel