"""PLAN 511-A 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub2. - 판 기본값(both) = B05 계획고 ▲ · 교각점 끌기 동안 /calc 0 · 브라우저 셈 기록만 - 묶음 하나씩(quantity · carry · fixed_browser) server → 같은 조작 → /calc 요청 · 답마다 그 페이지 TS 로 다시 셈한 값 = 서버 값 · 그 묶음 id 만 감 → 판 기본값으로(파일 바이트 그대로) - 저장 안 함(세션 초안만 · 창 닫으면 버림) · 시험 브라우저 표식 시스템 로그 행만 지움 - 시간 표 = `tmp/511a_times.json`(묶음마다 브라우저 ms · 왕복 ms · Node ms · 흉내 0 · 30 · 80 ms) ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_sites_browser.py -q -s """ from __future__ import annotations import asyncio import json import statistics import sys import urllib.request from pathlib import Path import pytest HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] if str(HERE) not in sys.path: sys.path.insert(0, str(HERE)) from helper_verify_project import own_ports # noqa: E402 from test_505_3_cross_card_screen import JS_NODES # noqa: E402 API, BASE = own_ports() BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) MARK = "AisloTest-511-A" TIMES = ROOT / "tmp" / "511a_times.json" GROUP_IDS = { "quantity": {"structure.layouts", "cross.berm_spans", "masshaul.series"}, "carry": {"stations.carry_keyed"}, "fixed_browser": {"route.handle", "route.station_fit", "stations.swap", "profile.tools"} | {"cross.fit", "footprint.plan"}, } def _started(base: str) -> str | None: try: with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: return json.loads(res.read()).get("started_at") except Exception: return None _UP = _started(BASE) if BASE else None pytestmark = pytest.mark.skipif( _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" ) JS_GRADE = r"""async () => { const url = performance.getEntriesByType('resource').map((e) => e.name) .find((n) => /design_pipeline_page\.ts/.test(n)); const m = await import(url); const pid = localStorage.getItem('frd_current_project_id'); const pipe = m.pagePipeline(pid); const cur = pipe.input('alignment') ?? { station_offsets: {}, curve_radii: {} }; const st = pipe.output('ground').stations; const key = st[Math.floor(st.length / 2)].chainageM.toFixed(3); m.pipelineGrade(pid, { ...cur, station_offsets: { ...cur.station_offsets, [key]: (cur.station_offsets?.[key] ?? 0) + 0.3 } }); }""" # 잡은 /calc 하나를 이 페이지의 같은 TS 로 다시 셈 → 같은지 JS_MIRROR = r"""async (calls) => { const urls = performance.getEntriesByType('resource').map((e) => e.name); const mod = async (re) => { const u = urls.find((n) => re.test(n)); if (!u) throw new Error('모듈 없음 ' + re); return import(u); }; const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) => x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null'); const where = { 'structure.layouts': [/B06_Section_Structure_Layouts\.ts/, 'structureAreaRows'], 'cross.berm_spans': [/common_util_berm_spans\.ts/, 'bermSpansFromStructures'], 'masshaul.series': [/common_util_mass_haul\.ts/, 'computeMassHaulSeries'], 'route.handle': [/B05_Profile_UI_RouteEdit_Curve\.ts/, 'dragHandleTo'], 'route.station_fit': [/B05_Profile_RouteEdit_StationFit\.ts/, 'fitAfter'], 'stations.swap': [/B06_Section_Station_Swap\.ts/, 'swapCrossSections'], 'profile.tools': [/B05_Profile_UI_Profile_Straighten\.ts/, 'straightenBetween'], 'cross.fit': [/B06_Section_UI_Cross_Fit\.ts/, 'toeOffsets'], 'footprint.plan': [/B05_Profile_Footprint_Stage\.ts/, 'footprintStage'], 'stations.carry_keyed': [/B06_Section_Route_Carry\.ts/, 'carryKeyedStates'], }; const out = []; for (const { run, inputs, results } of calls) { for (const id of run) { const fn = (await mod(where[id][0]))[where[id][1]]; const args = inputs.args?.[id]; const local = id === 'structure.layouts' ? fn(inputs.detail.cross_sections) : id === 'cross.berm_spans' ? fn(inputs.berm.structures) : id === 'footprint.plan' ? fn({ out: args[0], range: null, prev: null }) : id === 'stations.carry_keyed' ? fn((n) => args[0][n] ?? {}, args[1], args[2]) : fn(...args); const theirs = id === 'structure.layouts' ? results[id].areas : id === 'cross.berm_spans' ? results[id].spans : results[id]; out.push({ id, same: JSON.stringify(plain(local)) === JSON.stringify(theirs) }); } } return out; }""" def _drop_test_logs() -> None: 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("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) await connection.commit() finally: await close_db_pool() asyncio.run(run()) @pytest.fixture(scope="module") def ctx(): sync = pytest.importorskip("playwright.sync_api") committed = BOARD.read_bytes() cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) try: with sync.sync_playwright() as play: chrome = play.chromium.launch(channel="msedge", headless=True) context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) login = {k: cred[k] for k in ("email", "password")} r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 try: yield context finally: for key in GROUP_IDS: _put(context, key, "both") chrome.close() finally: BOARD.write_bytes(committed) _drop_test_logs() def _put(context, key: str, value: str) -> None: r = context.request.put(f"{BASE}/api/dashboard/admin/calc-sites", data={"sites": {key: value}}) assert r.ok, r.status def _session(context, delay: int = 0): page = context.new_page() page.add_init_script( f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" " performance.setResourceTimingBufferSize(20000);" ) calls: list[dict] = [] def on_response(res): if res.url.endswith("/calc") and res.request.method == "POST": calls.append({**json.loads(res.request.post_data), "results": res.json()["results"]}) page.on("response", on_response) page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) page.wait_for_timeout(9000) seed = "{ calls: [], local: {}, delayMs: 0 }" page.evaluate(f"(d) => {{ (window.__aisloCalcServer ??= {seed}).delayMs = d; }}", delay) return page, calls def _act(page) -> dict: page.evaluate(JS_GRADE) # 계획고 ▲ 한 번(가운데 측점 +0.3 m) page.wait_for_timeout(3500) nodes = page.evaluate(JS_NODES) assert len(nodes) >= 3, "지도에서 교각점을 못 찾음" node = nodes[len(nodes) // 2] page.mouse.move(node["x"], node["y"]) page.mouse.down() page.mouse.move(node["x"] + 14, node["y"] + 9, steps=10) # 교각점 끌기(노선 초안) page.mouse.up() page.wait_for_timeout(5000) return page.evaluate("() => window.__aisloCalcServer") def _median(values: list[float]) -> float | None: values = [v for v in values if v is not None] return round(statistics.median(values), 2) if values else None def test_기본값_서버_부름_0(ctx): page, calls = _session(ctx) log = _act(page) page.close() assert calls == [] and log["calls"] == [] assert {"structure.layouts", "masshaul.series", "footprint.plan"} <= set(log["local"]) assert {"route.station_fit", "stations.swap", "stations.carry_keyed"} <= set(log["local"]) TIMES.write_text(json.dumps({"browser": log["local"]}, ensure_ascii=False), encoding="utf-8") @pytest.mark.parametrize("group", list(GROUP_IDS)) def test_묶음_서버_요청_값_같음_시간(ctx, group): _put(ctx, group, "server") table = json.loads(TIMES.read_text(encoding="utf-8")) if TIMES.exists() else {} try: for delay in (0, 30, 80): page, calls = _session(ctx, delay) log = _act(page) mirror = page.evaluate(JS_MIRROR, calls) page.close() sent = {id_ for call in calls for id_ in call["run"]} assert sent and sent <= GROUP_IDS[group], (group, sent) assert mirror and all(row["same"] for row in mirror), (group, mirror) assert all(c["same"] is not False for c in log["calls"]), log["calls"] rows = {} for call in log["calls"]: rows.setdefault(call["id"], []).append(call) table[f"{group}@{delay}"] = { id_: { "n": len(group_calls), "round_ms": _median([c["roundMs"] for c in group_calls]), "node_ms": _median([c["nodeMs"] for c in group_calls]), "wall_ms": _median([c["wallMs"] for c in group_calls]), "kb": round(_median([c["bytes"] for c in group_calls]) / 1024), } for id_, group_calls in rows.items() } print(group, delay, table[f"{group}@{delay}"]) finally: _put(ctx, group, "both") TIMES.write_text(json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8") assert json.loads(BOARD.read_text("utf-8"))["sites"][group] == "both"