Files
Aislo/resources/tester/test_b05_structures_repository.py
T
eomsangdonandClaude Opus 5 0ef32b5279 chore(tester): 시험을 resources/tester/ 로 옮김 — 창끼리 건너가게
⚠ **뿌리** — `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>
2026-09-09 17:12:30 +09:00

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