⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt` 가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는 시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음. - `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침** - `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시) - 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q` 옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0 ⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""B05 구조물 정본 저장·조회·검증 테스트 (크로스체크 지적 2 반영 강화판)."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from B05_Profile.B05_Profile_Structures_Repository import (
|
|
StructureRevisionConflict,
|
|
load_structures,
|
|
save_structures,
|
|
structures_file_path,
|
|
)
|
|
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
|
|
|
|
|
@pytest.fixture()
|
|
def project_root(tmp_path):
|
|
return str(tmp_path / "project")
|
|
|
|
|
|
def _point(chainage=100.0, type_id="erosion_check", **overrides):
|
|
# 골막이 형식(재료)은 설계자 선택이라 필수 — 길이·높이는 사용자 확정 기본값이 있다.
|
|
data = {
|
|
"type_id": type_id,
|
|
"placement": "point",
|
|
"chainage_m": chainage,
|
|
"options": {"form": "돌"},
|
|
}
|
|
data.update(overrides)
|
|
return StructureInstance.model_validate(data)
|
|
|
|
|
|
def _interval(start=10.0, end=40.0, type_id="ditch_side", **overrides):
|
|
# 측구 형식도 필수 선택 — 깊이는 별표2 단일값이라 기본값이 있다.
|
|
data = {
|
|
"type_id": type_id,
|
|
"placement": "interval",
|
|
"start_m": start,
|
|
"end_m": end,
|
|
"options": {"form": "일반형(제형)"},
|
|
}
|
|
data.update(overrides)
|
|
return StructureInstance.model_validate(data)
|
|
|
|
|
|
# ── 기본 왕복·판번호 (기존 회귀) ────────────────────────────────────────────
|
|
|
|
|
|
def test_load_empty_project_returns_revision_zero(project_root):
|
|
assert load_structures(project_root) == (0, [])
|
|
|
|
|
|
def test_disabled_type_structures_still_save_and_load(project_root):
|
|
"""B06 이관으로 enabled=False가 된 타입(예: 측구)의 기존 저장분 — 백엔드는
|
|
전체 레지스트리로 검증하므로 로드·재저장이 계속 통과해야 한다(2026-08-17 재편).
|
|
화면 노출만 프론트 enabled 필터가 막는다."""
|
|
revision = save_structures(project_root, [_interval(type_id="ditch_side")], base_revision=0)
|
|
assert revision == 1
|
|
_, loaded = load_structures(project_root)
|
|
assert loaded[0].type_id == "ditch_side"
|
|
|
|
|
|
def test_save_then_load_roundtrip(project_root):
|
|
revision = save_structures(project_root, [_point(), _interval()], base_revision=0)
|
|
assert revision == 1
|
|
loaded_revision, loaded = load_structures(project_root)
|
|
assert loaded_revision == 1
|
|
assert len(loaded) == 2
|
|
|
|
|
|
def test_save_assigns_and_keeps_structure_ids(project_root):
|
|
save_structures(project_root, [_point()], base_revision=0)
|
|
_, loaded = load_structures(project_root)
|
|
kept = loaded[0].structure_id
|
|
assert kept
|
|
|
|
loaded[0].chainage_m = 150.0
|
|
save_structures(project_root, loaded, base_revision=1)
|
|
_, again = load_structures(project_root)
|
|
assert again[0].structure_id == kept
|
|
|
|
|
|
def test_stale_base_revision_raises_conflict(project_root):
|
|
save_structures(project_root, [_point()], base_revision=0)
|
|
with pytest.raises(StructureRevisionConflict):
|
|
save_structures(project_root, [_point(300.0)], base_revision=0)
|
|
|
|
|
|
def test_pipe_managed_types_are_rejected(project_root):
|
|
with pytest.raises(ValueError):
|
|
save_structures(project_root, [_point(50.0, type_id="pipe")], base_revision=0)
|
|
|
|
|
|
def test_unknown_type_id_is_rejected(project_root):
|
|
with pytest.raises(ValueError):
|
|
save_structures(project_root, [_point(50.0, type_id="no_such_type")], base_revision=0)
|
|
|
|
|
|
def test_saved_file_is_valid_json_with_revision(project_root):
|
|
save_structures(project_root, [_point()], base_revision=0)
|
|
with open(structures_file_path(project_root), encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
assert payload["revision"] == 1
|
|
|
|
|
|
def test_corrupt_file_does_not_crash_load(project_root):
|
|
save_structures(project_root, [_point()], base_revision=0)
|
|
with open(structures_file_path(project_root), "w", encoding="utf-8") as handle:
|
|
handle.write("{ broken json")
|
|
assert load_structures(project_root) == (0, [])
|
|
|
|
|
|
# ── 폐지 필드(설치측·이격) 이관 — 2026-08-17 사용자 지시 ────────────────────
|
|
|
|
|
|
def _write_raw(project_root, structures):
|
|
save_structures(project_root, [_point()], base_revision=0)
|
|
with open(structures_file_path(project_root), "w", encoding="utf-8") as handle:
|
|
json.dump({"revision": 3, "structures": structures}, handle, ensure_ascii=False)
|
|
|
|
|
|
def test_legacy_side_and_offset_keys_are_dropped_on_load(project_root):
|
|
# 구 저장분을 그대로 넘기면 extra="forbid"에 걸려 정본이 통째로 버려진다.
|
|
_write_raw(
|
|
project_root,
|
|
[
|
|
{
|
|
"structure_id": "abc",
|
|
"type_id": "erosion_check",
|
|
"placement": "point",
|
|
"chainage_m": 100.0,
|
|
"side": "left",
|
|
"offset_m": 2.5,
|
|
"options": {"form": "돌"},
|
|
}
|
|
],
|
|
)
|
|
revision, loaded = load_structures(project_root)
|
|
assert revision == 3
|
|
assert len(loaded) == 1
|
|
assert loaded[0].structure_id == "abc"
|
|
assert not hasattr(loaded[0], "side")
|
|
assert not hasattr(loaded[0], "offset_m")
|
|
|
|
|
|
def test_legacy_keys_disappear_from_file_after_next_save(project_root):
|
|
_write_raw(
|
|
project_root,
|
|
[
|
|
{
|
|
"type_id": "erosion_check",
|
|
"placement": "point",
|
|
"chainage_m": 100.0,
|
|
"side": "left",
|
|
"offset_m": 2.5,
|
|
"options": {"form": "돌"},
|
|
}
|
|
],
|
|
)
|
|
_, loaded = load_structures(project_root)
|
|
save_structures(project_root, loaded, base_revision=3)
|
|
with open(structures_file_path(project_root), encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
assert "side" not in payload["structures"][0]
|
|
assert "offset_m" not in payload["structures"][0]
|
|
|
|
|
|
def test_other_unknown_keys_are_still_rejected(project_root):
|
|
# 두 키만 떨어낸다 — 오타 키까지 통과시키면 정본이 조용히 썩는다.
|
|
_write_raw(
|
|
project_root,
|
|
[
|
|
{
|
|
"type_id": "erosion_check",
|
|
"placement": "point",
|
|
"chainage_m": 100.0,
|
|
"sidee": "left",
|
|
"options": {"form": "돌"},
|
|
}
|
|
],
|
|
)
|
|
assert load_structures(project_root) == (0, [])
|
|
|
|
|
|
# ── 크로스체크 지적 2: 서버 검증 강화 ──────────────────────────────────────
|
|
|
|
|
|
def test_placement_mismatching_registry_is_rejected(project_root):
|
|
"""옹벽(레지스트리 interval)을 point로 보내면 거절해야 한다."""
|
|
wall_as_point = _point(50.0, type_id="retaining_wall", options={"height_m": 2.0})
|
|
with pytest.raises(ValueError, match="배치형태"):
|
|
save_structures(project_root, [wall_as_point], base_revision=0)
|
|
|
|
|
|
def test_undefined_option_key_is_rejected(project_root):
|
|
bad = _point(options={"form": "돌", "no_such_option": 1})
|
|
with pytest.raises(ValueError, match="옵션"):
|
|
save_structures(project_root, [bad], base_revision=0)
|
|
|
|
|
|
def test_negative_number_option_is_rejected(project_root):
|
|
bad = _point(options={"form": "돌", "height_m": -999})
|
|
with pytest.raises(ValueError, match="0 이상"):
|
|
save_structures(project_root, [bad], base_revision=0)
|
|
|
|
|
|
def test_non_numeric_number_option_is_rejected(project_root):
|
|
bad = _point(options={"form": "돌", "height_m": "높음"})
|
|
with pytest.raises(ValueError, match="숫자"):
|
|
save_structures(project_root, [bad], base_revision=0)
|
|
|
|
|
|
def test_select_option_outside_choices_is_rejected(project_root):
|
|
bad = _interval(options={"form": "없는형식"})
|
|
with pytest.raises(ValueError, match="선택지"):
|
|
save_structures(project_root, [bad], base_revision=0)
|
|
|
|
|
|
def test_detail_required_option_missing_is_accepted_at_b05(project_root):
|
|
"""옹벽 형식은 phase=detail — B05는 유무·위치 단계라 비워도 저장된다.
|
|
|
|
필수 원칙(미확정 기본값 = 필수)은 유지되되 강제 시점이 B06/B07로 옮겨졌다
|
|
(2026-08-17 사용자 확정). 길이·높이는 2026-08-19 지시로 B05 필수가 됐으므로
|
|
채워서 보낸다 — 비운 것은 detail(형식)뿐이다.
|
|
"""
|
|
wall = _interval(type_id="retaining_wall", options={"length_m": 30.0, "height_m": 2.5})
|
|
assert save_structures(project_root, [wall], base_revision=0) == 1
|
|
|
|
|
|
def test_b05_phase_required_option_is_still_enforced(project_root):
|
|
"""phase=b05인 필수 옵션이 생기면 여전히 강제된다 — 완화는 detail에 한한다."""
|
|
from B05_Profile import B05_Profile_Structures_Repository as repo_module
|
|
from B05_Profile.B05_Profile_Structures_Schema import (
|
|
StructureOptionField,
|
|
StructureType,
|
|
)
|
|
|
|
fake = StructureType(
|
|
type_id="fake_b05_required",
|
|
group="Z",
|
|
name="테스트",
|
|
placement="point",
|
|
options=[
|
|
StructureOptionField(
|
|
key="must", label="필수", input="number", required=True, phase="b05"
|
|
)
|
|
],
|
|
)
|
|
original = repo_module.structure_type_map
|
|
|
|
def patched():
|
|
mapping = dict(original())
|
|
mapping[fake.type_id] = fake
|
|
return mapping
|
|
|
|
repo_module.structure_type_map = patched
|
|
try:
|
|
bad = _point(type_id="fake_b05_required", options={})
|
|
with pytest.raises(ValueError, match="필수"):
|
|
save_structures(project_root, [bad], base_revision=0)
|
|
finally:
|
|
repo_module.structure_type_map = original
|
|
|
|
|
|
def test_required_option_present_is_accepted(project_root):
|
|
wall = _interval(
|
|
type_id="retaining_wall",
|
|
options={"form": "반중력식", "length_m": 30.0, "height_m": 2.5},
|
|
)
|
|
assert save_structures(project_root, [wall], base_revision=0) == 1
|
|
|
|
|
|
def test_duplicate_structure_ids_are_rejected(project_root):
|
|
first = _point()
|
|
second = _point(200.0)
|
|
first.structure_id = second.structure_id = "dup"
|
|
with pytest.raises(ValueError, match="중복"):
|
|
save_structures(project_root, [first, second], base_revision=0)
|
|
|
|
|
|
def test_position_beyond_route_length_is_rejected(project_root):
|
|
with pytest.raises(ValueError, match="연장"):
|
|
save_structures(project_root, [_point(999.0)], base_revision=0, max_chainage_m=500.0)
|
|
with pytest.raises(ValueError, match="연장"):
|
|
save_structures(
|
|
project_root, [_interval(490.0, 600.0)], base_revision=0, max_chainage_m=500.0
|
|
)
|
|
|
|
|
|
def test_position_within_route_length_is_accepted(project_root):
|
|
assert (
|
|
save_structures(project_root, [_point(499.0)], base_revision=0, max_chainage_m=500.0) == 1
|
|
)
|
|
|
|
|
|
def test_no_route_length_skips_range_check(project_root):
|
|
"""노선 연장을 모르면(None) 범위 검증은 건너뛴다 — 저장 자체는 허용."""
|
|
assert save_structures(project_root, [_point(9999.0)], base_revision=0) == 1
|