fix(B05): 시설 속성은 측점이 같은 관에만 붙임 · 옛 관 이동 예약 적용 제거
- 원인: 화면 attach · 서버 carry_facility_attributes 가 거리 제한 없이 가장 가까운 관에 속성을 붙여, 관 수 > 속성 수이거나 관을 이웃 쪽으로 옮기면 BOX · 세월교가 옆 관 측점으로 넘어감 - 고침: 화면 carryIndices ↔ 서버 carry_indices 짝 — 허용 오차 0.05m 안 가장 가까운 원본만 · 한 원본 한 번 · 못 붙인 관은 속성 없음(추천은 기존대로 빈칸에만) · 두 파일 머리에 짝 표기 - 관을 옮기면 속성이 따라감: 지도 끌기(onMove → move) · 되돌리기 · 구조물 폼(follow — 사라진 관 하나 · 새 관 하나일 때) - B06 옛 「관 이동 예약」(culvertmove): 저장 때 읽지도 적용하지도 않음 — flushCulvertOptions · createCulvertOptionWriter 에서 이동 키 제거 - 시험: 거울 시험 test_b05_facility_carry(화면 · 서버 같은 답 · 끌기 · 되돌리기) · 옛 최근접 · 이동 예약 적용 시험을 새 규칙으로 고침 - 확인: ORCA 5180 랩탑_보조 — 세월교(173.09) 8m 앞 165m 에 옵션 없는 관 추가 → 저장 → pipe_points.json 165 = 속성 없음 · 173.09 = ford_bridge · 나머지 11건 전후 같음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
This commit is contained in:
@@ -170,14 +170,28 @@ def test_carry_attributes_restores_facility_after_rebuild():
|
||||
assert carried[1].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
def test_carry_attributes_uses_nearest_when_chainage_shifted():
|
||||
"""계산기가 좌표를 미세 조정해도(스냅) 가장 가까운 원본에서 승계한다 — _retag과 동일 기준."""
|
||||
def test_carry_attributes_only_within_tolerance():
|
||||
"""계산기 반올림(0.01m) 차이는 승계 · 허용 오차(0.05m) 밖은 옆 관 속성을 안 가져감."""
|
||||
requested = parse_pipe_points([{"chainage_m": 150.0, "facility": "box_culvert"}])
|
||||
rebuilt = [PipePoint(chainage_m=150.37)]
|
||||
near = carry_facility_attributes([PipePoint(chainage_m=150.004)], requested)
|
||||
far = carry_facility_attributes([PipePoint(chainage_m=150.37)], requested)
|
||||
|
||||
assert near[0].facility == PIPE_FACILITY_BOX
|
||||
assert far[0].facility == PIPE_FACILITY_PIPE and far[0].options is None
|
||||
|
||||
|
||||
def test_carry_attributes_new_pipe_before_box_keeps_box_in_place():
|
||||
"""옵션 없는 관을 BOX 앞에 두어도 BOX 속성은 제 측점에 남음 — 관 수 > 속성 원본 수."""
|
||||
requested = parse_pipe_points(
|
||||
[{"chainage_m": 100.0, "facility": "box_culvert", "options": {"body_width_m": 2.0}}]
|
||||
)
|
||||
rebuilt = [PipePoint(chainage_m=97.0), PipePoint(chainage_m=100.0)]
|
||||
|
||||
carried = carry_facility_attributes(rebuilt, requested)
|
||||
|
||||
assert carried[0].facility == PIPE_FACILITY_BOX
|
||||
assert carried[0].facility == PIPE_FACILITY_PIPE and carried[0].options is None
|
||||
assert carried[1].facility == PIPE_FACILITY_BOX
|
||||
assert carried[1].options == {"body_width_m": 2.0}
|
||||
|
||||
|
||||
def test_carry_attributes_without_reference_is_noop():
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""시설 속성 붙이기 거울 시험 — 화면 `carryIndices`(B05_Profile_UI_Drainage_Facility.ts) ↔
|
||||
서버 `carry_indices`(common_util_drainage_pipes.py) 가 같은 답을 내는지.
|
||||
|
||||
규칙: 허용 오차(0.05m) 안 가장 가까운 원본만 · 한 원본은 한 번만 · 못 붙인 관은 속성 없음.
|
||||
화면 보관소는 관을 끌면(`move`) · 되돌리기로 옮기면(`follow`) 속성을 따라 옮김.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_drainage_pipes import CARRY_TOLERANCE_M, carry_indices
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FACILITY = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts"
|
||||
CULVERT = ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts"
|
||||
|
||||
CASES = [
|
||||
([100.0, 200.0], [100.0, 200.0]), # 제자리
|
||||
([100.004], [100.0]), # 반올림 차이
|
||||
([95.0, 100.0], [100.0]), # 관 수 > 원본 수 — 앞 관이 BOX 를 가져가지 않음
|
||||
([150.37], [150.0]), # 허용 오차 밖
|
||||
([100.01, 100.02], [100.0]), # 한 원본은 한 번만
|
||||
([100.0], [99.97, 100.02]), # 허용 오차 안 가장 가까운 것
|
||||
([100.0, 100.0], [100.06, 100.04]), # 경계 — 0.06 은 밖
|
||||
([100.0], []), # 원본 없음
|
||||
]
|
||||
|
||||
|
||||
def _ts_block(body: str, head: str) -> str:
|
||||
"""최상위 선언 하나를 떼어 냄 — 상수는 한 줄 · 함수는 첫 칸 `}` 까지."""
|
||||
tail = r"[^\n]*;$" if head.startswith("const") else r"[\s\S]*?^\}$"
|
||||
found = re.search(rf"^export {head}{tail}", body, re.M)
|
||||
assert found, head
|
||||
return found.group(0).replace("export ", "")
|
||||
|
||||
|
||||
def _run_node(script: str) -> object:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_서버_규칙():
|
||||
assert CARRY_TOLERANCE_M == 0.05
|
||||
assert [carry_indices(t, s) for t, s in CASES] == [
|
||||
[0, 1],
|
||||
[0],
|
||||
[None, 0],
|
||||
[None],
|
||||
[0, None],
|
||||
[1],
|
||||
[1, None],
|
||||
[None],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_화면과_서버가_같은_답():
|
||||
body = FACILITY.read_text(encoding="utf-8")
|
||||
script = (
|
||||
_ts_block(body, "const CARRY_TOLERANCE_M")
|
||||
+ "\n"
|
||||
+ _ts_block(body, "function carryIndices")
|
||||
+ f"\nconst cases = {json.dumps(CASES)};"
|
||||
+ "\nconsole.log(JSON.stringify([CARRY_TOLERANCE_M,"
|
||||
+ " cases.map(([t, s]) => carryIndices(t, s))]));"
|
||||
)
|
||||
tolerance, picks = _run_node(script)
|
||||
assert tolerance == CARRY_TOLERANCE_M
|
||||
assert picks == [carry_indices(t, s) for t, s in CASES]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_화면_보관소_끌기_되돌리기_때_속성이_따라감():
|
||||
"""BOX(100) · 옵션 없는 관(80) — 관을 끌거나 되돌려도 BOX 속성은 BOX 관에만."""
|
||||
body = FACILITY.read_text(encoding="utf-8")
|
||||
script = (
|
||||
"type PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n"
|
||||
"interface FacilityAttributes { facility: string; start_m?: number; end_m?: number;"
|
||||
" options?: Record<string, string | number> }\n"
|
||||
+ "\n".join(
|
||||
_ts_block(body, head)
|
||||
for head in (
|
||||
"const CARRY_TOLERANCE_M",
|
||||
"function carryIndices",
|
||||
"function createFacilityStore",
|
||||
)
|
||||
)
|
||||
+ "\n"
|
||||
+ re.search(r"^function hasExtras[\s\S]*?^\}$", body, re.M).group(0)
|
||||
+ """
|
||||
const store = createFacilityStore();
|
||||
store.replaceFromResponse([{ chainage_m: 80 }, { chainage_m: 100, facility: "box_culvert" }]);
|
||||
const kinds = (list) => store.attach(list.map((c) => ({ chainage_m: c, source: "user" })))
|
||||
.map((p) => p.facility ?? "-");
|
||||
const out = {};
|
||||
out.관을_BOX_곁으로 = kinds([99, 100]); // 옵션 없는 관을 BOX 곁으로 끌기(보관소 변화 없음)
|
||||
store.move(100, 130); // BOX 를 끌기
|
||||
out.BOX_끌기 = kinds([80, 130]);
|
||||
store.follow([80, 130], [80, 100]); // 되돌리기 — BOX 가 제자리로
|
||||
out.되돌리기 = kinds([80, 100]);
|
||||
store.follow([80, 100], [81, 120]); // 둘이 한꺼번에 바뀜 — 옮기지 않음
|
||||
out.둘_바뀜 = kinds([81, 120]);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
)
|
||||
assert _run_node(script) == {
|
||||
"관을_BOX_곁으로": ["-", "box_culvert"],
|
||||
"BOX_끌기": ["-", "box_culvert"],
|
||||
"되돌리기": ["-", "box_culvert"],
|
||||
"둘_바뀜": ["-", "-"],
|
||||
}
|
||||
|
||||
|
||||
def test_옛_관_이동_예약은_저장_때_안_씀():
|
||||
body = CULVERT.read_text(encoding="utf-8")
|
||||
assert "readMoves" not in body and "movedTo" not in body and "moveKey" not in body
|
||||
@@ -7,8 +7,8 @@ B05 는 관 목록 전체를 세션 스냅샷(`pipes`)으로 담고, B06 은 바
|
||||
여기서 지키는 것은 둘이다.
|
||||
① 저장기(`createCulvertOptionWriter`)에는 **관을 넣고 빼는 길이 없다** — 남아 있으면
|
||||
관 목록을 담는 자리가 다시 둘이 된다.
|
||||
② 옛 세션에 남아 있던 **이동 예약은 저장 때 흘려보낸다** — 합치기 전에 만들어 둔
|
||||
예약이 조용히 사라지면 안 된다.
|
||||
② 옛 세션에 남아 있던 **이동 예약은 저장 때 안 씀** — 관 위치는 스냅샷으로만 옮김
|
||||
(적용하면 관이 이웃 측점으로 튀어 BOX · 세월교 속성이 넘어감).
|
||||
|
||||
실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식).
|
||||
"""
|
||||
@@ -62,21 +62,17 @@ global.window = {
|
||||
};
|
||||
|
||||
const api = require(process.argv[3]);
|
||||
const writer = api.createCulvertOptionWriter(
|
||||
() => "p1",
|
||||
() => "opt",
|
||||
undefined,
|
||||
() => "move",
|
||||
);
|
||||
const writer = api.createCulvertOptionWriter(() => "p1", () => "opt", undefined);
|
||||
|
||||
// ① 관을 넣고 빼는 길이 저장기에 남아 있으면 안 된다.
|
||||
const methods = Object.keys(writer).sort();
|
||||
|
||||
// ② 합치기 전 세션에 남아 있던 이동 예약은 저장 때 흘려보낸다.
|
||||
// ② 옛 세션의 이동 예약은 남아 있어도 안 씀 — 구간값만 제 측점에 나감.
|
||||
store.set("move", JSON.stringify({ "120.00": 135 }));
|
||||
store.set("opt", JSON.stringify({ "120.00": { pipe_count: 2 } }));
|
||||
|
||||
api
|
||||
.flushCulvertOptions("p1", "opt", "move")
|
||||
.flushCulvertOptions("p1", "opt")
|
||||
.then(() => {
|
||||
writeFileSync(
|
||||
process.argv[2],
|
||||
@@ -134,6 +130,5 @@ def test_관_목록을_담는_자리는_하나뿐이다(tmp_path: Path) -> None:
|
||||
# ① 저장기에는 구간값 예약과 내보내기만 있다 — 관을 넣고 빼는 길은 스냅샷 한 곳뿐이다.
|
||||
assert result["methods"] == ["flush", "queue"]
|
||||
|
||||
# ② 옛 세션의 이동 예약은 저장 때 반영되고 비워진다.
|
||||
assert result["savedChainages"] == [135, 200]
|
||||
assert result["moveLeft"] is None
|
||||
# ② 옛 세션의 이동 예약은 저장 때 안 씀 — 관은 제 측점 그대로.
|
||||
assert result["savedChainages"] == [120, 200]
|
||||
|
||||
Reference in New Issue
Block a user