feat(B05·B06·B07): 소수 측점 횡단 — 관 · 구조물 자리마다 따로 · 이름 소수 2자리 · 화면 표기만 정수

PLAN 20장 소수 측점 횡단(sub6). 사용자 확정: 관 · 구조물이 놓인 소수 측점(예 119.8)에 그 자리 횡단을
따로 둠 · 백엔드 · 파일 · 저장값은 소수 그대로 · 화면 표기만 「측점 + 거리」의 거리를 정수로(5+20).
- 이름 규칙 한 벌(B05_Profile_Engine_Sections): cross_key · cross_filename · parse_cross_key ·
  CROSS_KEY_PATTERN — 정수 m 는 옛 이름 그대로(cross_00120m) · 소수 측점은 cross_00119.80m.
  find_cross_file = 읽기용(새 이름이 없으면 옛 정수 m 이름) · prune_stale_cross_files 도 이것으로
  가려 옛 프로젝트의 정수 이름 파일을 지우지 않음
- generate_sections: 정수 m 격자 붙임을 풂 — 같은 이름이 되는 0.005m 안 겹침만 하나로
- B06: 찾기 · 패치 입력(Router_Design 둘) · 샘플 넓힘(SampleExtend)이 find_cross_file ·
  DB 설계 찾기 · 병합(get_cross_section_design · merge_…_by_round)은 ROUND(chainage_m)=정수 →
  0.005m 안 매칭
- B07: 도면 id · 장 id(cross_{key}m · cross_s{key}m) 정규식 · 측점 키(cross_station — 정수는 int
  그대로라 옛 id · 수량표 키 안 바뀜) · 측점은 파일 속 값으로(옛 정수 이름 파일도) · 횡단 파일 찾기
- 화면 표기 한 벌 common_util/common_util_station.ts(stationLabel · stationPartsLabel · 순수 함수) —
  common_util_svg 가 다시 내보내고 B05 formatStation 을 대신함(B05 목록 · 표시 · 폼 범위 · 계획노선
  편집 · 비정규 측점 라벨 · B06 카드 · 종단 · 유토) · 입력 칸 · 저장값은 소수 그대로
