Files
Aislo/resources/tester/test_322_0_golden.py
T
eomsangdonandClaude Opus 5.5 3abd2bdfd4 feat(구조물표): 322-2 M02 표 화면 — 탭 셋(한눈 표 · 종류별 · 부품) · 반영 · 부품 · 알약 색 저장 · 목록 + enabled 함께 · 검사 짝(화면 = 서버 · 1-3 규칙 · 만든 뒤 고정) · 명세 폼 보임 따름 · 판 알림
- 한눈 표(M02_SO_Def_Table): 군 · 종류 ▲▼ · 이름 · 목록 · 자리꼴 · 정본(고정) · 횡단도 · 카드 이름 · 형상(+ 벽 기본 형태 · 물빼기) · 알약 색 · 3D · 검사 · 처음 값 = 흐림 「기본」 · 바꿈 = 진함 · [처음 값으로]
- 종류별: 반영 칸 + 명세 이름 나무 줄마다 보임(컨테이너 = 그 안 칸) · 못 숨김 · 미리보기 숨김 따름 · 「안 씀」 = 목록 B06 만
- 부품 탭(M02_SO_Def_Parts): 부품 · 읽는 칸 · 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 부품 = 끈 것만)
- 편집 손질(M02_SO_Def_Reflect): 처음 값이면 반영에서 지움 · [저장] 때 enabled = 목록 both 일 때만 참
- 검사 짝 M02_SO_Def_Check.ts ↔ M02_SO_Check.py(글까지 같음): 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 기본값 없는 읽는 칸 새로 숨김 금지(앞 판 숨김 그대로) · 종류 지우기 · 자리꼴 · 정본 · 군 글자 · 선택지 값 고정 · 서버 check(doc, 지금 판) · 앞 판 없으면 꼴만
- 명세 폼: 쓰는 옵션 키가 모두 숨긴 칸 · 상자는 안 그림(값 그대로) · B05 · B06 시설 · 구조물 명세 폼이 정의 「표시」를 따름
- 판 알림: GET 구조물 목록에 definition_version · structures.json 판 ≠ 지금 판이면 「구조물 정의가 판 N 으로 바뀜 — 저장하면 반영」 한 번
- 시험: test_322_2_check_mirror(거울 · 규칙 · 반영 손질 · 서버 저장 · 422) · 판 알림 · 옛 정의 화면 시험 = 선택지 값 대신 이름 표 · 못 숨김 아닌 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 15:40:38 +09:00

262 lines
10 KiB
Python

