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