- B05_Profile_UI_Structures_Panel 700줄 → 690(순수 도우미 셋을 Panel_Types 로)
- M02_Table_Fill 관연장 찾기는 이미 측점 값(정확 먼저 · 0.5m 안)이라 그대로
시험: test_b05_station_roundtrip(표기 5+20 · 옛 정수 이름 찾기 · 안 지움 · 119.8 과 120 따로) ·
test_b05_extra_stations_supplier(새 규칙) · test_b06_patch_station(119.8 한 건) ·
test_b05_b06_save_roundtrip(보낸 관 측점마다 소수 이름 횡단 파일 있음) 등 고친 대상 108 통과 ·
test_b05_station_merge 통과 · 타입검사 통과.
ORCA(IP 5181 · 사본 프로젝트 sub6_검증 — 끝나고 DB 행 · 폴더 지움 · 원본 안 씀): 노출형 횡단수로를
5+19.8 에 놓고 저장 → structures.json 119.8 · 종단 측점 119.8(irregular) + 120 · 파일
cross_00119.80m.json 과 cross_00120m.json · B05 목록 「5+20」 · B06 카드 「5+20 · 119.8m」 열림.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
This commit is contained in:
2026-09-29 11:54:51 +09:00
co-authored by Claude Opus 5.5
parent 2cbfc1f53f
commit 08e493f132
25 changed files with 254 additions and 140 deletions
@@ -101,4 +101,16 @@ out.moved = store.edits();
store.resetStation(200);
out.deleted = store.edits();
// 화면 표기 한 벌 — 거리는 정수로 보이기만(PLAN 20장 · 119.8 → 5+20)
const { stationLabel, stationPartsLabel } = loadTs(
path.join(ROOT, "common_util", "common_util_station.ts"),
);
out.labels = [
stationLabel(119.8, INTERVAL),
stationLabel(120, INTERVAL),
stationLabel(84.3, INTERVAL),
stationLabel(123.37, INTERVAL),
stationPartsLabel(5, 19.8),
];
process.stdout.write(JSON.stringify(out));
@@ -22,6 +22,8 @@ from typing import Any
import pytest
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
ROOT = Path(__file__).resolve().parents[2]
PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
@@ -425,6 +427,10 @@ class Flow:
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
if not any(abs(n - c) < 0.005 for n in near):
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
# 소수 측점 횡단(PLAN 20장) — 그 자리 횡단 파일이 소수 2자리 이름으로 따로 있음
name = cross_filename(c)
if not (root / "B06_Section" / "cross_sections" / name).is_file():
d.append(f"관 측점 {c} — 횡단 파일 {name} 없음")
self._check_designs()
def _check_designs(self) -> None:
@@ -192,7 +192,7 @@ def test_matching_signature_loads_points(tmp_path):
]
# ── 횡단 파일명 충돌 — 한 정수 미터에 측점 하나 · 관 · 구조물 자체 측점 우선(PLAN 19장) ──
# ── 소수 측점 횡단 — 관 · 구조물 자리마다 따로(이름 소수 2자리 · PLAN 20장) ──────────────
def _stations(extra_stations):
@@ -204,31 +204,32 @@ def _stations(extra_stations):
return result["longitudinal"]["stations"]
def test_extra_station_in_grid_meter_replaces_grid_station():
def test_extra_station_in_grid_meter_keeps_both_with_own_names():
stations = _stations(((100.4, "X"),))
chainages = [station["chainage_m"] for station in stations]
# 관 · 구조물 자체 측점 그대로 — 같은 정수 미터 격자 100 은 빠져 파일(cross_00100m)은 한 장
assert 100.4 in chainages and 100.0 not in chainages
assert 100.4 in chainages and 100.0 in chainages
labelled = [station for station in stations if station.get("structure") == "X"]
assert [(station["chainage_m"], station["kind"]) for station in labelled] == [
(100.4, "irregular")
]
assert cross_filename(100.4) == "cross_00100.40m.json"
assert len({cross_filename(c) for c in chainages}) == len(chainages)
def test_extra_station_at_route_end_keeps_end_station():
stations = _stations(((0.3, "S"), (349.8, "E")))
chainages = [station["chainage_m"] for station in stations]
# 시 · 종점은 격자 그대로 두고 라벨만 얹음
assert chainages[0] == 0.0 and chainages[-1] == 350.0
assert 0.3 not in chainages and 349.8 not in chainages
assert stations[0].get("structure") == "S" and stations[-1].get("structure") == "E"
def test_extra_station_on_grid_within_5mm_labels_grid_station():
stations = _stations(((100.003, "X"), (0.3, "S")))
labelled = {station["chainage_m"]: station for station in stations if station.get("structure")}
assert sorted(labelled) == [0.3, 100.0]
assert labelled[100.0]["kind"] == "regular" and labelled[0.3]["kind"] == "irregular"
def test_two_extras_in_same_meter_bucket_keep_one():
def test_two_extras_in_same_meter_both_kept():
stations = _stations(((80.2, "A"), (80.4, "B")))
assert [station["chainage_m"] for station in stations if station.get("structure")] == [80.2]
assert 80.0 not in [station["chainage_m"] for station in stations]
assert [station["chainage_m"] for station in stations if station.get("structure")] == [
80.2,
80.4,
]
assert 80.0 in [station["chainage_m"] for station in stations]
def test_live_extras_stay_irregular_and_filenames_stay_unique():
@@ -287,7 +288,7 @@ def test_run_section_generation_derives_stations_from_truth_files(project):
]
cross_dir = project / "B06_Section" / "cross_sections"
for name in ("cross_00084m", "cross_00150m", "cross_00201m", "cross_00276m"):
for name in ("cross_00084.30m", "cross_00149.73m", "cross_00200.92m", "cross_00275.71m"):
assert (cross_dir / f"{name}.json").is_file()
# 파일 = DB 행 — 종전에는 파일 23 vs DB 19로 갈렸다.
assert len(result["cross_sections"]) == len(stations)
+30 -3
View File
@@ -37,6 +37,10 @@ def test_칸을_안_고치면_실어_둔_측점_그대로(results):
assert results["r119"] == ["5", "19.8", 119.8]
def test_화면_표기는_거리를_정수로(results):
assert results["labels"] == ["5+20", "6+0", "4+4", "6+3", "5+20"]
def test_칸을_고치면_친_값(results):
assert results["edited"] == pytest.approx(125.2)
assert results["normalized"] == ["2", "10.0", 50]
@@ -96,8 +100,31 @@ def test_사용자_구조물_119_8_저장_다시_읽기_그대로(tmp_path):
SectionGenerationOptions(extra_stations=extras),
)
chainages = [round(s["chainage_m"], 3) for s in sections["longitudinal"]["stations"]]
# 횡단 측점도 자체 측점 119.8(같은 정수 미터 격자 120 대신 · 파일 이름 cross_00120m 한 장)
assert 119.8 in chainages and 120.0 not in chainages
assert cross_filename(119.8) == cross_filename(120.0)
# 소수 측점 횡단을 따로 둠(PLAN 20장) — 119.8 · 격자 120 둘 다 · 이름도 따로
assert 119.8 in chainages and 120.0 in chainages
assert (cross_filename(119.8), cross_filename(120.0)) == (
"cross_00119.80m.json",
"cross_00120m.json",
)
_, again = load_structures(str(tmp_path))
assert [s.chainage_m for s in again] == [119.8] and again[0].memo == "메모만"
def test_옛_프로젝트_정수_이름_횡단은_찾고_안_지움(tmp_path):
"""소수 측점이 정수 m 이름으로 저장된 옛 프로젝트 — 열 때 지우지 않고 그 파일을 씀."""
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file, prune_stale_cross_files
(tmp_path / "cross_00084m.json").write_text("{}", encoding="utf-8") # 옛 84.3
(tmp_path / "cross_00100m.json").write_text("{}", encoding="utf-8")
(tmp_path / "cross_00119.80m.json").write_text("{}", encoding="utf-8") # 새 이름
(tmp_path / "cross_00500m.json").write_text("{}", encoding="utf-8") # 잔재
stations = [{"chainage_m": 84.3}, {"chainage_m": 100.0}, {"chainage_m": 119.8}]
valid = prune_stale_cross_files(tmp_path, stations)
assert sorted(p.name for p in tmp_path.iterdir()) == [
"cross_00084m.json",
"cross_00100m.json",
"cross_00119.80m.json",
]
assert valid == {"cross_00084m.json", "cross_00100m.json", "cross_00119.80m.json"}
assert find_cross_file(tmp_path, 84.3).name == "cross_00084m.json"
assert find_cross_file(tmp_path, 119.8).name == "cross_00119.80m.json"
@@ -34,3 +34,9 @@ def test_행_없는_측점_수정이면_측점을_세움(monkeypatch):
def test_모든_수정에_행이_있으면_안_부름(monkeypatch):
assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == []
assert _calls(monkeypatch, [], [120.0]) == []
def test_소수_측점_수정은_정수_격자_행으로_갈음하지_않음(monkeypatch):
# 119.8 은 격자 120 과 다른 측점(횡단 cross_00119.80m · PLAN 20장) — 제 행이 없으면 세움
assert _calls(monkeypatch, [119.8], [120.0]) == [PROJECT]
assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == []