"""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