- 공용 구조물 패널 종류 바꿈: 관 지점 ↔ 구조물이면 새로 놓기 폼으로 빠지던 것 → 옛 것 지움 → 우클릭 추가 길(addAt)로 같은 측점 · 옛 측점 조정값 버림(318-4) - 횡단 초안 맞춤: 일반 측점 자리(grid)는 종류가 바뀌어도 저장분(옛 관 그림)이 남던 것 → 구조물만 뺀 사본(지형 쪽 고친 값 그대로) + 새 종류 - 시험: test_319_kind_swap 8 · test_318 중심고 겹침 기대값 바꿈 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
296 lines
12 KiB
Python
296 lines
12 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__tick b06-chart__center-text",
|
|
]
|
|
assert center["tick_count"] > 3
|
|
assert center["hidden_when_out_of_view"]
|
|
assert center["absent_without_center"]
|
|
|
|
|
|
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 # 토사도 띠는 남음(빈 띠)
|
|
|
|
|
|
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"
|
|
)
|
|
|
|
|
|
# ── 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(--color-accent)" in box # 테마 색
|
|
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}
|