Files
Aislo/resources/tester/test_318_cross_card.py
T
eomsangdonandClaude Opus 5.5 c9b4f0bede fix(B06 십자선): 십자선 = 형상과 한 높이(측점 설계 높이) · 원지반 십자선 · 평면 초안 계획선 표본도 초안 종단 (413-1)
- 카드 · B05 오른쪽 판 · [상세] 창 십자선 · 계획 상자 = section.design.design_elevation_m · 설계 없을 때만 종단 표본
- 원지반 십자선(center_z · 원지반 색 · 다른 무리) 먼저 그림 · 겹치면 보라 위 · 확대 보정 둘 다
- [상세] 표 원지반고 = 원지반 십자 · 절성토고 = 둘 다
- 시험 318 · 150 · 164 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-18 12:19:39 +09:00

365 lines
16 KiB
Python

"""PLAN 318 — B06 횡단 카드(요구 215 · 216).
- 318-1 단추 줄: 빈자리 누름 = 카드 고름 · 단추 · ⋯ 펼침 누름 = 카드로 안 넘김
- 318-2 중심고: 아래 띠에서 빼고 Y축 라벨 둥근 상자(눈금과 같은 글자 · 소수 첫째 자리 ·
축선 안 침범) · 암 경계 = 띠 왼쪽
- 318-3 아래 띠 아래 여유: 카드 = 내용 높이(같은 줄 큰 카드에 안 늘어남) · 빈 띠도 높이 그대로
- 318-4 관 종류 바꿈: 그 측점 옛 조정값(세션 · 미리 뜬 횡단 사본)을 버리고
[저장] 때 정본에서도 비움
화면 TS 는 `helper_318_cross_card.cjs` 가 Node 로 돌린다(무거운 화면 사슬 · 서버 부름은 흉내).
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
import pytest
from B06_Section import B06_Section_Router_Confirm as confirm
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Design import STRUCTURE_ADJUST_KEYS, USER_TOUCHED_KEYS
from B06_Section.B06_Section_Schema import SectionConfirmRequest
HERE = os.path.dirname(__file__)
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
NODE_READY = os.path.isdir(os.path.join(ROOT, "config", "node_modules"))
def _src(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
@pytest.fixture(scope="module")
def result() -> dict:
if not NODE_READY:
pytest.skip("프론트엔드 의존성 미설치")
out = subprocess.run(
["node", os.path.join(HERE, "helper_318_cross_card.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
cwd=ROOT,
timeout=120,
)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
# ── 318-1 단추 줄 ─────────────────────────────────────────────────────────
def test_bar_empty_area_picks_card(result: dict) -> None:
bar = result["bar"]
assert bar["empty"] == 1 # 단추 줄 빈자리
assert bar["legend"] == 1 # 단추 묶음 바탕(단추 아닌 자리)
def test_bar_controls_do_not_pick_card(result: dict) -> None:
assert result["bar"]["button"] == 0
assert result["bar"]["summary"] == 0 # ⋯ 펼침
# ── 318-2 중심고 Y축 상자 ──────────────────────────────────────────────────
def test_center_tag_on_y_axis(result: dict) -> None:
center = result["center"]
box = center["box"]
assert center["text"] == "876.6" # 소수 첫째 자리
assert "b06-chart__tick" in center["text_classes"] # 다른 Y 라벨과 같은 글자 크기
assert box["rx"] > 0 # 둥근 상자
assert box["x"] + box["width"] < center["axis_x"] # Y 축선 안 침범
assert box["x"] > 0
assert abs(box["y"] + box["height"] / 2 - center["center_y"]) < 0.01 # 중심고 높이에
assert center["text_x"] == center["tick_x"] # 글 오른끝 = 다른 눈금 자리
assert center["tick_near_center"] >= 1 # 겹친 눈금 글도 그대로(319-1)
assert center["on_top"] == [ # 상자 · 글을 맨 뒤에 붙여 위에서 가림(319-1)
"b06-chart__center-box b06-chart__center-tag--ground",
"b06-chart__tick b06-chart__center-text b06-chart__center-tag--ground",
]
assert center["tick_count"] > 3
assert center["hidden_when_out_of_view"]
assert center["absent_without_center"]
def test_two_tags_ground_dodges(result: dict) -> None:
"""379-3 — 원지반 상자 먼저 · 계획 상자 나중(위) · 16 px 안이면 원지반만 비킴 · 값 그대로."""
tags = result["tags"]
apart = tags["apart"]
assert abs(apart["ground"]["mid"] - apart["groundY"]) < 0.01 # 멀면 제자리
assert abs(apart["design"]["mid"] - apart["designY"]) < 0.01
assert [c.split()[-1] for c in apart["order"]] == [
"b06-chart__center-tag--ground",
"b06-chart__center-tag--ground",
"b06-chart__center-tag--design",
"b06-chart__center-tag--design",
]
low = tags["groundLow"] # 원지반이 낮음 → 아래로
assert abs(low["design"]["mid"] - low["designY"]) < 0.01
assert abs(low["ground"]["mid"] - (low["designY"] + 16)) < 0.01
assert low["ground"]["text"] == "876.6" and low["design"]["text"] == "876.9"
high = tags["groundHigh"] # 원지반이 높음 → 위로
assert abs(high["ground"]["mid"] - (high["designY"] - 16)) < 0.01
assert high["ground"]["text"] == "876.9" and high["design"]["text"] == "876.6"
edge = tags["edge"] # 아래가 칸 밖 → 반대(위)
assert edge["groundY"] > edge["designY"]
assert abs(edge["ground"]["mid"] - (edge["designY"] - 16)) < 0.01
only = tags["designOnly"]
assert only.get("ground") is None and abs(only["design"]["mid"] - only["designY"]) < 0.01
def test_footer_without_center_text(result: dict) -> None:
footer = result["footer"]
assert "중심고" not in footer["rock_text"]
assert "876" not in footer["rock_text"]
assert footer["rock_first"].split()[-1] == "b06-cross-card__rockb" # 암 경계 = 왼쪽 첫 칸
assert footer["rock_classes"][-1].split()[-1] == "b06-cross-card__cutslope"
assert footer["soil_exists"] and footer["soil_children"] == 0 # 토사도 띠는 남음(빈 띠)
# 370-3 — 선 공용 칸 = 왼쪽 첫 칸(암 꼴 클래스) · 암 절토 경사 = 오른쪽 그대로
assert footer["cell_classes"] == [
"linecell b06-cross-card__rockb",
"cutslope b06-cross-card__cutslope",
]
def test_center_wired_into_card_and_modal() -> None:
assert "centerZ: section.center_z ?? null" in _src(
"B06_Section", "B06_Section_UI_Cross_View.ts"
)
assert "centerZ: section.center_z ?? null" in _src(
"B06_Section", "B06_Section_UI_Section_View.ts"
)
# 379-3 계획 상자 = 십자선 계획고(카드 designZ · 모달 cardDesignElevation)
# 413 — 십자선 · 계획 상자 = 그 측점 설계 높이(형상과 한 값) · 설계 없을 때만 종단 표본
card = _src("B06_Section", "B06_Section_UI_Cross_View.ts")
assert "const designZ = section.design?.design_elevation_m ?? designElevation;" in card
assert "designZ: hasDesign ? (designZ as number) : null" in card
assert "designElevation: hasDesign ? (designZ as number) : null" in card
assert "groundElevation: section.center_z ?? null" in card
view = _src("B06_Section", "B06_Section_UI_Section_View.ts")
assert "designZ: cardDesignElevation(detail, section) ?? null" in view
assert (
"if (section.design?.design_elevation_m !== undefined) return section.design.design_elevation_m;"
in view
)
def test_two_crosshairs(result: dict) -> None:
"""413 — 원지반 십자(원지반 색 · 다른 무리) 먼저 · 계획 십자 나중(위) · 높이 = 설계 · 원지반."""
cross = result["crosshair"]
assert cross["order"] == ["b06-chart__ground-center", "b06-chart__center"]
assert abs(cross["design_y"] - cross["want_design_y"]) < 1e-6 # 설계 높이(종단 표본 아님)
assert abs(cross["ground_y"] - cross["want_ground_y"]) < 1e-6 # center_z
assert cross["design_only_ground"] is None # 설계 없음 = 십자 하나(지반 폴백)
assert cross["scaled"] == cross["want_scaled"] # 둘 다 자기 중심에서 배율 되돌림
areas = _src("B06_Section", "B06_Section_UI_Style_Cross_Areas.css")
assert "var(--color-chart-1)" in _rule(areas, ".b06-chart__ground-center-marker")
def test_draft_detail_profile_is_draft(result: dict) -> None:
"""413 — 평면 초안 상세의 계획선 표본 = 초안 종단(저장 종단 아님) · 이름은 저장분 그대로."""
draft = result["draftProfile"]
assert draft["samples"] == [
{"chainage_m": 0, "elevation_m": 110},
{"chainage_m": 20, "elevation_m": 112},
]
assert draft["id"] == "old"
# ── 318-2 · 318-3 양식 ──────────────────────────────────────────────────────
def _rule(css: str, selector: str) -> str:
match = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert match, selector
return match.group(1)
def test_css_layout() -> None:
css = _src("B06_Section", "B06_Section_UI_Style_Cross.css")
assert "align-items: start" in _rule(css, ".b06-section__grid") # 카드 = 내용 높이
rock = _rule(css, ".b06-cross-card__rockb")
assert "position: absolute" not in rock and "translateX" not in rock # 왼쪽 맞춤
assert 'content: "\\200b"' in _rule(css, ".b06-cross-card > footer:empty::before")
box = _rule(css, ".b06-chart__center-box")
assert "var(--b06-tag)" in box # 색 = 갈래 변수(379-3)
areas = _src("B06_Section", "B06_Section_UI_Style_Cross_Areas.css")
assert "var(--color-chart-1)" in _rule(areas, ".b06-chart__center-tag--ground") # 원지반선
assert "var(--color-royal-amethyst)" in _rule(areas, ".b06-chart__center-tag--design") # 설계선
assert "font-size" not in _rule(css, ".b06-chart__center-text") # 글자 크기 = 눈금 그대로
# ── 318-4 관 종류 바꿈 ─────────────────────────────────────────────────────
def test_forget_session_adjusts_at_station(result: dict) -> None:
forget = result["forget"]
assert forget["culvertopt"] == {"140.00": {"a": 1}}
assert forget["culvertopt_other_route"] == {} # 노선 모두
assert forget["culvertopt_other_project"] == {"100.00": {"a": 1}} # 다른 프로젝트 그대로
assert forget["revetx"] == {"140.00:inlet": {"x": 3}}
assert forget["extrawall"] == {"140.00": 1} # `b` 머리(집수정 단)까지
assert forget["extraspan"] == {}
assert forget["revetlink"] == {"detached": {"140.00": True}, "grade": {}}
assert forget["fordadjust"] == {} and forget["boxadjust"] == {} # 100.30 = 같은 관(0.51 안)
assert forget["basinadjust"] == {} and forget["inletstruct"] == {}
assert forget["crossw"] == {"100.00": 5} # 구조물 아닌 값(표시 반폭)은 그대로
assert forget["reloads"] == 1 # 횡단 보기 메모리 사본 다시 읽음
assert forget["preview_calls"][0] == ["P", 100, list(STRUCTURE_ADJUST_KEYS)]
def test_kind_reset_marks(result: dict) -> None:
forget = result["forget"]
assert forget["kindreset"] == {"100.00": "pipe"}
assert forget["changed"] == [100]
assert forget["second"] == {"100.00": "pipe"} # 처음 종류를 기억
assert forget["reverted"] is None # 처음 종류로 되돌림 = 저장분 그대로
assert forget["same_kind_untouched"]
def test_preview_copy_stripped(result: dict) -> None:
assert result["preview"]["had"] == ["ford_adjust", "ground_type", "revet_adjust"]
assert result["preview"]["after"] == ["ground_type"]
def test_b05_save_path_patches(result: dict) -> None:
assert result["cache_patches"] == [
{"chainage_m": 100, "structure_reset": True},
{"chainage_m": 100, "display_half_width_m": 6}, # 옛 ford_adjust 안 실음
{"chainage_m": 140, "ford_adjust": {"w": 2}},
]
assert result["cache_patches_plain"] == [{"chainage_m": 140, "ford_adjust": {"w": 2}}]
def test_wiring_sources() -> None:
events = _src("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts")
live = events.index("ctx.liveCommit();\n // 관 지점 종류 바꿈")
assert "forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id" in events[live:]
persist = _src("B06_Section", "B06_Section_UI_Page_Persist.ts")
assert "patchFor(at).structure_reset = true" in persist
assert 'clearState("kindreset", ctx.projectId)' in persist
store = _src("B06_Section", "B06_Section_Section_Store.ts")
assert "crossPatchesFromCache(detail, kindChangedChainages(projectId))" in store
view = _src("A00_Common", "design", "design_view_cross.ts")
assert "setStationAdjustReload(() => stationControls.load())" in view
def test_structure_keys_mirror() -> None:
"""서버 · 브라우저 열쇠 목록이 같음 · 모두 승계 목록 안."""
ts = _src("B06_Section", "B06_Section_Api_Culvert_Options.ts")
block = ts[ts.index("export const STRUCTURE_ADJUST_KEYS = [") :]
block = block[: block.index("] as const")]
assert re.findall(r'"([a-z_]+)"', block) == list(STRUCTURE_ADJUST_KEYS)
assert set(STRUCTURE_ADJUST_KEYS) <= set(USER_TOUCHED_KEYS)
assert "structure_note" not in STRUCTURE_ADJUST_KEYS # 사용자 글 · 그림은 안 버림
# ── 318-4 서버 — 비움이 먼저 · 같은 행은 이어 얹음 · None = 지움 ───────────────
class _Cursor:
def __init__(self, rows):
self.rows = rows
self.calls = []
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def execute(self, sql, params=None):
self.calls.append((" ".join(sql.split()), list(params) if params else []))
async def fetchall(self):
return self.rows
class _Connection:
def __init__(self, rows):
self.cursor_obj = _Cursor(rows)
def cursor(self):
return self.cursor_obj
def _written(connection: _Connection) -> dict:
for sql, params in connection.cursor_obj.calls:
if sql.startswith("UPDATE cross_sections SET data = CASE"):
count = len(params) // 3
return {params[i * 2]: json.loads(params[i * 2 + 1]) for i in range(count)}
return {}
def test_merge_none_deletes_and_same_row_accumulates() -> None:
stored = {
"design": {
"ground_type": "soil",
"ford_adjust": {"w": 1},
"revet_adjust": {"inlet": {"x": 1}},
"display_half_width_m": 6,
}
}
connection = _Connection([(7, 100.0, json.dumps(stored))])
asyncio.run(
merge_cross_section_designs(
connection,
route_id=1,
entries=[
(100.0, dict.fromkeys(STRUCTURE_ADJUST_KEYS)),
(100.0, {"box_adjust": {"w": 2}}),
],
replace=False,
)
)
written = _written(connection)
assert list(written) == [7] # 한 행 한 벌
assert written[7]["design"] == {
"ground_type": "soil",
"display_half_width_m": 6,
"box_adjust": {"w": 2},
}
def test_merge_none_only_entry_for_missing_row_inserts_nothing() -> None:
connection = _Connection([])
asyncio.run(
merge_cross_section_designs(
connection,
route_id=1,
entries=[(50.0, dict.fromkeys(STRUCTURE_ADJUST_KEYS))],
replace=False,
project_id="00000000-0000-0000-0000-000000000001",
)
)
assert not any("INSERT" in sql for sql, _ in connection.cursor_obj.calls)
def test_apply_edits_resets_before_patches(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[list, bool]] = []
async def fake_merge(connection, *, route_id, entries, replace, project_id=None):
calls.append((list(entries), replace))
return len(entries)
monkeypatch.setattr(confirm, "merge_cross_section_designs", fake_merge)
request = SectionConfirmRequest(
cross_patches=[
{"chainage_m": 100, "structure_reset": True, "box_adjust": {"left": {"lengthM": 1}}},
{"chainage_m": 140, "display_half_width_m": 6},
]
)
asyncio.run(confirm._apply_section_edits(None, 1, request, []))
entries = [entries for entries, replace in calls if not replace][-1]
assert entries[0] == (100, dict.fromkeys(STRUCTURE_ADJUST_KEYS)) # 비움이 먼저
rest = dict(entries[1:])
assert "structure_reset" not in rest[100] # design 에 안 씀
assert rest[100]["box_adjust"]["left"]["lengthM"] == 1 # 새 값은 뒤에 얹힘
assert rest[140] == {"display_half_width_m": 6}