Files
Aislo/resources/tester/test_508_3_b07_prefetch_browser.py
T

333 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""508-3b · c 화면 — 자기 포트(5177) Playwright 머리 없는 브라우저 · 검증_sub2 · `AISLO_LIVE_DB=1` 일 때만.
- B07 열기 → 미리받기 차례 = 가벼운 것 먼저 · 평면도(무거운 것) 끝
- B06 으로 가 첫 측점 깎기 비탈 고치고 [저장] → B07 돌아옴 → 지문 바뀐 도면만 다시 받음
(횡단 = 고친 측점 든 장 하나 · 측점 지문도 그 측점만) · 페이지 옮겨도 받은 것은 그대로
- 안 받은 평면도(유역도 · 저장본 지움) 열기 → 「받는 중」 → 받은 뒤 보임
- 평면도 둘째 열기 = 서버 저장본(시간 전 · 후) · GET 이 지우는 파일 없음
- 못 받은 도면 = 「다시 받기」 → 누르면 다시 받음(밝음 · 어두움 한 장씩 · tmp/shots_508_3)
- 단계 행 · projects · 노선 · 종 · 횡단 행 · B05 노선 · B06 · B07 storage 를 먼저 떠 두고 끝에 되돌림
AISLO_LIVE_DB=1 ./venv/Scripts/python.exe -m pytest resources/tester/test_508_3_b07_prefetch_browser.py -q -s
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import sys
import time
import urllib.request
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from helper_verify_project import own_ports # noqa: E402
BASE = own_ports()[1] # 화면 서버 = OWNERS.md 이 폴더 줄
PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md)
SHOTS = ROOT / "tmp" / "shots_508_3"
STAGE_COLS = (
"state",
"progress_percent",
"params",
"message",
"started_at",
"completed_at",
"updated_at",
)
KEEP_DIRS = ("B05_Profile/route", "B06_Section", "B07_DesignDetail")
def _up() -> bool:
try:
urllib.request.urlopen(BASE, timeout=3)
return True
except Exception:
return False
pytestmark = pytest.mark.skipif(
os.environ.get("AISLO_LIVE_DB") != "1" or not _up(),
reason="AISLO_LIVE_DB=1 · 5177 서버 있을 때만(검증_sub2 storage · DB 행을 씀)",
)
async def _keep(mode: str, held: dict) -> None:
"""단계 · projects · routes · 종 · 횡단 행 + storage 폴더 떠 두기(save) · 되돌리기(restore)."""
import aiomysql
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
conn = await aiomysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME,
charset="utf8mb4", autocommit=False,
) # fmt: skip
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
if mode == "save":
await cur.execute("SELECT storage_path FROM projects WHERE id=%s", (PROJECT,))
held["root"] = Path(
resolve_stored_project_path((await cur.fetchone())["storage_path"])
)
for name, sql in (
("stages", "SELECT * FROM project_workflow_stages WHERE project_id=%s"),
("project", "SELECT status, updated_at FROM projects WHERE id=%s"),
("routes", "SELECT id, status FROM routes WHERE project_id=%s"),
("long", "SELECT id, data, status FROM longitudinal_sections WHERE project_id=%s"),
("cross", "SELECT * FROM cross_sections WHERE project_id=%s"),
): # fmt: skip
await cur.execute(sql, (PROJECT,))
held[name] = await cur.fetchall()
held["copy"] = ROOT / "tmp" / f"pt_508_3_keep_{int(time.time())}"
for rel in KEEP_DIRS:
shutil.copytree(held["root"] / rel, held["copy"] / rel)
return
for row in held["stages"]:
await cur.execute(
f"UPDATE project_workflow_stages SET {', '.join(f'{k}=%s' for k in STAGE_COLS)} "
"WHERE id=%s",
(*[row[k] for k in STAGE_COLS], row["id"]),
)
project = held["project"][0]
await cur.execute(
"UPDATE projects SET status=%s, updated_at=%s WHERE id=%s",
(project["status"], project["updated_at"], PROJECT),
)
for row in held["routes"]:
await cur.execute(
"UPDATE routes SET status=%s WHERE id=%s", (row["status"], row["id"])
)
for row in held["long"]:
await cur.execute(
"UPDATE longitudinal_sections SET data=%s, status=%s WHERE id=%s",
(row["data"], row["status"], row["id"]),
)
await cur.execute("DELETE FROM cross_sections WHERE project_id=%s", (PROJECT,))
for row in held["cross"]:
cols = list(row)
await cur.execute(
f"INSERT INTO cross_sections ({', '.join(cols)}) "
f"VALUES ({', '.join(['%s'] * len(cols))})",
[row[c] for c in cols],
)
await conn.commit()
for rel in KEEP_DIRS:
shutil.rmtree(held["root"] / rel)
shutil.copytree(held["copy"] / rel, held["root"] / rel)
finally:
conn.close()
@pytest.fixture(scope="module")
def ctx():
sync = pytest.importorskip("playwright.sync_api")
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
held: dict = {}
asyncio.run(_keep("save", held))
try:
with sync.sync_playwright() as play:
chrome = play.chromium.launch(channel="msedge", headless=True)
context = chrome.new_context(viewport={"width": 1600, "height": 950})
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}" # 비밀번호는 안 찍음
context.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')")
context.held = held # type: ignore[attr-defined]
yield context
chrome.close()
finally:
asyncio.run(_keep("restore", held))
print("되돌림 사본 자리", held.get("copy"))
def _list(ctx) -> dict:
body = ctx.request.get(f"{BASE}/api/projects/{PROJECT}/design-drawings", timeout=120000)
return {item["id"]: item for item in body.json()["drawings"]}
def _watch(page) -> list[tuple[float, str]]:
gets: list[tuple[float, str]] = []
page.on(
"requestfinished",
lambda q: (
q.method == "GET"
and "/design-drawings/" in q.url
and gets.append((time.time(), q.url.rsplit("/", 1)[-1]))
),
)
return gets
def _settle(page, gets: list, want: set[str], start: int = 0, timeout_s: float = 240) -> None:
"""`start` 뒤로 원하는 도면을 다 받고 5 s 동안 새 GET 이 없을 때까지."""
end = time.time() + timeout_s
while time.time() < end:
got = {name for _t, name in gets[start:]}
quiet = len(gets) == start or time.time() - gets[-1][0] > 5
if want <= got and quiet:
return
page.wait_for_timeout(500)
raise AssertionError(f"못 받음 {sorted(want - {name for _t, name in gets[start:]})}")
def _b07(page) -> None:
page.wait_for_selector(".b07-drawing-button[data-active='true']", timeout=90000)
def _open_group(page, kind: str) -> None:
"""접힌 목록 묶음을 폄."""
group = page.locator(f".b07-drawing-group[data-kind='{kind}']")
if "is-collapsed" in (group.get_attribute("class") or ""):
group.locator(".ui-collapsible__title").first.click()
def _cut_slope_and_save(page, value: str) -> str:
"""B06 첫 횡단 카드 깎기 비탈 → [저장] · 카드 머리 글을 돌려줌."""
box = page.locator(".b06-design__cutslope-input").first
box.wait_for(timeout=120000)
page.wait_for_timeout(3000)
title = page.locator("article.b06-cross-card").first.inner_text().split("\n")[0]
box.fill(value)
box.press("Enter")
page.wait_for_timeout(800)
with page.expect_response(
lambda r: "/design/save" in r.url and r.request.method == "POST", timeout=180000
) as saved:
page.locator("button.ui-btn", has_text="저장").last.click()
assert saved.value.ok, f"B06 저장 실패 {saved.value.status}"
page.wait_for_timeout(2000)
return title
def test_b07_미리받기_지문_화면(ctx):
root: Path = ctx.held["root"]
SHOTS.mkdir(parents=True, exist_ok=True)
page = ctx.new_page()
gets = _watch(page)
# 옛 판 자료는 첫 B06 [저장]이 모든 측점 설계에 새 칸을 붙임(한 번만 · 실제 자료 바뀜) — 먼저 한 번
page.goto(f"{BASE}/#/b06-section", wait_until="domcontentloaded")
_cut_slope_and_save(page, "0.45")
before = _list(ctx)
mark = len(gets)
page.evaluate("location.hash = '#/b07-design-detail'")
_b07(page)
_settle(page, gets, set(before), mark)
order = [name for _t, name in gets[mark:]]
heavy = [i for i, name in enumerate(order) if before[name]["kind"] == "plan"]
light = [i for i, name in enumerate(order) if before[name]["kind"] != "plan"]
print("미리받기 차례", order)
assert len(order) == len(set(order)) == len(before) # 한 장 한 번
assert max(light) < min(heavy) # 무거운 것 끝
# B06 으로 가 측점 하나 고치고 [저장] → B07 로 돌아옴(같은 창 · 페이지 밖 모듈 그대로)
page.evaluate("location.hash = '#/b06-section'")
card = _cut_slope_and_save(page, "0.55")
after = _list(ctx)
changed = {key for key in before if before[key]["fingerprint"] != after[key]["fingerprint"]}
sheets = {key for key in changed if before[key]["kind"] == "cross"}
stations = {
(key, c)
for key in sheets
for c, value in (after[key]["stations"] or {}).items()
if before[key]["stations"].get(c) != value
}
print(f"고친 카드 「{card}」 · 지문 바뀐 도면 {sorted(changed)} · 측점 {sorted(stations)}")
assert len(sheets) == 1 and len(stations) == 1 # 그 장만 · 그 측점만
mark = len(gets)
page.evaluate("location.hash = '#/b07-design-detail'")
_b07(page)
_settle(page, gets, changed, mark)
again = [name for _t, name in gets[mark:]]
print("돌아와 다시 받음", again)
assert set(again) == changed and len(again) == len(changed)
# 되돌림(화면 조작으로 바꾼 값 = 원래 값) — 행 · 파일은 끝에 통째로 되돌림
page.evaluate("location.hash = '#/b06-section'")
_cut_slope_and_save(page, "0.40")
page.close()
# 평면도 둘째 열기 = 서버 저장본(시간 전 · 후) · GET 이 지우는 파일 없음
shutil.rmtree(root / "_derived" / "b07_plan_plan_watershed", ignore_errors=True)
shutil.rmtree(root / "_derived" / "b07_plan_plan_terrain", ignore_errors=True)
files = {p for p in root.rglob("*") if p.is_file() and "B05_Profile" not in p.parts}
url = f"{BASE}/api/projects/{PROJECT}/design-drawings/plan_terrain"
spent = []
for _ in range(2):
start = time.time()
assert ctx.request.get(url, timeout=120000).ok
spent.append(time.time() - start)
print(f"평면도(지형) 첫 열기 {spent[0]:.2f} s → 둘째(저장본) {spent[1]:.2f} s")
assert spent[1] < spent[0]
gone = {p for p in files if not p.exists()}
assert not gone, f"GET 이 지움 {sorted(map(str, gone))[:5]}"
assert list((root / "_derived" / "b07_plan_plan_terrain").glob("*.json"))
# 안 받은 평면도(유역도 · 저장본 없음) 열기 → 「받는 중」 → 받은 뒤 보임
page = ctx.new_page()
page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded")
_b07(page)
_open_group(page, "plan")
start = time.time()
page.locator(".b07-drawing-button[data-drawing-id='plan_watershed']").click()
page.wait_for_selector(".ui-toast:has-text('받는 중')", timeout=10000)
page.wait_for_function(
"() => document.querySelector('.b07-drawing-button[data-drawing-id=plan_watershed]')"
"?.dataset.fetch === 'ready' && document.querySelector('.cad-host')?.dataset.loading === 'false'",
timeout=120000,
)
print(f"유역도 받는 중 → 보임 {time.time() - start:.2f} s")
page.screenshot(path=str(SHOTS / "watershed_light.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
page.wait_for_timeout(500)
page.screenshot(path=str(SHOTS / "watershed_dark.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
page.close()
def test_b07_다시_받기(ctx):
page = ctx.new_page()
target = "mass_haul"
fails = {"n": 0}
def fail(route):
fails["n"] += 1
route.abort()
page.route(f"**/design-drawings/{target}", fail)
page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded")
_b07(page)
button = page.locator(f".b07-drawing-button[data-drawing-id='{target}']")
page.wait_for_function(
f"() => document.querySelector('.b07-drawing-button[data-drawing-id={target}]')"
"?.dataset.fetch === 'error'",
timeout=120000,
)
print("미리받기 실패 횟수(한 번 다시 포함)", fails["n"])
assert fails["n"] == 2
text = page.evaluate(
f"getComputedStyle(document.querySelector('.b07-drawing-button[data-drawing-id={target}]"
" .b07-drawing-button__name'), '::after').content"
)
assert "다시 받기" in text
panel = button.locator("xpath=..")
panel.screenshot(path=str(SHOTS / "retry_light.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
panel.screenshot(path=str(SHOTS / "retry_dark.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
page.unroute(f"**/design-drawings/{target}")
button.click()
page.wait_for_function(
f"() => document.querySelector('.b07-drawing-button[data-drawing-id={target}]')"
"?.dataset.fetch === 'ready' && document.querySelector('.cad-host')?.dataset.loading === 'false'",
timeout=60000,
)
page.screenshot(path=str(SHOTS / "retry_after.png"))
page.close()