"""[저장]이 여는 창구 넷이 **비어 있으면 안 나가는지**. 왜 (2026-09-07 조사) — [저장]은 창구 다섯을 순서대로 연다(관 옵션·상단측·관 목록·구조물· `sections/save`). 넷은 초안이 비면 **요청 없이 즉시 돌아와야** 한다. 그래야 보통 저장이 1~2건으로 끝나고, 하나가 실패해도 나머지가 나간다(2026-08-29 사고 뒤 일부러 만든 구조). 이 성질이 깨지면 저장마다 빈 요청이 붙고, 「초안을 한 덩어리로 합치자」는 명분이 되살아난다. 합치지 않기로 한 근거(0-6)가 이 성질에 기대고 있으므로 그물을 남긴다. 코드를 읽어 지키는 시험이다 — 화면 왕복 없이 무너짐을 잡는 것이 목적이다. """ import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] #: (파일, 함수 이름, 비었을 때 돌아가는 표시) CHANNELS = [ ( ROOT / "B05_Profile" / "B05_Profile_Api_Pipes_Draft.ts", "flushPendingPipes", "관 목록", ), ( ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts", "flushUphillOverrides", "상단측(측구 방향)", ), ( ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts", "flushPendingStructures", "구조물", ), ( ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts", "flushCulvertOptions", "관 옵션", ), ] #: 비었을 때 빠져나가는 모양 — `return;` 만 있는 이른 반환. _EARLY_RETURN = re.compile(r"\breturn\s*;") def _body(path: Path, name: str) -> str: """함수 머리부터 다음 최상위 선언 전까지 — 이른 반환이 있는지만 보면 되므로 넉넉히 자른다.""" text = path.read_text(encoding="utf-8") match = re.search(rf"(?:async\s+)?(?:export\s+)?(?:async\s+)?function\s+{name}\s*\(", text) if match is None: match = re.search(rf"\b{name}\s*\([^)]*\)\s*(?::[^{{]+)?\{{", text) assert match is not None, f"{path.name} 에서 {name} 을 못 찾음" return text[match.start() : match.start() + 900] def test_창구_넷은_초안이_비면_요청을_안_낸다(): problems = [] for path, name, label in CHANNELS: body = _body(path, name) if not _EARLY_RETURN.search(body): problems.append(f"{label}({path.name}:{name}) — 빈 초안에서 빠져나가는 자리가 없음") assert not problems, "\n".join(problems) def test_저장이_창구를_다섯_다_거친다(): """순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다. 차례는 저장 한 줄기(`A00_Common/design/design_save.ts`) 한 곳에 있다(계획서 24-2a) — B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다.""" save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8") for token in ( "flushCulvertOptions(", "flushUphillOverrides(", "flushPendingPipes(", "flushPendingStructures(", ): assert token in save, f"{token} 가 저장 한 줄기에서 사라짐" persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐" # 하나가 실패해도 나머지가 나가야 한다 — 관·구조물·계획선은 `catch` 로 감싸 둔다. assert save.count(".catch(") >= 3, "실패 격리(catch)가 줄었음" order = save[save.index("DESIGN_SAVE_ORDER = [") :] order = order[: order.index("]")] names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"] assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), ( "저장 차례가 바뀜 — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단" ) def test_두_페이지가_저장_한_줄기를_탄다(): """[저장] · [확정]은 한 페이지 한 벌이 두 보기 몫을 모아 저장 한 줄기 한 번으로 보낸다 (계획서 24-2a · 24-3b) — 보기 몫은 창구를 따로 부르지 않는다.""" shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8") actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8") persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert shared.count("await saveDesign(") == 1, "저장 한 줄기를 한 자리에서만 부름" assert "saveSharedDesign(shared, true)" in shared, "[확정]이 한 줄기를 안 탐" assert 'await shared.ensureView("cross")' in shared, "종단 보기 [확정]이 횡단 자료를 못 얻음" for name, text in (("종단 보기", actions), ("횡단 보기", persist), ("한 벌", shared)): for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("): assert token not in text, f"{name} 가 {token} 를 따로 부름 — 차례가 갈림" assert "export function profileSaveParts(" in actions assert "export function crossSaveParts(" in persist