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

This commit is contained in:
2026-10-18 09:35:31 +09:00
27 changed files with 829 additions and 58 deletions
@@ -0,0 +1,33 @@
/* 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 } = 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), // 판을 끝까지 올림 = 지도 안에 남김
}),
);
+50
View File
@@ -0,0 +1,50 @@
/* 410 좌표 따라감 거울 — 진짜 TS(`common_util_route_follow`)를 Node 로 돌림.
* 입력 argv[2] JSON = { lines: [L1, L2, ...], structures, pipes, followed } · 표준출력 JSON 한 벌.
* 차례(구조물) = L1 에서 자리 붙임(placeOnLine) → L2 따라감 → L2 에서 자리 붙임 → L3 따라감 …
* 관 = 좌표 없는 초안 + 앞 따라감(followed) → L1 자리 붙임 → L2 투영.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file, 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} (${file})`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const catalog = require("./helper_322_catalog_module.cjs");
const station = loadTs("common_util/common_util_structure_station.ts", {
"./common_util_structure_catalog": catalog,
});
const follow = loadTs("common_util/common_util_route_follow.ts", {
"./common_util_structure_station": station,
});
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const lines = input.lines.map((line) => line.map(([x, y]) => ({ x, y })));
const steps = [];
let items = input.structures;
for (let i = 0; i + 1 < lines.length; i += 1) {
const placed = follow.placeOnLine(items, lines[i], [], input.interval);
items = follow
.followStructures(placed, lines[i + 1], undefined, input.interval)
.map((moved) => moved.item);
steps.push({ placed, followed: items });
}
const pipesPlaced = follow.placeOnLine(input.pipes, lines[0], input.followed);
const out = {
steps,
pipesPlaced,
pipesFollowed: follow.projectPipePoints(pipesPlaced, lines[1]),
};
process.stdout.write(JSON.stringify(out));
+18
View File
@@ -0,0 +1,18 @@
/* 411-B · 411-D 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
* 관 짝(id) · 관 세션 id 장부 옮김 · 알약 따라감 짝 · 배수 편집기 따라감 · B06 측점 열쇠 갈래를 화면 · 서버 없이 잰다.
*/
export { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow";
export { followedMarks } from "../../B05_Profile/logic/B05_Profile_Structure_Follow";
export {
movePipeId,
movePipeIds,
pipeChainageOf,
pipeIdAt,
usePipeIds,
} from "../../A00_Common/design/design_pipe_ids";
export { stationKeysOf, useStationOwners } from "../../B06_Section/B06_Section_Station_Keys";
export { followEditorPipes } from "../../B05_Profile/B05_Profile_UI_Drainage_Parts";
export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility";
export { planStations } from "../../common_util/common_util_structure_station";
export { createPipeEditor } from "../../B05_Profile/B05_Profile_UI_Drainage_Pipes";
export { stationKey as pipelineStationKey } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
@@ -0,0 +1,129 @@
/* 411-B · 411-D 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_411_b_entry.ts)을 부르고 결과를 JSON 한 벌로.
* node helper_411_b_pipe_id_pairing.mjs <묶음.mjs>
* 사용자 길: 관 p2 를 앞 관 p1 을 지나 옮김(150 → 80) · 노선 따라감이 두 관을 함께 밂.
*/
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 out = {};
const P = "p411";
m.usePipeIds(P);
/* 1. 알림 목록 ↔ 가게 관 초안 짝 = id — 옮겨 차례가 뒤바뀌어도 그 관 */
const reports = [
{ id: "p10000", chainage_m: 100, facility: "pipe" },
{ id: "p15000", chainage_m: 150, facility: "pipe", start_m: 145, end_m: 155 },
];
const draft = [
{ id: "p15000", chainage_m: 80 },
{ id: "p10000", chainage_m: 100 },
];
out.reports = m
.followPipeReports(reports, draft)
.map((r) => [r.id, r.chainage_m, r.start_m ?? null]);
/* 2. 세션 id 장부 — 끌어 옮김(followMovedPipe 길) 뒤 화면 목록 짝(shownPipes 규칙 = id) */
const idA = m.pipeIdAt(100, P);
const idB = m.pipeIdAt(150, P);
m.movePipeId(150, 80, P); // 끌기 = 옮긴 관 id 그대로
const was = new Map(reports.map((r) => [r.id, r.chainage_m]));
const shown = m.followPipeReports(reports, draft);
const moves = shown.flatMap((r) => {
const from = was.get(r.id);
return from === undefined || Math.abs(from - r.chainage_m) <= 0.005 ? [] : [[from, r.chainage_m]];
});
m.movePipeIds(moves, P);
out.ids = { idA, idB, at80: m.pipeIdAt(80, P), at100: m.pipeIdAt(100, P) };
out.bookNext = m.pipeIdAt(999, P); // 새 관 = 다음 차례(앞에서 헛 id 가 안 생겼으면 s3)
/* 3. 노선 따라감이 두 관을 함께 밂(앞 관이 뒤 관 옛 자리로) — 한 번에 옮김 · 기록 안 잃음 */
const Q = "q411";
const q1 = m.pipeIdAt(100, Q);
const q2 = m.pipeIdAt(103, Q);
m.movePipeIds(
[
[100, 103],
[103, 106],
],
Q,
);
out.batch = {
q1,
q2,
at103: m.pipeIdAt(103, Q),
at106: m.pipeIdAt(106, Q),
next: m.pipeIdAt(500, Q),
};
/* 4. 알약 따라감 — 알약이 이미 따라간 자리면 같은 자리 짝(차례 뒤바뀜에도 그 관) */
const marks = [
{ structure_id: idB, chainage_m: 80 },
{ structure_id: idA, chainage_m: 100 },
{ structure_id: "st-1", chainage_m: 40 },
];
out.marks = m
.followedMarks(marks, [{ structure_id: "st-1", chainage_m: 42 }], draft)
.map((mk) => [mk.structure_id, mk.chainage_m]);
/* 5. 배수 편집기 — 따라간 관 자리(id 짝)를 받음 · 시설 속성 · 고름도 따라감 */
const editor = m.createPipeEditor(() => {});
editor.setPipes([
{ chainage_m: 100, reason: "stream", id: "p10000" },
{ chainage_m: 150, reason: "confirmed", id: "p15000" },
]);
editor.select(1);
const facilities = m.createFacilityStore();
facilities.set(150, { facility: "ford", options: { 월류높이: 0.3 } });
m.followEditorPipes(editor, facilities, [
{ id: "p10000", chainage_m: 103 },
{ id: "p15000", chainage_m: 98 },
]);
out.editor = {
pipes: editor.pipes().map((p) => [p.id, p.chainage_m, p.reason]),
selected: editor.pipes()[editor.selected()]?.id ?? null,
ford98: facilities.get(98)?.facility ?? null,
at150: facilities.get(150),
};
m.followEditorPipes(editor, facilities, [{ id: "p10000", chainage_m: 103 }]); // 같은 값 = 그대로
out.editorSame = editor.pipes().map((p) => p.chainage_m);
/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */
const planned = [
{ chainage_m: 80.17, owners: ["p8017"], role: "anchor" },
{ chainage_m: 52.5, owners: ["st-1"], role: "anchor" },
];
const sections = [
{ chainage_m: 52.5, kind: "irregular" },
{ chainage_m: 80.17, kind: "irregular" },
];
m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]);
out.keys = m.stationKeysOf(P, sections, 20).map((k) => k.key);
m.useStationOwners(P, planned);
out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key);
out.pipelineKey = m.pipelineStationKey("pipe", "p8017");
process.stdout.write(JSON.stringify(out));
@@ -56,8 +56,8 @@ def test_상태_범례_접기_한_줄_기억() -> None:
def test_상태_글판_작게_반투명_자리() -> None:
info = _rule(".b05-routeedit__info")
# 왼쪽 아래 제자리 — 뜬 판이 덮으면 가려진 채(349-0) · 폭 줄임
assert "bottom: var(--spacing-12);" in info
# 왼쪽 아래 제자리 — 뜬 판이 덮으면 그 위로(408-2) · 폭 줄임
assert "bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));" in info
assert "max-width: min(460px, 46%);" in info and "grid-template-columns:" in info
# 상태 글 두 줄에서 자름 · 범례는 옆 칸 두 줄
assert "-webkit-line-clamp: 2;" in _rule(".b05-routeedit__status")
@@ -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))
+203
View File
@@ -0,0 +1,203 @@
# -*- coding: utf-8 -*-
"""410 구조물 · 관 자리 = 등고선 좌표(x · y) 지킴 — 교점 옮김 · R 바꿈 뒤 좌표 그대로 ·
누가거리 다시 매김.
거울(파이썬 = 서버 정본 · TS = 브라우저 즉시 · 같은 값):
① 좌표 낡음 판정 — `common_util_route_follow.fill_structure_coordinates` ↔ TS `placeOnLine`
(좌표가 그 누가거리를 낳으면 살림 · 누가거리만 옮긴 구조물 = 그 누가거리의 노선 위 자리).
② 따라감 — `follow_structures` ↔ `followStructures`(L1 → L2 교점 옮김 → L3 곡선 바꿈).
③ 관 — 좌표 없는 초안 관은 앞 따라감(같은 id · 같은 자리)의 좌표 · 옮긴 관은 노선 위 자리.
핵심: 마커로 옮긴 구조물은 다음 노선 고침에 옛 자리로 안 튐 · 안 옮긴 구조물 · 관은 좌표 그대로.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from shapely.geometry import LineString, Point
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402
from common_util.common_util_route_follow import ( # noqa: E402
fill_structure_coordinates,
follow_structures,
)
from common_util.common_util_route_geometry import RouteVertex # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
L1 = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)]
L2 = [(0.0, 0.0), (100.0, 12.0), (200.0, 50.0)] # 교점 옮김
L3 = [(0.0, 0.0), (85.0, 4.0), (100.0, 10.0), (115.0, 18.0), (200.0, 50.0)] # 곡선(R) 바꿈
LINES = [L1, L2, L3]
INTERVAL = 20.0
def _at(line, chainage, offset=0.0):
"""노선 위 누가거리 자리 + 왼쪽 법선 offset(m)."""
geo = LineString(line)
p = geo.interpolate(chainage)
q = geo.interpolate(min(chainage + 0.01, geo.length))
dx, dy = q.x - p.x, q.y - p.y
norm = (dx * dx + dy * dy) ** 0.5 or 1.0
return p.x - dy / norm * offset, p.y + dx / norm * offset
def _structure(sid, placement, chainage, xy, start=None, end=None):
return {
"structure_id": sid,
"placement": placement,
"chainage_m": chainage,
"start_m": start,
"end_m": end,
"x": xy[0],
"y": xy[1],
}
# S1 = 노선 밖 5 m 땅 자리(안 옮김) · S2 = 140 m 에 놓았다 마커로 60 m 로 옮김(좌표 옛 값) ·
# S3 = 구간(안 옮김 · 3 m 밖) · S4 = 사토장(측점 80 · 땅 자리 84 m) ·
# S5 = 구간을 폼으로 옮김(좌표 옛 값)
STRUCTURES = [
_structure("S1", "point", 50.0, _at(L1, 50.0, 5.0)),
_structure("S2", "point", 60.0, _at(L1, 140.0)),
_structure("S3", "interval", 120.0, _at(L1, 120.0, -3.0), 110.0, 135.0),
_structure("S4", "site", 80.0, _at(L1, 84.0, 2.0)),
_structure("S5", "interval", 30.0, _at(L1, 170.0), 25.0, 40.0),
]
# 관 — p1 = 앞 따라감 그대로(좌표 없는 초안 · 같은 자리) · p2 = 초안에서 옮김(앞 따라감과 자리 다름)
FOLLOWED = [
{"id": "p1", "chainage_m": 70.0, "x": _at(L1, 70.0, 1.5)[0], "y": _at(L1, 70.0, 1.5)[1]},
{"id": "p2", "chainage_m": 150.0, "x": _at(L1, 150.0)[0], "y": _at(L1, 150.0)[1]},
]
PIPES = [{"id": "p1", "chainage_m": 70.0}, {"id": "p2", "chainage_m": 30.0}]
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
source = tmp_path_factory.mktemp("s410") / "in.json"
source.write_text(
json.dumps(
{
"lines": LINES,
"structures": STRUCTURES,
"interval": INTERVAL,
"pipes": PIPES,
"followed": FOLLOWED,
}
),
encoding="utf-8",
)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_410_follow_xy.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def py() -> list[dict]:
"""서버 차례 — 저장(좌표 채움 · L1) → 노선 저장(따라감 · L2) → 저장(L2) → 노선 저장(L3)."""
items = [SimpleNamespace(**item) for item in STRUCTURES]
steps = []
for index in range(len(LINES) - 1):
fill_structure_coordinates(items, LINES[index])
placed = [dict(vars(item)) for item in items]
follow_structures(items, LINES[index + 1])
steps.append({"placed": placed, "followed": [dict(vars(item)) for item in items]})
return steps
def _by_id(rows):
return {row["structure_id"]: row for row in rows}
def _close(a, b, tol=1e-3):
return (a is None and b is None) or abs(float(a) - float(b)) <= tol
@pytest.mark.parametrize("step", [0, 1])
def test_파이썬_TS_같은_값(ts, py, step):
for phase in ("placed", "followed"):
got, want = _by_id(ts["steps"][step][phase]), _by_id(py[step][phase])
for sid, row in want.items():
for key in ("chainage_m", "start_m", "end_m", "x", "y"):
assert _close(got[sid].get(key), row[key]), (step, phase, sid, key)
def test_안_옮긴_구조물_좌표_그대로(py):
for sid in ("S1", "S3", "S4"):
first = _by_id(py[0]["placed"])[sid]
for step in py:
for phase in ("placed", "followed"):
now = _by_id(step[phase])[sid]
assert _close(now["x"], first["x"]) and _close(now["y"], first["y"]), sid
def test_누가거리는_새_노선_투영(py):
for step, line in zip(py, LINES[1:]):
geo = LineString(line)
for sid in ("S1", "S2"):
row = _by_id(step["followed"])[sid]
assert row["chainage_m"] == float(round(geo.project(Point(row["x"], row["y"]))))
# 사토장 = 새 노선 일반 측점 중 가장 가까운 곳
site = _by_id(step["followed"])["S4"]
assert site["chainage_m"] % INTERVAL == 0
def test_마커로_옮긴_구조물은_옛_자리로_안_튐(py):
moved_xy = _at(L1, 60.0)
for step in py:
for phase in ("placed", "followed"):
row = _by_id(step[phase])["S2"]
assert _close(row["x"], moved_xy[0]) and _close(row["y"], moved_xy[1])
assert 50 <= row["chainage_m"] <= 70 # 옛 자리 140 m 근처로 안 돌아감
form = _by_id(py[1]["followed"])["S5"]
assert 20 <= form["chainage_m"] <= 40 and form["end_m"] - form["start_m"] == 15
def test_서버는_보낸_좌표가_누가거리를_낳으면_살림():
# 노선 고친 초안에서 옮긴 구조물 — 옛 정본과 자리가 달라도 보낸 좌표(노선 밖 4 m)가
# 그 누가거리를 낳음
xy = _at(L2, 90.0, 4.0)
item = SimpleNamespace(**_structure("S9", "point", 90.0, xy))
old = SimpleNamespace(**_structure("S9", "point", 140.0, _at(L2, 140.0)))
fill_structure_coordinates([item], L2, previous={"S9": old})
assert (item.x, item.y) == xy
stale = SimpleNamespace(**_structure("S9", "point", 30.0, xy)) # 누가거리만 옮김 = 낡은 좌표
fill_structure_coordinates([stale], L2, previous={"S9": old})
assert _close(stale.x, _at(L2, 30.0)[0]) and _close(stale.y, _at(L2, 30.0)[1])
def test_관_초안은_앞_따라감_좌표_옮긴_관은_노선_위(ts):
placed = {row["id"]: row for row in ts["pipesPlaced"]}
assert _close(placed["p1"]["x"], FOLLOWED[0]["x"]) and _close(
placed["p1"]["y"], FOLLOWED[0]["y"]
)
assert _close(placed["p2"]["x"], _at(L1, 30.0)[0]) and _close(placed["p2"]["y"], 0.0)
vertices = [RouteVertex(x=x, y=y, z=0.0, chainage_m=0.0) for x, y in L2]
points = [
PipePoint(chainage_m=70.0, x=FOLLOWED[0]["x"], y=FOLLOWED[0]["y"], id="p1"),
PipePoint(chainage_m=30.0, x=_at(L1, 30.0)[0], y=0.0, id="p2"),
]
want = {point.id: point.chainage_m for point in project_pipe_points(points, vertices)}
got = {row["id"]: row["chainage_m"] for row in ts["pipesFollowed"]}
assert got == want
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""411-B · 411-D 관 짝 = id — 앞 관을 지나 옮겨도 알약 · 측점 수 · id 그대로 ·
B06 열쇠 = 계산 묶음과 같음.
사용자 길(요구 252-6): 관 마커를 앞 관을 지나 옮김 → 옛 차례 짝이 이웃 id 를 빼앗고
새 `pipe-sN` 을 지어 새 측점 · 계획고 0 이 생기던 흠. 진짜 TS 를 vite ssr 로
묶어(`helper_411_b_entry.ts`) Node 로 잰다:
① `followPipeReports` 짝 = 관 고정 id
② 세션 id 장부(`movePipeId` · `movePipeIds`)
③ `followedMarks`
④ 배수 편집기 `followEditorPipes`(410 · 따라간 자리 받음)
⑤ B06 `useStationOwners` · `stationKeysOf`.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b411")
build = subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_411_b_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_411_b_entry.js", bundle)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_411_b_pipe_id_pairing.mjs"), str(bundle)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_알림_목록_짝은_id(out):
# 옮긴 관(p15000)이 앞 관 자리를 빼앗지 않음 · 구간도 그 관과 함께 밂
assert sorted(out["reports"]) == [["p10000", 100, None], ["p15000", 80, 75]]
def test_앞_관_지나_옮겨도_세션_id_그대로(out):
ids = out["ids"]
assert ids["at80"] == ids["idB"] # 옮긴 관 = 제 id
assert ids["at100"] == ids["idA"] # 이웃 관 = 제 id(새 pipe-sN 아님)
assert out["bookNext"] == "pipe-s3" # 헛 id 없음 = 알약 · 측점 수 그대로
def test_따라감이_여럿을_함께_밀어도_기록_안_잃음(out):
batch = out["batch"]
assert batch["at103"] == batch["q1"] and batch["at106"] == batch["q2"]
assert batch["next"] == "pipe-s3"
def test_알약은_그_관_자리(out):
assert out["marks"] == [["pipe-s2", 80], ["pipe-s1", 100], ["st-1", 42]]
def test_배수_편집기는_따라간_자리를_받음(out):
editor = out["editor"]
assert editor["pipes"] == [["p15000", 98, "confirmed"], ["p10000", 103, "stream"]]
assert editor["selected"] == "p15000" # 고름도 그 관
assert editor["ford98"] == "ford" and editor["at150"] is None # 시설 속성도 새 자리
assert out["editorSame"] == [98, 103]
def test_B06_열쇠는_서버_관_id_도_pipe(out):
assert out["keys"] == ["structure:st-1", "pipe:p8017"]
assert out["keys"][1] == out["pipelineKey"] # 계산 묶음 측점 열쇠와 같음
assert out["keysNoPipes"][1] == "structure:p8017" # 관 목록을 안 주면 옛 갈래(대조)