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

# Conflicts:
#	B05_Profile/B05_Profile_UI_RouteEdit.ts
#	ui_template/ui_template_locale_b2.ts
This commit is contained in:
2026-10-17 12:29:44 +09:00
23 changed files with 604 additions and 376 deletions
@@ -99,7 +99,7 @@ out.viewOf = {
buildAlignment: () => null,
store: { edits: () => ({}) },
projectId: "p",
pagePipeline: { pipelineGrade: () => undefined }, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
pagePipeline: { pipelineGrade: () => undefined, pipelineOwnsCross: () => false }, // 계산 묶음(346-1 · 365-4) — 묶음 없으면 아무 일 없음
scheduleCrossPreview: () => s.previews++,
draw: () => s.draws++,
root: inPopup(),
+130 -1
View File
@@ -177,6 +177,10 @@ async function savedBaseOf() {
}
const store = load("A00_Common", "design", "design_store.ts");
// B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜
STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined };
STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" };
const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts");
const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null);
/** 묶음 새 노선 상세(카드가 그리는 것) — 측점 · 계획고 · 절 · 성토 면적. */
@@ -199,6 +203,7 @@ async function run() {
const savedBase = await savedBaseOf();
const shop = store.useDesignStore(P, null);
const holds = [];
let unstable = 0;
const link = () => ({
pipelineSource: () => ({
base: savedBase,
@@ -215,6 +220,7 @@ async function run() {
holds.push(saved === job.detail ? "saved" : saved ? "other" : "null");
return async () => "saved";
},
onUnstable: () => (unstable += 1),
};
const open = (label) => {
const bridge = pageMod.routeDraftBridge(P, link(), options);
@@ -249,6 +255,76 @@ async function run() {
stages: Object.keys(reopenRun.stages),
};
// ⑤ 초안 동안 B06 고침 — 세션 칸(측점 열쇠) → pipelineCross → 카드(묶음 새 노선 상세)
const pipe = pageMod.pagePipeline(P);
const target = pipe.output("ground").stations.find((s) => s.kind === "grid" && s.chainageM > 30);
const cell = (ground) => ({ [target.key]: { ground_type: ground, section_mode: "left_cut" } });
const groundAt = () =>
pageMod
.pipelineDetail(P)
.cross_sections.find((s) => Math.abs(s.chainage_m - target.chainageM) < 1e-3)?.design
?.ground_type ?? null;
const before = groundAt();
page.writeState("crossdesign", cell("blasting_rock"), P, 7);
let mark = runs().length;
pageMod.pipelineCross(P);
await settle(mark + 1);
await tick(20);
const edit = {
key: target.key,
before,
after: groundAt(),
input: pipe.input("cross").byKey[target.key]?.groundType ?? null,
runs: runs().length - mark,
sources: runs()[runs().length - 1].sources,
};
// ⑦ B05 오른쪽 횡단 판 = 묶음 그 측점(카드와 같은 계획고 · 면적)
const card = pageMod.pipelineDetail(P).cross_sections;
const panes = card.map((section) => {
const shown = pane.pipelineCrossAt(pipe, section.chainage_m + 0.4, job.interval, undefined);
return [
r3(shown?.chainage_m) === r3(section.chainage_m),
r3(shown?.design?.design_elevation_m) === r3(section.design?.design_elevation_m),
r3(shown?.design?.cut_area_m2) === r3(section.design?.cut_area_m2),
shown?.samples.length === section.samples.length,
];
});
const paneCheck = {
count: card.length,
same: panes.filter((row) => row.every(Boolean)).length,
elevation: r3(
pane.pipelineCrossAt(pipe, target.chainageM, job.interval, undefined)?.design
?.design_elevation_m,
),
};
// ⑥ 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜(알림 없음) = 1 바퀴 · 안 맞음 = 3 바퀴 뒤 알림
mark = runs().length;
const same = await pageMod.reconcilePagePipeline(P);
const sameRuns = runs().length - mark;
page.writeState("crossdesign", cell("ripping_rock"), P, 7);
mark = runs().length;
const once = await pageMod.reconcilePagePipeline(P);
const loop = {
same,
sameRuns,
once,
onceRuns: runs().length - mark,
onceGround: groundAt(),
};
let n = 0;
const realStandard = STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross;
STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = () => ({
...job.standard,
n: (n += 1),
});
mark = runs().length;
loop.unstable = await pageMod.reconcilePagePipeline(P);
loop.unstableRuns = runs().length - mark;
loop.alerts = unstable;
STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = realStandard;
await pageMod.reconcilePagePipeline(P);
// ④ 초안 버림 — 열 때 맡긴 초안 전 상세로 되돌림(저장 정본)
again.bridge.edited(null);
const dropRun = await settle(runs().length + 1);
@@ -260,7 +336,60 @@ async function run() {
};
pageMod.disposePagePipeline(P);
page.clearDrafts(P);
return { kept, b05, b06, dropped };
// ⑨ 저장 노선 ▲▼ 한 번 = 횡단 한 벌(365-4) — 묶음 cross 가 다시 셈 창구를 부르고 그 상세 설계를 냄
const Q = "proj-365b";
window.localStorage.setItem("frd_current_project_id", Q);
let refreshCalls = 0;
const qBridge = pageMod.routeDraftBridge(Q, link(), {
stationIntervalM: job.interval,
savedCross: async (detail, _routeId, stations) => {
if (!detailMod.detailCross(detail, stations)) return null;
refreshCalls += 1;
return detailMod.detailCross(detail, stations);
},
});
pageMod.pipelineEarthwork(Q, { conversion: job.conversion });
const qPipe = pageMod.pagePipeline(Q);
await settle(runs().length + 1);
await tick(20);
const openCalls = refreshCalls;
const key = alignment.chainageKey(savedBase.stations[3].chainage_m);
mark = runs().length;
pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.3 }, curve_radii: {} });
const gradeRun = await settle(mark + 1);
const firstKey = qPipe.output("ground").stations[0].key;
const saved = {
owns: pageMod.pipelineOwnsCross(Q),
openCalls,
gradeCalls: refreshCalls - openCalls,
runs: runs().length - mark,
sources: gradeRun.sources,
fromDetail:
qPipe.output("cross").byKey[firstKey]?.detail === job.detail.cross_sections[0].design,
};
// ⑧ 중심점 좌우(356-3 꼴) — 노선 한 걸음 → 새 종단이 오면 계획고 맞춤 · 첫 차례는 cross 전에 밀림(무거운 셈 한 번)
let graded = false;
const stopShift = qPipe.subscribe((event) => {
if (event.kind !== "stage" || event.stage !== "profile" || graded) return;
if (event.generation !== qPipe.generation) return;
graded = true;
void Promise.resolve().then(() =>
pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.1 }, curve_radii: {} }),
);
}, "중심점 좌우");
mark = runs().length;
qBridge.edited(draftOf(shift(1, 0, 1)));
await settle(mark + 2);
await tick(job.haulDelayMs + 30);
stopShift();
const shiftRuns = runs()
.slice(mark)
.map((r) => ({ outcome: r.outcome, cross: "cross" in r.stages, sources: r.sources }));
pageMod.disposePagePipeline(Q);
page.clearDrafts(Q);
return { kept, b05, b06, dropped, edit, loop, pane: paneCheck, saved, shiftRuns };
}
run()
@@ -1,6 +1,7 @@
/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 —
* `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다.
* 서버 묻기 · 그림 조각은 가짜로 채운다 — 「무슨 반폭을 묻고 · 무슨 반폭으로 선을 잡나」만 본다.
* 계산 묶음 · 그림 조각은 가짜로 채운다 — 「무슨 반폭으로 선을 잡나」만 본다(365-3 · 서버 길 없음 ·
* 묶음 그 측점 원지반 표본 = 좌우 20 m).
*/
const fs = require("fs");
const path = require("path");
@@ -56,7 +57,6 @@ globalThis.ResizeObserver = class {
disconnect() {}
};
const asked = [];
const STUBS = {
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} },
"../common_util/common_util_station": { stationLabel: (m) => String(m) },
@@ -65,17 +65,24 @@ const STUBS = {
CROSS_PLOT_PAD: 0,
summarizeCross: () => "",
},
// 서버 흉내 — 요청 반폭을 그대로 되돌려 주고, 없으면 기본 20.
"./B05_Profile_Api_Replan": {
fetchCrossPreview: async (_project, request) => {
asked.push(request.cross_half_width_m ?? null);
return {
chainage_m: request.chainage_m,
cross_half_width_m: request.cross_half_width_m ?? 20,
samples: [],
design: null,
};
},
// 계산 묶음 흉내 — 측점 하나(100 m) · 원지반 표본 좌우 20 m
"../A00_Common/design/design_pipeline_page": {
pagePipeline: () => ({
output: (name) =>
name === "ground" ? { stations: [{ key: "grid:5", chainageM: 100 }] } : null,
input: () => null,
subscribe: () => () => undefined,
}),
},
"../A00_Common/design/design_store": {
useDesignStore: () => ({ subscribe: () => () => undefined }),
},
"./logic/B05_Profile_Pipeline_Detail": {
stationSection: (station) => ({
chainage_m: station.chainageM,
label: null,
samples: [-20, 0, 20].map((offset_m) => ({ offset_m, elevation_m: 100, valid: true })),
}),
},
};
@@ -111,17 +118,17 @@ async function run() {
const out = {};
out.before = cross.watched();
await cross.open(100);
out.empty = { asked: asked.at(-1), watched: cross.watched() };
out.empty = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:3", "15");
await cross.refresh();
out.applied15 = { asked: asked.at(-1), watched: cross.watched() };
out.applied15 = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:12", "8.5");
session.setItem("aislo:draft:cross-display:p-2:99", "40");
await cross.refresh();
out.latestRoute = { asked: asked.at(-1), watched: cross.watched() };
out.latestRoute = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:12", "abc");
await cross.refresh();
out.badValue = { asked: asked.at(-1), watched: cross.watched() };
out.badValue = { watched: cross.watched() };
return out;
}
run().then((out) => console.log(JSON.stringify(out)));
@@ -138,10 +138,6 @@ def test_수치지형도_서피스_없으면_라이다만(tmp_path):
def test_허용_목록_밖_거절(bad):
with pytest.raises(ValidationError):
terrain_router.ElevationRequest(points=[(0.0, 0.0)], terrain=bad)
with pytest.raises(ValidationError):
terrain_router.CrossPreviewRequest(
vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad
)
with pytest.raises(ValidationError):
replan.RouteInitialRequest(terrain=bad)
@@ -167,8 +163,8 @@ def test_화면_고르기_배선():
assert '<option value="sheet">수치지형도</option>' in chrome
assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main
assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api
# 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 2
# 모달 지반고가 고른 지형을 실어 보냄 · 횡단 미리보기 = 묶음(365-3) · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 1
assert "terrain: analysisTerrain(projectId)" in initial_api
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
assert "terrain" not in replan_fn
@@ -359,30 +359,3 @@ def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> Non
with pytest.raises(RuntimeError):
sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False)
assert seen["station_snap"] is True
def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Terrain as terrain
from common_util.common_util_route_polyline import build_planned_polyline
monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat())
built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False)
points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64))
spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves])
assert len(spans) == 1
target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5)
assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음
def ask(snap: bool) -> float:
request = terrain.CrossPreviewRequest(
vertices=[{"x": x, "y": y} for x, y in _NODES],
chainage_m=target,
min_radius_m=40.0,
station_interval_m=20.0,
station_snap=snap,
)
params = {"source_filter": "f", "method": "m", "smooth": False}
return terrain._cross_preview(tmp_path, params, request)["chainage_m"]
assert ask(True) == target
assert ask(False) % 20 == 0
@@ -38,20 +38,20 @@ def out():
def test_노선_초안_동안_카드는_새_노선_상세(out):
cards = out["cards"]
assert cards["before"] == "saved"
assert cards["afterDraft"] == ["draft-profile-1", "draft-cross-1"]
assert cards["afterDraft"] == ["draft-cross-1"] # profile 차례 중간 상세는 안 그림(365-4)
assert cards["draftNow"] == "draft-cross-1"
def test_숨은_동안은_안_그림(out):
cards = out["cards"]
assert cards["whileHidden"] == ["draft-profile-1", "draft-cross-1"]
assert cards["whileHidden"] == ["draft-cross-1"]
assert cards["hiddenNow"] == "draft-cross-2"
def test_초안_버리면_저장_노선_카드(out):
cards = out["cards"]
assert cards["discarded"][-1] == "saved" and cards["savedNow"] == "saved"
assert cards["afterStop"] == 3 # 끊은 뒤엔 안 그림
assert cards["afterStop"] == 2 # 끊은 뒤엔 안 그림
def test_알약은_따라간_누가거리(out):
@@ -5,6 +5,15 @@
- 노선 초안 둔 채 다시 열기(새로고침 · B06 바로 열기 · 편집기 없음) = B05 길과 같은 초안 상세
(측점 · 계획고 · 면적) · 측점을 가게에 다시 냄 · 초안 전 = 저장 상세(열 때 맡김) → 버리면 saved.
- 초안 동안 측점 갈아끼움은 메모리만 — 세션 상세(저장 노선)를 새 측점 + 옛 설계로 덮지 않음.
못박는 것(365-2)
- 초안 동안 B06 고침(세션 칸 · 측점 열쇠) → `pipelineCross` → 묶음 cross 입력 · 카드에 보임.
- 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜 = 1 바퀴 · 안 맞으면 3 바퀴 뒤 「계산 불안정」 한 번.
못박는 것(365-3)
- B05 오른쪽 횡단 판 = 묶음 그 측점 — 계획고 · 절토 면적 · 표본 = B06 카드 · 서버 길 없음.
못박는 것(365-4)
- 저장 노선 ▲▼ 한 번 = 횡단 한 벌 — 묶음 cross 가 다시 셈 창구(상세 설계) 한 번 · 종단도 예약 끔.
- 중심점 좌우 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만).
- B06 다시 그림 = 겹친 신호(갈아끼움 · 묶음 cross · 초안) 한 번 · profile 차례는 안 그림.
짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) ·
`helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr).
@@ -99,6 +108,58 @@ def test_365_1_초안_버림_저장_정본(r):
assert d["restoreWhy"] == "saved"
def test_365_2_초안_동안_횡단_고침_카드에(r):
e = r["edit"]
assert e["before"] != "blasting_rock"
assert e["input"] == "blasting_rock" and e["after"] == "blasting_rock"
assert e["runs"] == 1 and e["sources"] == ["cross"]
def test_365_2_고리_바퀴_수(r):
loop = r["loop"]
assert loop["same"] == 0 and loop["sameRuns"] == 0 # 같은 입력 = 안 돎
assert loop["once"] == 1 and loop["onceRuns"] == 1 and loop["onceGround"] == "ripping_rock"
assert loop["unstable"] == -1 and loop["unstableRuns"] == 3 and loop["alerts"] == 1
def test_365_3_평면_횡단_판_카드와_같음(r):
p = r["pane"]
assert p["count"] > 5 and p["same"] == p["count"]
assert p["elevation"] is not None
def test_365_3_서버_횡단_미리보기_길_없음():
router = (ROOT / "B05_Profile/B05_Profile_Router_Terrain.py").read_text(encoding="utf-8")
api = (ROOT / "B05_Profile/B05_Profile_Api_Replan.ts").read_text(encoding="utf-8")
edit = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "cross-preview" not in router and "cross-preview" not in api
assert "crossPreview.refresh()" not in edit # 묶음 cross 차례가 그림
def test_365_4_저장_노선_계획고_횡단_한_벌(r):
s = r["saved"]
assert s["owns"] and s["openCalls"] == 1
assert s["gradeCalls"] == 1 and s["runs"] == 1 and s["sources"] == ["grade"]
assert s["fromDetail"] # 묶음 cross = 상세 설계 그대로(엔진 따로 안 셈)
def test_365_4_중심점_좌우_횡단_한_번(r):
runs = r["shiftRuns"]
assert [x["outcome"] for x in runs] == ["dropped", "done"], runs
assert [x["cross"] for x in runs] == [False, True]
assert runs[1]["sources"] == ["route", "grade"] # 밀린 노선 범위 + 계획고 = 한 차례
def test_365_4_다시_그림_한_번():
swap = (ROOT / "B06_Section/B06_Section_UI_Page_Station_Swap.ts").read_text(encoding="utf-8")
view = (ROOT / "A00_Common/design/design_view_cross.ts").read_text(encoding="utf-8")
profile = (ROOT / "A00_Common/design/design_profile.ts").read_text(encoding="utf-8")
assert 'event.stage !== "cross") return;' in swap # profile 차례 중간 상세는 안 그림
body = view.split("async function refreshDetailForStructures")[1].split("loadSectionDetail")[0]
assert "setTimeout(next, 0)" in body # 겹친 신호는 seq 가 앞 차례를 버림 = 한 번
assert "if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview();" in profile
@pytest.fixture(scope="module")
def swap(tmp_path_factory) -> dict:
work = tmp_path_factory.mktemp("b365")
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증.
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 서버 기본값.
선 길이는 서버가 덮은 반폭(= 횡단 미리보기 원지반 선 범위)을 그대로 쓴다.
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 원지반 선 범위
(계산 묶음 그 측점 표본 · 365-3).
Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다.
"""
@@ -31,21 +31,17 @@ def test_측점을_고르기_전에는_선이_없다(results):
assert results["before"] is None
def test_적용한_적_없으면_서버_기본값_양쪽(results):
assert results["empty"]["asked"] is None
def test_적용한_적_없으면_원지반_범위_양쪽(results):
assert results["empty"]["watched"] == {"chainageM": 100, "leftM": 20, "rightM": 20}
def test_적용값이_있으면_그_값_양쪽(results):
assert results["applied15"]["asked"] == 15
assert results["applied15"]["watched"] == {"chainageM": 100, "leftM": 15, "rightM": 15}
def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(results):
assert results["latestRoute"]["asked"] == 8.5
assert results["latestRoute"]["watched"]["leftM"] == 8.5
def test_망가진_값은_기본값으로(results):
assert results["badValue"]["asked"] is None
assert results["badValue"]["watched"]["rightM"] == 20