254 lines
13 KiB
Python
254 lines
13 KiB
Python
"""PLAN 505-3 화면 — 자기 포트(5179) Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4.
|
|
|
|
- 종단 측점선 · 원지반선 겹친 자리 누름 = 측점선이 받음(지반선 누름 통과) → 임시 횡단 창 = B06 카드 + 「⚠ 배관 관경 빈칸」 칩
|
|
- 관경 800(세션만 · [저장] 안 함) → 창 카드에 관 형상 · Ctrl+휠 확대가 창 칸 밖으로 안 넘침 · 밝음 · 어두움 한 장씩
|
|
- 곡선 판 = 도구줄 아래 · 열린 횡단 판 왼쪽(판을 나중에 열어도 비킴)
|
|
- 계정 화면 취향(종단 · 횡단 판 열림 · 높이)만 시험 앞 값으로 되돌림
|
|
|
|
./venv/Scripts/python.exe -m pytest resources/tester/test_505_3_cross_card_screen.py -q
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
# 기본 = sub4 환경 · 다른 창은 AISLO_505_3_BASE · AISLO_505_3_PROJECT 로 바꿔 test_0 만 돌림(test_1 · 2 는 sub4 자료)
|
|
from helper_verify_project import own_ports # noqa: E402
|
|
|
|
BASE = os.environ.get("AISLO_505_3_BASE", own_ports()[1]) # 화면 서버 = OWNERS.md 이 폴더 줄
|
|
PROJECT = os.environ.get("AISLO_505_3_PROJECT", "c725d747-50ae-46b0-87ce-4d4e56a53cf3") # 검증_sub4
|
|
SHOTS = ROOT / "tmp" / "shots_505_3"
|
|
PIPE_M = 388 # 검증_sub4 배관 하나(관경 빈칸 · 19+8)
|
|
PREFS = "/api/dashboard/me/ui-prefs"
|
|
# 이 시험이 건드리는 취향만 되돌림 — 계정을 다른 창도 같이 써서 통째로 덮으면 남의 값이 사라짐
|
|
TOUCHED = ("profile-collapsed", "profile-height", "b05-routeedit-cross-v2")
|
|
|
|
|
|
def _up() -> bool:
|
|
try:
|
|
urllib.request.urlopen(BASE, timeout=3)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
pytestmark = pytest.mark.skipif(not _up(), reason="서버 없음")
|
|
|
|
# 측점 m 을 가로 가운데로 · 그 측점선과 원지반선 교점(화면 좌표) · 그 자리 맨 위 요소
|
|
JS_SCROLL = r"""(m) => {
|
|
const marker = [...document.querySelectorAll('.b06-chart__station')]
|
|
.find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6);
|
|
const body = marker.closest('.b05-route-profile__body');
|
|
body.scrollLeft = Math.max(0, +marker.querySelector('line').getAttribute('x1') - body.clientWidth / 2);
|
|
}"""
|
|
JS_POINT = r"""(m) => {
|
|
const marker = [...document.querySelectorAll('.b06-chart__station')]
|
|
.find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6);
|
|
const line = marker.querySelector('.b06-chart__station-line');
|
|
const svg = line.ownerSVGElement;
|
|
const x = +line.getAttribute('x1');
|
|
const poly = svg.querySelector('.b06-chart__profile');
|
|
const pts = poly.getAttribute('points').trim().split(/\s+/).map((s) => s.split(',').map(Number));
|
|
const k = Math.max(1, pts.findIndex((p) => p[0] >= x));
|
|
const [a, b] = [pts[k - 1], pts[k]];
|
|
const pt = svg.createSVGPoint();
|
|
pt.x = x;
|
|
pt.y = a[1] + ((b[1] - a[1]) * (x - a[0])) / (b[0] - a[0] || 1);
|
|
const s = pt.matrixTransform(poly.getScreenCTM());
|
|
return { x: s.x, y: s.y, hit: document.elementFromPoint(s.x, s.y)?.getAttribute('class') };
|
|
}"""
|
|
# 지도 캔버스에서 교각점 원(--map-route-node 색)을 찾아 화면 좌표로 — 픽셀 고정 자리 대신 요소 · 색으로
|
|
JS_NODES = r"""() => {
|
|
const canvas = document.querySelector('.b05-routeedit__canvas');
|
|
const box = canvas.getBoundingClientRect();
|
|
const hex = getComputedStyle(canvas).getPropertyValue('--map-route-node').trim() || '#15803d';
|
|
const want = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
const sx = canvas.width / box.width;
|
|
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const cell = 14, cells = new Map();
|
|
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) {
|
|
const i = (y * canvas.width + x) * 4;
|
|
if (Math.abs(data[i] - want[0]) + Math.abs(data[i + 1] - want[1]) + Math.abs(data[i + 2] - want[2]) > 12) continue;
|
|
const key = `${Math.floor(x / cell)},${Math.floor(y / cell)}`;
|
|
const c = cells.get(key) ?? { n: 0, x: 0, y: 0 };
|
|
c.n++; c.x += x; c.y += y; cells.set(key, c);
|
|
}
|
|
return [...cells.values()].filter((c) => c.n > 60)
|
|
.map((c) => ({ x: box.left + c.x / c.n / sx, y: box.top + c.y / c.n / sx }))
|
|
.filter((p) => document.elementFromPoint(p.x, p.y) === canvas);
|
|
}"""
|
|
JS_MODAL = r"""() => {
|
|
const r = document.querySelector('.b05-crossmodal');
|
|
const card = r.querySelector('.b06-cross-card');
|
|
const box = r.querySelector('.b05-crossmodal__box').getBoundingClientRect();
|
|
const svg = card?.querySelector('svg').getBoundingClientRect();
|
|
return {
|
|
station: r.querySelector('.b05-crossmodal__station').textContent,
|
|
card: !!card,
|
|
chips: [...r.querySelectorAll('.b05-cross-chips > *')].map((e) => e.textContent),
|
|
pipes: card ? card.querySelectorAll('.b06-chart__culvert-pipe').length : 0,
|
|
inside: !!svg && svg.top >= box.top && svg.bottom <= box.bottom + 1 && svg.right <= box.right + 1,
|
|
width: Math.round(r.getBoundingClientRect().width),
|
|
buttons: [...r.querySelectorAll('.b05-crossmodal__actions button')].map((e) => e.className),
|
|
scale: [...(card?.querySelectorAll('svg g[transform]') ?? [])]
|
|
.map((g) => g.getAttribute('transform')).find((t) => /^translate\([^)]*\) scale\(/.test(t)) ?? '',
|
|
};
|
|
}"""
|
|
JS_RECTS = r"""() => {
|
|
const q = (s) => [...document.querySelectorAll(s)].find((x) => x.getClientRects().length);
|
|
const r = (s) => { const o = q(s)?.getBoundingClientRect(); return o ? [o.left, o.top, o.right, o.bottom] : null; };
|
|
return { label: r('.b05-routeedit__label'), tools: r('.b05-routeedit__tools'), side: r('.b05-routeedit__side-wrap') };
|
|
}"""
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def page():
|
|
sync = pytest.importorskip("playwright.sync_api")
|
|
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
|
|
with sync.sync_playwright() as play:
|
|
chrome = play.chromium.launch(channel="msedge", headless=True)
|
|
ctx = chrome.new_context(viewport={"width": 1600, "height": 1000})
|
|
login = {k: cred[k] for k in ("email", "password")}
|
|
assert ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok
|
|
prefs = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {})
|
|
tab = ctx.new_page()
|
|
tab.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')")
|
|
tab.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded")
|
|
tab.wait_for_function("() => !!window.__aisloPipeline", timeout=90000)
|
|
tab.wait_for_timeout(8000)
|
|
SHOTS.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
yield tab
|
|
finally:
|
|
tab.wait_for_timeout(2000) # 취향 올리기(1.5 s 모음) 끝난 뒤 되돌림
|
|
now = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {})
|
|
for key in (f"aislo:pref:{name}" for name in TOUCHED):
|
|
if key in prefs:
|
|
now[key] = prefs[key]
|
|
else:
|
|
now.pop(key, None)
|
|
assert ctx.request.put(f"{BASE}{PREFS}", data={"prefs": now}).ok
|
|
chrome.close()
|
|
|
|
|
|
def _profile(page, open_: bool) -> None:
|
|
height = page.evaluate(
|
|
"() => document.querySelector('.b05-route-profile')?.getBoundingClientRect().height ?? 0"
|
|
)
|
|
if (height > 100) != open_: # 접힘 = 높이 1 px(측점 요소는 DOM 에 남음)
|
|
page.get_by_text("종단", exact=True).last.click()
|
|
page.wait_for_timeout(3000)
|
|
|
|
|
|
def _open_modal(page) -> dict:
|
|
_profile(page, True)
|
|
page.evaluate(JS_SCROLL, PIPE_M)
|
|
page.wait_for_timeout(800)
|
|
at = page.evaluate(JS_POINT, PIPE_M)
|
|
assert "b06-chart__station-line" in (at["hit"] or ""), at # 지반선이 아니라 측점선이 받음
|
|
page.mouse.move(at["x"], at["y"])
|
|
page.wait_for_timeout(300)
|
|
page.mouse.click(at["x"], at["y"])
|
|
page.wait_for_function(
|
|
"() => document.querySelector('.b05-crossmodal .b06-cross-card')", timeout=30000
|
|
)
|
|
page.wait_for_timeout(1500)
|
|
return page.evaluate(JS_MODAL)
|
|
|
|
|
|
def test_0_곡선_판_도구줄_횡단판_비킴(page):
|
|
_profile(page, False)
|
|
rects = page.evaluate(JS_RECTS)
|
|
if rects["side"][2] - rects["side"][0] > 100: # 횡단 판 접고 시작
|
|
page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click()
|
|
page.wait_for_timeout(2000)
|
|
shown = "() => [...document.querySelectorAll('.b05-routeedit__label')].some((x) => !x.hidden)"
|
|
nodes = page.evaluate(JS_NODES)
|
|
assert nodes, "지도에서 교각점을 못 찾음"
|
|
for node in nodes[1:-1] or nodes: # 양끝(시점 · 종점) 말고 곡선 있는 가운데 점
|
|
for _ in range(3): # 첫 누름이 앞 고름(구조물) 풂으로 갈 수 있음
|
|
page.mouse.click(node["x"], node["y"])
|
|
page.wait_for_timeout(1500)
|
|
if page.evaluate(shown):
|
|
break
|
|
if page.evaluate(shown):
|
|
break
|
|
page.screenshot(path=str(SHOTS / "curve_pick.png"))
|
|
assert page.evaluate(shown)
|
|
# 지도를 밀어 고른 꺾임점을 도구줄 · 오른쪽 끝 밑으로
|
|
canvas = page.locator(".b05-routeedit__canvas").bounding_box()
|
|
tools = page.evaluate(JS_RECTS)["tools"]
|
|
goal = (canvas["x"] + canvas["width"] - 60, tools[3] + 40)
|
|
page.mouse.move(*(canvas["x"] + canvas["width"] / 2, canvas["y"] + canvas["height"] * 0.65))
|
|
page.mouse.down(button="middle")
|
|
page.mouse.move(
|
|
canvas["x"] + canvas["width"] / 2 + goal[0] - node["x"],
|
|
canvas["y"] + canvas["height"] * 0.65 + goal[1] - node["y"],
|
|
steps=16,
|
|
)
|
|
page.mouse.up(button="middle")
|
|
page.wait_for_timeout(1200)
|
|
rects = page.evaluate(JS_RECTS)
|
|
assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5
|
|
page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click()
|
|
page.wait_for_timeout(2500)
|
|
rects = page.evaluate(JS_RECTS)
|
|
assert rects["label"][2] <= rects["side"][0] - 8 + 0.5 # 판을 나중에 열어도 비킴
|
|
assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5
|
|
page.screenshot(path=str(SHOTS / "curve_light.png"))
|
|
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
|
|
page.screenshot(path=str(SHOTS / "curve_dark.png"))
|
|
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
|
|
page.keyboard.press("Escape") # 꺾임점 고름 풂
|
|
|
|
|
|
def test_1_빈칸_칩_카드(page):
|
|
modal = _open_modal(page)
|
|
assert modal["station"] == "19+8" and modal["card"]
|
|
assert modal["chips"] == ["⚠ 배관 관경 빈칸"]
|
|
assert modal["pipes"] == 0 and modal["width"] == 380
|
|
assert all("ui-btn ui-btn--ghost ui-btn--sm" in name for name in modal["buttons"])
|
|
page.locator(".b05-crossmodal").get_by_text("닫기").click()
|
|
|
|
|
|
def test_2_횡단_판_칩_관경_넣으면_형상_확대_밝음_어두움(page):
|
|
_profile(page, False)
|
|
page.locator(".b05-route__irregular-item", has_text="19+8").first.click()
|
|
page.wait_for_timeout(3000)
|
|
chips = page.eval_on_selector_all(
|
|
".b05-routeedit__cross .b05-cross-chips > *", "(els) => els.map((e) => e.textContent)"
|
|
)
|
|
assert "⚠ 배관 관경 빈칸" in chips # 「횡단」 판 = 카드 머리를 걷어도 칩은 그래프 오른쪽 위
|
|
page.locator(".b05-routeedit__cross").first.screenshot(path=str(SHOTS / "panel_chip.png"))
|
|
if not page.locator("select:visible").filter(has=page.locator("option[value='800']")).count():
|
|
page.get_by_text("구조물 배치", exact=True).click()
|
|
page.wait_for_timeout(1500)
|
|
page.locator("select:visible").filter(
|
|
has=page.locator("option[value='800']")
|
|
).first.select_option("800") # 세션 초안만 — [저장] 안 누름
|
|
page.wait_for_timeout(5000)
|
|
modal = _open_modal(page)
|
|
assert modal["chips"] == [] and modal["pipes"] > 0
|
|
shot = page.locator(".b05-crossmodal")
|
|
shot.screenshot(path=str(SHOTS / "modal_light.png"))
|
|
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
|
|
shot.screenshot(path=str(SHOTS / "modal_dark.png"))
|
|
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
|
|
box = page.locator(".b05-crossmodal__box").bounding_box()
|
|
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
|
|
page.evaluate(
|
|
"([x, y]) => document.elementFromPoint(x, y).dispatchEvent(new WheelEvent('wheel',"
|
|
" {deltaY: 300, ctrlKey: true, clientX: x, clientY: y, bubbles: true, cancelable: true}))",
|
|
[cx, cy],
|
|
)
|
|
page.wait_for_timeout(800)
|
|
zoomed = page.evaluate(JS_MODAL)
|
|
assert "scale(1.15)" in zoomed["scale"] and zoomed["inside"]
|
|
page.locator(".b05-crossmodal").get_by_text("닫기").click()
|