검증_sub2: B06 측점 하나 [저장] 뒤 다시 받음 = 바뀐 6 장(횡단 그 장 · 측점 하나) · 평면도 지형 첫 열기 1.3~4.4 s → 저장본 0.2~0.4 s · 안 받은 유역도 = 받는 중 → 4.4 s 뒤 보임 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
14 KiB
Python
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()
|