From 3736e601ee1ffecbbceee585a0398a0af145968f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 21:53:30 +0900 Subject: [PATCH] =?UTF-8?q?fix(514-4=20=EC=B6=94=EC=B2=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=20=C2=B7=20=EB=81=8C=EA=B8=B0=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98):=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=3D=20=ED=91=9C=EC=A4=80=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=A9=B4=20=E3=80=8C=EC=97=86=EC=9D=8C=E3=80=8D=EA=B3=BC=20con?= =?UTF-8?q?fig=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=84=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EB=9C=BB=EC=9C=BC=EB=A1=9C=20=EB=91=90=20=EA=BC=B4=20?= =?UTF-8?q?=EB=8B=A4=20=EA=B2=AC=EC=A4=8C(=EC=A7=80=EA=B8=88=20=EA=BC=B4?= =?UTF-8?q?=20=EB=A8=BC=EC=A0=80=20=C2=B7=20=EC=B6=94=EC=B2=9C=20=C2=B7=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=A2=85=EB=8B=A8=20=C2=B7=20standard=5Ff?= =?UTF-8?q?orms)=20=E2=80=94=20B05=20=C2=B7=20B06=20[=EC=A0=80=EC=9E=A5]?= =?UTF-8?q?=EC=9D=B4=20=EC=86=90=EB=8C=80=EC=A7=80=20=EC=95=8A=EC=9D=80=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=84=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=EC=97=90=20=EC=8B=A4=EC=96=B4=20=EC=A0=84?= =?UTF-8?q?=EC=B2=98=EB=A6=AC(None)=20=EC=A0=80=EC=9E=A5=EB=B3=B8=20?= =?UTF-8?q?=ED=82=A4=EA=B0=80=20=EC=96=B4=EA=B8=8B=EB=82=98=20=EB=8B=A4?= =?UTF-8?q?=EC=84=AF=20=EA=B0=88=EB=9E=98=20409=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=20=ED=91=9C=EC=A4=80=EC=9D=80=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20409=20=C2=B7=20Store=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C(=EC=97=94=EC=A7=84=20=EC=A7=80?= =?UTF-8?q?=EB=AC=B8=20=EC=95=88=20=EB=B0=94=EB=80=9C)=20=C2=B7=20test=5F5?= =?UTF-8?q?08=5F2=20=EB=81=8C=EA=B8=B0=20=3D=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EB=B3=B4=EB=8D=98=20=ED=99=94=EB=A9=B4(user=5Fui=5Fprefs)=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=A0=84=20=EB=B9=84=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EB=81=9D=EB=82=98=EB=A9=B4=20=EB=90=98=EB=8F=8C=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EB=91=98=EC=A7=B8=20=EB=8F=84=EC=97=BD=20=EC=98=AC=20?= =?UTF-8?q?=EB=95=8C=EA=B9=8C=EC=A7=80=20=EA=B8=B0=EB=8B=A4=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EC=83=88=20=EC=8B=9C=ED=97=98=20test=5F514=5F4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Router_RouteInitial.py | 30 +++++++--- ...B05_Profile_Router_RouteInitial_Initial.py | 12 ++-- resources/tester/test_508_2_browser.py | 57 ++++++++++++++++++- .../test_514_4_recommend_standard_default.py | 52 +++++++++++++++++ 4 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 resources/tester/test_514_4_recommend_standard_default.py diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 7e3c62b1c..136a0a038 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -157,15 +157,17 @@ async def compute_initial( forget_pool_on_standard(pool_key, inputs["standard"]) stored = await asyncio.to_thread(read_stored, project_root, key) if stored is None and not may_compute: - # 프로젝트 접근 — 엔진 코드 판이 달라도 같은 입력 저장본을 꺼냄(505-1 덧) - found = await asyncio.to_thread( - read_stored_any, - project_root, - pool_key, - lambda source: request_fingerprint( - route, terrain, streams, **inputs, source=source - )[0], - ) + # 프로젝트 접근 — 엔진 코드 판이 달라도 같은 입력 저장본을 꺼냄(505-1 덧 · 514-4) + found = None + for form in standard_forms(inputs["standard"]): + found = found or await asyncio.to_thread( + read_stored_any, + project_root, + pool_key, + lambda source, form=form: request_fingerprint( + route, terrain, streams, **{**inputs, "standard": form}, source=source + )[0], + ) if found is None: return JSONResponse( status_code=409, content={"status": "error", "message": NOT_PREPARED} @@ -249,6 +251,16 @@ async def project_standard(project_id: UUID) -> dict[str, Any] | None: return stored_standard_cross_section({"data": data if isinstance(data, dict) else None}) +def standard_forms(standard: dict[str, Any] | None) -> list[dict[str, Any] | None]: + """같은 뜻 표준 횡단면 꼴(지금 꼴 먼저) — 없음(None) = config 기본값 · 키 · 지문은 둘을 가름 · + [저장]은 손대지 않은 패널 기본값도 실어 보냄 → 프로젝트 접근은 두 꼴 저장본 다 꺼냄(514-4).""" + from config.config_system import STANDARD_CROSS_SECTION + + if standard is None: + return [None, STANDARD_CROSS_SECTION] + return [standard, None] if standard == STANDARD_CROSS_SECTION else [standard] + + async def precompute_defaults(project_id: UUID) -> None: """체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯 × 고를 수 있는 지형(509-5 · 설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만). diff --git a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py index 85309c903..aefeb5537 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py @@ -49,6 +49,7 @@ async def initial_profile_of( NOT_PREPARED, balanced_profile_of, project_standard, + standard_forms, ) from B06_Section.B06_Section_Repository import ( get_latest_section_options, @@ -113,10 +114,13 @@ async def initial_profile_of( "road": await replan._road_settings(project_id), } mark = ip.fingerprint(parts) - # 프로젝트 접근(compute 거짓) = 코드 지문 안 견줌(505-1 덧) - stored = await asyncio.to_thread( - ip.read_initial, project_root, key, mark, None if compute else parts - ) + # 프로젝트 접근(compute 거짓) = 코드 지문 안 견줌(505-1 덧) · 표준 두 꼴(514-4) + forms = standard_forms(parts["standard"]) + stored = None + for form in [None] if compute else [{**parts, "standard": f} for f in forms]: + stored = stored or await asyncio.to_thread( + ip.read_initial, project_root, key, mark, form + ) if stored is not None: return stored if not compute: diff --git a/resources/tester/test_508_2_browser.py b/resources/tester/test_508_2_browser.py index b879b6a3e..68cc6b2e2 100644 --- a/resources/tester/test_508_2_browser.py +++ b/resources/tester/test_508_2_browser.py @@ -134,6 +134,45 @@ def _drop_logs() -> None: asyncio.run(run()) +# 계정 화면 취향(서버 user_ui_prefs) — 새 문맥이어도 남음 · 끌기 시험이 건드리는 것 +VIEW_PREFS = (f"aislo:pref:routeedit-view:{PROJECT}", "aislo:pref:profile-collapsed") + + +def _prefs(email: str, values: dict[str, str | None] | None = None) -> dict[str, str | None]: + """`VIEW_PREFS` 값 읽기 · `values` 있으면 그 키만 되씀(None = 지움 · 다른 키 그대로).""" + import asyncio + import concurrent.futures + + from config.config_db import close_db_pool, init_db_pool + + async def run(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT id FROM users WHERE email = %s", (email,)) + user = (await cursor.fetchone())[0] + for key, value in (values or {}).items(): + path = f'$."{key}"' + if value is None: + sql, args = "JSON_REMOVE(prefs, %s)", (path,) + else: + sql, args = "JSON_SET(prefs, %s, %s)", (path, value) + await cursor.execute( + f"UPDATE user_ui_prefs SET prefs = {sql} WHERE user_id = %s", (*args, user) + ) + await connection.commit() + await cursor.execute("SELECT prefs FROM user_ui_prefs WHERE user_id = %s", (user,)) + row = await cursor.fetchone() + prefs = json.loads(row[0]) if row else {} + return {key: prefs.get(key) for key in VIEW_PREFS} + finally: + await close_db_pool() + + # 플레이라이트 고리 안 — asyncio.run 은 딴 실에서 + with concurrent.futures.ThreadPoolExecutor(1) as pool: + return pool.submit(asyncio.run, run()).result() + + def _board(corridor: str | None) -> None: board = json.loads(BOARD.read_text("utf-8")) board["sites"]["corridor_mesh"] = corridor or "both" @@ -269,6 +308,17 @@ def test_노선_크게_끌어_놓기_등고선_다시_받음(ctx): # 새 문맥 — 앞 시험 페이지가 남긴 보던 화면 · 보관함 없이(축척 · 받기가 늘 같게) fresh_ctx = ctx.browser.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + # 보던 화면은 계정(서버)에도 남음 — 지난 판 끌기 · 줄임이 쌓여 시작 화면이 매번 달랐음(514-4) + kept = _prefs(cred["email"]) + _prefs(cred["email"], {VIEW_PREFS[0]: None}) + try: + _drag_and_fetch(fresh_ctx, cred, got, on_response) + finally: + fresh_ctx.close() + _prefs(cred["email"], kept) # 계정 화면 취향 되돌림 + + +def _drag_and_fetch(fresh_ctx, cred, got, on_response) -> None: login = {k: cred[k] for k in ("email", "password")} assert fresh_ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok page = _open(fresh_ctx, on_response) @@ -309,6 +359,12 @@ def test_노선_크게_끌어_놓기_등고선_다시_받음(ctx): drops += 1 page.wait_for_timeout(4000) if any(g["bbox"] for g in got): + # 둘째 도엽(수 MB)은 늦게 옴 — 바쁜 서버(싱크 때 시험 묶음)면 4 초 안에 하나만(514-4) + for _ in range(60): + if sum(g["bbox"] for g in got) >= 2: + break + page.wait_for_timeout(500) + page.wait_for_timeout(1500) # 더 오는 것(다시 받기 두 번)도 잡히게 break page.mouse.move(1500, 900) page.mouse.down(button="middle") @@ -326,7 +382,6 @@ def test_노선_크게_끌어_놓기_등고선_다시_받음(ctx): page.screenshot(path=str(SHOTS / "sheet_follow_dark.png")) page.evaluate("() => document.documentElement.setAttribute('data-theme', 'light')") page.close() - fresh_ctx.close() fresh = [g for g in after if g["bbox"]] print("first", first, "fresh", fresh, "drops", drops, "status", status) assert during == 0 # 끄는 동안 = 안 받음 diff --git a/resources/tester/test_514_4_recommend_standard_default.py b/resources/tester/test_514_4_recommend_standard_default.py new file mode 100644 index 000000000..24ed55060 --- /dev/null +++ b/resources/tester/test_514_4_recommend_standard_default.py @@ -0,0 +1,52 @@ +"""PLAN 514-4 — 추천 저장본 접근 = 표준 횡단면 「없음」과 config 기본값을 같은 뜻으로. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_514_4_recommend_standard_default.py -q + +전처리 때 표준 없음(None)으로 셈 → B05 · B06 [저장]이 손대지 않은 패널 기본값을 실음 → 화면은 +그 저장본을 꺼냄(409 아님 · 셈 0) · 반대 꼴도 · 사용자가 고친 표준은 옛 저장본을 안 끌어옴. +""" + +from __future__ import annotations + +import copy +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TESTER = Path(__file__).resolve().parent +for path in (ROOT, TESTER): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from test_224_3_recommend_store import _ask, wired # noqa: E402, F401 — 가짜 세계 fixture + +from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES # noqa: E402 +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 + + +def _chain_then_screen(wired, before, after) -> list[dict]: # noqa: F811 + wired.standard = before + for mode in DEFAULT_MODES: + _ask(True, mode=mode) + count = wired.count + wired.standard = after + answers = [_ask(False, mode=mode) for mode in DEFAULT_MODES] + assert wired.count == count # 접근 = 셈 0 + return answers + + +def test_전처리_없음_저장_뒤_기본값이면_꺼냄(wired) -> None: # noqa: F811 + answers = _chain_then_screen(wired, None, copy.deepcopy(STANDARD_CROSS_SECTION)) + assert all(isinstance(a, dict) and a["stored"] is True for a in answers), answers + + +def test_전처리_기본값_뒤_없음이어도_꺼냄(wired) -> None: # noqa: F811 + answers = _chain_then_screen(wired, copy.deepcopy(STANDARD_CROSS_SECTION), None) + assert all(isinstance(a, dict) and a["stored"] is True for a in answers), answers + + +def test_고친_표준은_옛_저장본_안_끌어옴(wired) -> None: # noqa: F811 + custom = copy.deepcopy(STANDARD_CROSS_SECTION) + custom["soil"]["road_width_m"] = 4.0 + answers = _chain_then_screen(wired, None, custom) + assert all(getattr(a, "status_code", None) == 409 for a in answers), answers