"""PLAN 322-0 전수 표 시험 — 구조물 42종 × 견본마다 일곱 갈래 결과를 기록(`golden_322_structures.json`)과 맞춤.
갈래 = 심는 측점(TS `structureStationMarks` · `pipeStationMarks` · 파이썬 `resolve_extra_stations`) · 카드 이름
(파이썬 측점 이름 · TS 미리 뜬 횡단 이름 · 표시 이름) · 형상 얹기 열쇠(파이썬 attach 차례 · TS `Section_Store` 초안 얹기)
· 3D 몸체 · 알약 띠 · 고르기 목록(B05 · B06 `pickGroups`) · 옹벽 물빼기.
견본 = 종류마다 기본값 한 벌(main) · 빈칸(blank) · 배관은 흄관(hume) 하나 더 — 점형 · 부지형 = 101 m · 구간형 = 93 ~ 113 m
(기준 101) · 관 지점 = 101 m(기슭막이 96 ~ 106 m) · 격자 = 0 ~ 200 m 20 m 마다.
기록 다시 쓰기 = `GOLDEN_322_RECORD=1` · ★ = 계획(PLAN_구조물표 4장)대로 바뀔 칸 — 기록에 「지금 → 다음」을 적고
`STAGE` 가 그 단계를 넘으면 다음 값과 맞춤. TS 쪽 = `helper_322_0_golden.cjs`(Node).
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
import tempfile
from pathlib import Path
import pytest
from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
from B05_Profile.B05_Profile_Structures_Schema import load_definition
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets, pipe_points_file
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
from B06_Section.B06_Section_Engine_Tilt import attach_underdrains
from common_util.common_util_drainage_pipes import parse_pipe_points
from common_util.common_util_structure_pavement import attach_pavement_structures
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
GOLDEN = HERE / "golden_322_structures.json"
FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json"
#: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤
STAGE = 1
GRID = [float(at) for at in range(0, 201, 20)]
SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain")
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
def _plain_section() -> dict:
sections = json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"]
plain = copy.deepcopy(next(s for s in sections if s["chainage_m"] == 180))
for key in (*SHAPE_KEYS, "structure"):
plain.pop(key, None)
return plain
def _samples(definition: dict) -> list[dict]:
out: list[dict] = []
for item in definition["types"]:
defaults = {
option["key"]: option["default"]
for option in item.get("options", [])
if option.get("default") is not None
}
cases = {"main": defaults, "blank": {}}
if item["type_id"] == "pipe":
cases["hume"] = {**defaults, "pipe_kind": "흄관", "pipe_diameter_mm": "800"}
for case, options in cases.items():
pipe = structure = None
if item.get("managed_by"):
pipe = {"chainage_m": 101.0, "source": "user", "facility": item["type_id"]}
if item["type_id"] == "revetment":
pipe.update(start_m=96.0, end_m=106.0)
if options:
pipe["options"] = options
else:
structure = {
"structure_id": "s1",
"type_id": item["type_id"],
"placement": item["placement"],
"chainage_m": 101.0,
"options": options,
"memo": "",
"placement_source": "manual",
"status": "draft",
"revision": 0,
"geometry": None,
}
if item["placement"] == "interval":
structure.update(start_m=93.0, end_m=113.0)
out.append(
{"type_id": item["type_id"], "case": case, "pipe": pipe, "structure": structure}
)
return out
def _shape_of(section: dict) -> dict:
"""TS 짝 `shapeOf` 와 같은 꼴."""
out: dict = {}
for key in SHAPE_KEYS:
spec = section.get(key)
if not spec:
continue
if key == "culvert":
out[key] = {
"hidden_pipe": bool(spec.get("hidden_pipe")),
"body": bool(spec.get("body")),
}
elif key == "revetment":
out[key] = {"type_id": spec.get("type_id"), "form": spec.get("form")}
elif key == "pavement":
out[key] = sorted(p["type_id"] for p in spec)
else:
out[key] = True
return out
def _python_row(sample: dict, root: Path, plain: dict) -> dict:
"""서버 길 — 정본 파일을 쓰고 측점 만들기 · 횡단 얹기 차례(`_read_section_detail`)를 그대로."""
if sample["structure"]:
path = Path(structures_file_path(str(root)))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"revision": 1, "structures": [sample["structure"]]}), encoding="utf-8"
)
pipes = parse_pipe_points([sample["pipe"]]) if sample["pipe"] else []
if sample["pipe"]:
path = pipe_points_file(root)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"points": [sample["pipe"]]}), encoding="utf-8")
extras = resolve_extra_stations(root, pipes)
sections = [{**copy.deepcopy(plain), "chainage_m": at, "kind": "regular"} for at in GRID]
for at, label in extras:
hit = next((s for s in sections if abs(s["chainage_m"] - at) < 0.005), None)
if hit is None:
hit = {**copy.deepcopy(plain), "chainage_m": at, "kind": "irregular"}
sections.append(hit)
hit["structure"] = label
sections.sort(key=lambda s: s["chainage_m"])
attach_culvert_sets(root, sections)
relabel_pipe_stations(root, sections)
attach_wall_structures(root, sections)
attach_pavement_structures(root, sections)
attach_underdrains(root, sections)
return {
"station_py": [f"{label}@{at:.2f}" for at, label in extras],
"cards_py": {
f"{s['chainage_m']:.2f}": {"title": s.get("structure"), "shape": _shape_of(s)}
for s in sections
if s.get("structure") or _shape_of(s)
},
}
@pytest.fixture(scope="module")
def result() -> dict:
with tempfile.TemporaryDirectory() as folder:
return _run(Path(folder))
def _run(work: Path) -> dict:
definition = load_definition()
samples = _samples(definition)
plain = _plain_section()
payload = {
"types": with_visible().model_dump()["types"],
"definition": definition,
"samples": samples,
"grid": GRID,
"plain_section": plain,
}
(work / "input.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
out = subprocess.run(
["node", str(HERE / "helper_322_0_golden.cjs"), str(work / "input.json")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
check=True,
)
ts = json.loads(out.stdout)
rows: dict = {}
for index, sample in enumerate(samples):
key = f"{sample['type_id']}/{sample['case']}"
rows[key] = {**ts["samples"][key], **_python_row(sample, work / f"p{index}", plain)}
return {"samples": rows, "pick": ts["pick"], "version": ts["version"], "notice": ts["notice"]}
def test_type_cache_follows_definition_version(result: dict) -> None:
"""322-0 ③ — 화면 종류 캐시: 같은 판이면 그대로 · M02 저장으로 판이 바뀌면 탭이 다시 보일 때 버리고 다시 받음."""
assert result["version"] == {
"same": False,
"sameCalls": 0,
"reloads": 1,
"staleCalls": 1,
"removed": True,
}
def test_stale_definition_notice_once(result: dict) -> None:
"""322-2 판 알림 — structures.json 판 ≠ 지금 판이면 한 줄 · 탭 수명 동안 한 번 · 같은 판 · 판 없음은 없음."""
now = load_definition()["판"] + 1 # 시험 서버가 판을 하나 올린 뒤
assert result["notice"] == [
f"구조물 정의가 판 {now} 으로 바뀜 — 저장하면 반영",
None,
None,
None,
]
def _cells(table: dict) -> dict[tuple[str, str, str], object]:
return {
(part, key, column): value
for part in ("samples", "pick")
for key, row in table[part].items()
for column, value in row.items()
}
def test_golden_matches(result: dict) -> None:
if os.environ.get("GOLDEN_322_RECORD") == "1":
old = json.loads(GOLDEN.read_text(encoding="utf-8")) if GOLDEN.exists() else {}
GOLDEN.write_text(
json.dumps(
{
"★ 메모": old.get("★ 메모", []),
"★": old.get("★", []),
"samples": result["samples"],
"pick": result["pick"],
},
ensure_ascii=False,
indent=1,
)
+ "\n",
encoding="utf-8",
)
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
star = {(s["part"], s["key"], s["column"]): s for s in golden["★"]}
want = _cells(golden)
got = _cells(result)
assert set(got) == set(want), "견본 · 칸 목록이 기록과 다름"
wrong = []
for cell, value in want.items():
mark = star.get(cell)
expect = mark["다음"] if mark and STAGE >= mark["단계"] else value
if got[cell] != expect:
wrong.append(f"{cell}: 기록 {expect!r} · 지금 {got[cell]!r}")
assert not wrong, "\n".join(wrong[:40])
def test_golden_covers_every_type(result: dict) -> None:
types = {item["type_id"] for item in load_definition()["types"]}
assert len(types) == 42
assert {key.split("/")[0] for key in result["samples"]} == types
assert set(result["pick"]) == types
def test_star_cells_exist() -> None:
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
cells = _cells(golden)
for mark in golden["★"]:
assert (mark["part"], mark["key"], mark["column"]) in cells, mark