"""PLAN 334 — ☰ 바로가기(요구 225-1 · 225-2). ① Node — 진행 단계 「시스템 관리자 전용」 = 전처리 길 기준(칸 번호 아님) · 대시보드 둘째 칸은 안 꺼짐 settingLinks 역할 셋 · 설정 ☰ 지금 칸(진입 인자) ② 실제 브라우저(sub2 화면 서버 5177 · 꺼지면 건너뜀) — 대시보드 ☰ 이름 = 구역 제목 차례(역할 셋) · 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) · M01 ☰ 다섯 · 강조 · 「도면 양식」 → open=도면 · M02 → M02 이동은 떠나기 물음 먼저(가드 자리에 물음 창을 심어 확인 · 취소 = 머묾) """ from __future__ import annotations import json import os import re import subprocess import urllib.request from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5177") # sub2 화면 서버(OWNERS 자기 줄) LAYOUT = ROOT / "ui_template" / "ui_template_workflow_layout.ts" NAV = ROOT / "A00_Common" / "m_settings_nav.ts" STEPS = ["파일입력", "전처리", "종단설계"] WF_ROUTES = ["b03-file-input", "b04-preprocess", "b05-profile"] def _fn(src: str, name: str) -> str: found = re.search(rf"^(?:export )?function {name}\([\s\S]*?^}}", src, re.M) assert found, f"{name} 를 못 찾음" return found.group(0).removeprefix("export ") def _node(script: str, *args: str): result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", script, *args], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr return json.loads(result.stdout) FAKE_DOM = ( "const mk = (): any => { const cls = new Set(); return {" " children: [] as any[], textContent: '', title: '', disabled: false, on: null as any," " set className(v: string) { cls.clear(); v.split(' ').filter(Boolean).forEach((c) => cls.add(c)); }," " get className() { return [...cls].join(' '); }," " classList: { add: (...c: string[]) => c.forEach((x) => cls.add(x))," " remove: (c: string) => cls.delete(c), contains: (c: string) => cls.has(c)," " toggle: (c: string, on: boolean) => (on ? cls.add(c) : cls.delete(c)) }," " setAttribute() {}, append(...c: any[]) { this.children.push(...c); }," " addEventListener(_: string, f: any) { this.on = f; } }; };\n" "const document = { createElement: () => mk() };\n" ) def _step_bar(role: str, routes: list[str]) -> list[bool]: """역할 · 길 목록으로 그린 단계 단추의 꺼짐 — 역할 물음이 끝난 뒤""" src = _fn(LAYOUT.read_text(encoding="utf-8"), "createStepBar") script = ( FAKE_DOM + "const ROUTES = { B01_ACCOUNT: 'b01-account', B04_PREPROCESS: 'b04-preprocess' };\n" "const t = (k: string) => k;\nconst navigateTo = () => {};\n" "const getDashboardRole = async () => process.argv[1];\n" + src.replace("import.meta.env.DEV", "false") + "\nconst bar = createStepBar(JSON.parse(process.argv[2]), -1," " { routes: JSON.parse(process.argv[3]) });" "\nawait new Promise((r) => setTimeout(r, 0));" "\nconsole.log(JSON.stringify(bar.children.map((b: any) => b.disabled)));" ) return _node(script, role, json.dumps(STEPS), json.dumps(routes)) def test_전처리_잠금은_길_기준(): # 프로젝트 진행 단계 — 그대로(전처리만 시스템 관리자 전용) assert _step_bar("ADMIN", WF_ROUTES) == [False, True, False] assert _step_bar("USER", WF_ROUTES) == [False, True, False] assert _step_bar("SYSTEM_ADMIN", WF_ROUTES) == [False, False, False] # 대시보드 구역 바로가기 — 둘째 칸이 회사 관리자 · 일반에서 안 꺼짐 titles = ["시스템 설정", "프로젝트", "임시 보관함"] assert _step_bar("ADMIN", titles) == [False, False, False] assert _step_bar("USER", titles) == [False, False, False] def _settings(user: dict, active: str, company) -> dict: """settingLinks 목록 + ☰ 에 맡긴 단계 막대(이름 · 지금 칸 · 아이콘 · 🏠)""" src = NAV.read_text(encoding="utf-8") icons = re.search(r"^const ICONS.*$", src, re.M).group(0) script = ( "const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," " M02_MASTER_TEMPLATE: 'm02-master-template' };\n" "const L = (k: string) => k;\nlet bar: any = null;\n" "const createStepBar = (titles: string[], active: number, o: any) =>" " ({ titles, active, icons: o.icons, home: o.homeButton, routes: o.routes });\n" "const setDrawerSteps = (b: any) => { bar = b; };\nconst go = () => {};\n" + icons + "\n" + _fn(src, "settingLinks") + "\n" + _fn(src, "mountSettingsDrawer") + "\nconst [user, active, company] = JSON.parse(process.argv[1]);" "\nmountSettingsDrawer(user, active, company);" "\nconsole.log(JSON.stringify({ links: settingLinks(user, company).map((l) =>" " [l.key, l.route, l.query ?? null]), bar }));" ) return _node(script, json.dumps([user, active, company])) M02 = "m02-master-template" def test_설정_바로가기_역할_셋_지금_칸(): admin = _settings({"role": "SYSTEM_ADMIN", "company_id": 1}, "옵션", "3") assert admin["links"] == [ ["m01", "m01-master-data", None], ["구조물조합", M02, {"open": "구조물조합", "root": "personal", "company": "3"}], ["옵션", M02, {"open": "옵션", "root": "master", "company": "3"}], ["도면", M02, {"open": "도면", "root": "master", "company": "3"}], ] bar = admin["bar"] assert bar["active"] == 2 and bar["home"] is True assert bar["icons"] == ["🗄️", "🧱", "🎛️", "📑"] assert _settings({"role": "SYSTEM_ADMIN"}, "m01", None)["bar"]["active"] == 0 # 일위대가조합 = 강조 없음 assert _settings({"role": "SYSTEM_ADMIN"}, "일위대가조합", None)["bar"]["active"] == -1 # 그 밖 역할 = 🏠 + 구조물 조합 하나(회사 인자 안 실음) company = _settings({"role": "ADMIN", "company_id": 1}, "구조물조합", "3") assert company["links"] == [["구조물조합", M02, {"open": "구조물조합", "root": "personal"}]] assert company["bar"]["active"] == 0 assert _settings({"role": "USER", "is_master": True}, "구조물조합", None)["links"][0][2] == { "open": "구조물조합", "root": "company", } assert _settings({"role": "USER"}, "구조물조합", None)["links"][0][2]["root"] == "personal" # ── 실제 브라우저 ───────────────────────────────────────────── def _up() -> bool: try: urllib.request.urlopen(BASE, timeout=3) return True except Exception: return False def _cred(who: str) -> dict: if who == "시스템 관리자": return json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) accounts = json.loads((ROOT / "tmp" / "verify_accounts.json").read_text(encoding="utf-8")) return {k: accounts[who][k] for k in ("email", "password")} def _page(browser, who: str, route: str, size=(1280, 700)): ctx = browser.new_context(viewport={"width": size[0], "height": size[1]}) r = ctx.request.post(f"{BASE}/api/auth/login/request", data=_cred(who), timeout=60000) assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 page = ctx.new_page() page.goto(f"{BASE}/#/{route}", wait_until="domcontentloaded") return page DRAWER_LABELS = ( "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step-label')]" ".map((e) => e.textContent.trim())" ) DRAWER_DISABLED = ( "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step')]" ".map((e) => e.disabled)" ) SECTION_TITLES = ( "[...document.querySelector('.b01-dashboard__grid').children]" ".map((s) => s.querySelector('.ui-card__title').textContent.trim())" ) DRAWER_OPEN = "!!document.querySelector('.is-drawer-open')" def _open_drawer(page): page.click(".app-burger") page.wait_for_selector(".app-drawer__steps .ui-workflow-layout__step") @pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") @pytest.mark.parametrize("who, count", [("시스템 관리자", 10), ("회사관리자", 8), ("일반", 6)]) def test_대시보드_구역_바로가기(who, count): from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(channel="msedge", headless=True) page = _page(browser, who, "b01-account") page.wait_for_selector(".b01-dashboard__grid", timeout=60000) page.wait_for_load_state("networkidle") titles = page.evaluate(SECTION_TITLES) assert len(titles) == count, titles _open_drawer(page) assert page.evaluate(DRAWER_LABELS) == titles assert page.evaluate(DRAWER_DISABLED) == [False] * count # 둘째 칸 안 꺼짐 if who == "시스템 관리자": page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('보안')") assert not page.evaluate(DRAWER_OPEN) top = ( "[...document.querySelector('.b01-dashboard__grid').children]" ".find((s) => s.querySelector('.ui-card__title').textContent.trim() === '보안')" ".getBoundingClientRect().top" ) try: # 부드러운 굴림이 멎을 때까지 page.wait_for_function(f"() => Math.abs({top} - 52) <= 2", timeout=5000) except Exception: pass assert abs(page.evaluate(top) - 52) <= 2, page.evaluate(top) browser.close() ACTIVE = "document.querySelector('.app-drawer__steps .is-active .ui-workflow-layout__step-label')?.textContent" HASH = "decodeURIComponent(location.hash)" def _drawer_click(page, label: str): _open_drawer(page) page.click(f".app-drawer__steps .ui-workflow-layout__step:has-text('{label}')") @pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") def test_설정_4_페이지_바로가기(): from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(channel="msedge", headless=True) page = _page(browser, "시스템 관리자", "m01-master-data") page.wait_for_selector(".m01-master", timeout=60000) _open_drawer(page) assert page.evaluate(DRAWER_LABELS) == [ "대시보드", "마스터 데이터", "구조물 조합", "구조물 옵션", "도면 양식", ] assert page.evaluate(ACTIVE) == "마스터 데이터" page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('도면 양식')") page.wait_for_function("decodeURIComponent(location.hash).includes('open=도면')") assert M02 in page.evaluate(HASH) page.wait_for_selector(".m02-master", timeout=60000) page.wait_for_load_state("networkidle") _open_drawer(page) assert page.evaluate(ACTIVE) == "도면 양식" page.click(".app-burger") # 닫음 # M02 → M02 — 저장 안 한 고침 대신 가드 자리에 앱의 물음 창을 심음 page.evaluate( """async () => { const url = (p) => performance.getEntriesByType('resource') .map((e) => e.name).find((n) => n.includes(p)); const router = await import(url('/router.ts')); const ui = await import(url('/ui_template_elements.ts')); router.setLeaveGuard(() => ui.showConfirmDialog('저장 안 한 고침')); }""" ) before = page.evaluate(HASH) _drawer_click(page, "구조물 옵션") page.wait_for_selector(".ui-confirm") page.click(".ui-confirm__actions button >> nth=0") # 취소 = 머묾 page.wait_for_timeout(300) assert page.evaluate(HASH) == before _drawer_click(page, "구조물 옵션") page.wait_for_selector(".ui-confirm") page.click(".ui-confirm__actions button >> nth=1") # 버림 = 이동 page.wait_for_function("decodeURIComponent(location.hash).includes('open=옵션')") browser.close()