"""B01 대시보드 항목별 탭 시험(92장) — 화면 조작은 ORCA. · 탭 줄: 역할별 탭 수 · 해시 값 해석(없거나 모르는 값 = 개요) · 시스템 탭은 시스템 관리자만 · 프로젝트 계산: 단계별 수 · 실패 · 재확인 · 거르기 · 찾기 · 진행 현황 단계 고르기 · 진입 두 곳(회사 없음)이 회사 탭으로 · 같은 프로젝트 목록을 두 번 받지 않음 · 새 문구 키가 사전에 있음 · 소스 700줄 이하 """ from __future__ import annotations import json import re import shutil import subprocess from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] B01 = ROOT / "B01_Dashboard" PAGE = B01 / "B01_Dashboard_UI_Page.ts" STATS = B01 / "B01_Dashboard_UI_ProjectStats.ts" TABS = B01 / "B01_Dashboard_UI_Tabs.ts" NODE = ["node", "--experimental-strip-types", "--no-warnings"] def _text(path: Path) -> str: return path.read_text(encoding="utf-8") def _node(script: str, *args: str) -> object: result = subprocess.run( [*NODE, "--input-type=module", "-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) def _stage(no: int, state: str, **extra: object) -> dict: return {"stage_no": no, "stage_key": f"S{no}", "state": state, "progress_percent": 0, **extra} def _project(name: str, current: int, states: tuple[str, ...] = (), **extra: object) -> dict: stages = [_stage(i, states[i] if i < len(states) else "NOT_STARTED") for i in range(7)] return { "id": name, "name": name, "workflow_stage": 0, "workflow_state": {"project_id": name, "current_stage": current, "stages": stages}, **extra, } # fmt: skip PROJECTS = [ _project( "가-노선", 2, ("COMPLETE", "COMPLETE", "IN_PROGRESS"), region="강원", client_org="산림청" ), _project("나-횡단", 3, ("COMPLETE", "COMPLETE", "COMPLETE", "FAILED"), owner_name="홍길동"), _project("다-상세", 4, ("COMPLETE", "STALE", "COMPLETE", "COMPLETE", "IN_PROGRESS")), _project("라-입력", 0, ()), { "id": "마", "name": "마-옛", "workflow_stage": 9, }, # 단계 정보 없는 옛 줄 · 단계 9 → 끝으로 눌림 ] @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_프로젝트_계산(): script = ( f"import * as m from '{STATS.as_uri()}';\n" "const ps = JSON.parse(process.argv[1]);\n" "const names = (f, q = '') => ps.filter((p) => m.matchesFilter(p, f) && m.matchesQuery(p, q))" ".map((p) => p.name);\n" "console.log(JSON.stringify({\n" " sum: m.summarizeProjects(ps),\n" " failed: names('failed'), stale: names('stale'), stage2: names('stage-2'),\n" " stage6: names('stage-6'), all: names('all').length,\n" " q1: names('all', '산림청'), q2: names('all', '강원 노선'), q3: names('all', '없는말'),\n" " filters: ['failed', 'stale', 'stage-3', 'stage-7', 'stage-x', null, 'all']" ".map((v) => m.parseFilter(v)),\n" " prog: ps.map((p) => m.progressStage(p)?.stage_no ?? null),\n" "}));\n" ) out = _node(script, json.dumps(PROJECTS)) assert out["sum"] == { "total": 5, "byStep": [1, 0, 1, 1, 1, 0, 1], # 마-옛 는 9 → 6 으로 눌림 "failed": 1, "stale": 1, } # 개요 숫자 = 프로젝트 탭 거르기 수 assert out["failed"] == ["나-횡단"] and len(out["failed"]) == out["sum"]["failed"] assert out["stale"] == ["다-상세"] and len(out["stale"]) == out["sum"]["stale"] assert out["stage2"] == ["가-노선"] and out["stage6"] == ["마-옛"] and out["all"] == 5 assert out["q1"] == ["가-노선"] and out["q2"] == ["가-노선"] and out["q3"] == [] assert out["filters"] == ["failed", "stale", "stage-3", "all", "all", "all", "all"] # 진행 현황 단계 — 실패 → 재확인 → 지금 단계 assert out["prog"] == [2, 3, 1, 0, None] @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_탭_수와_해시_해석(): src = _text(TABS) funcs = "\n".join( re.search(rf"^export function {name}[\s\S]*?^}}", src, re.M).group(0).replace("export ", "") for name in ("tabsFor", "resolveTab") ) script = ( "type Tab = 'overview' | 'projects' | 'company' | 'account' | 'system';\n" "type Role = 'SYSTEM_ADMIN' | 'ADMIN' | 'USER';\n" f"interface DashboardUser {{ role: Role }}\n{funcs}\n" "const roles: Role[] = ['SYSTEM_ADMIN', 'ADMIN', 'USER'];\n" "console.log(JSON.stringify({\n" " tabs: roles.map((r) => tabsFor(r)),\n" " pick: roles.map((r) => ['company', 'system', 'zzz', null].map((v) => resolveTab(r, v))),\n" "}));\n" ) result = subprocess.run( [*NODE, "--input-type=module-typescript", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr out = json.loads(result.stdout) base = ["overview", "projects", "company", "account"] assert out["tabs"] == [[*base, "system"], base, base] # 시스템 관리자 5 · 나머지 4 assert out["pick"] == [ ["company", "system", "overview", "overview"], ["company", "overview", "overview", "overview"], # 시스템 탭은 시스템 관리자만 ["company", "overview", "overview", "overview"], ] assert "navigateTo(ROUTES.B01_ACCOUNT, { tab, ...extra })" in src # 라우터 안 바꿈 · 해시만 def test_회사_없음_두_곳은_회사_탭으로(): router = _text(ROOT / "A00_Common" / "router.ts") assert 'navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" })' in router layer = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Layer.ts") assert 'navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" })' in layer def test_탭마다_그_탭_자료만_받음(): src = _text(PAGE) load = src.split("async function loadTabData")[1].split("\n}\n")[0] # 같은 프로젝트 목록을 두 번 받지 않음 — 회사 관리자는 회사 목록 하나 assert load.count("fetchUserProjects()") == 1 and load.count("fetchCompanyProjects(") == 2 assert 'if (tab === "overview" || tab === "projects")' in load for key in ("overview", "company", "system"): assert f'tab === "{key}"' in load # 고른 회사 인자 한 곳 assert "const scoped = system ? state.adminCompanyId : undefined;" in load for call in ("fetchCompanyProjects(scoped)", "fetchAllUsers(scoped)", "fetchCompanyJoinRequests(scoped)"): # fmt: skip assert call in load, call # 시스템 탭 자료는 시스템 탭에서만 sys_part = load.split('if (tab === "system")')[1] for call in ("fetchAuditLogPage(", "fetchSupportRequests()", "fetchAllUsers(null)"): assert call in sys_part, call assert "fetchSupportRequests" not in load.split('if (tab === "system")')[0] def test_설정_카드는_탭_줄_위_알약은_탭_줄_오른쪽_하나(): src = _text(PAGE) build = src.split("function buildPage(")[1].split("\n}\n")[0] assert build.count("buildSystemSettingsSection(state)") == 1 assert build.index("buildSystemSettingsSection(state)") < build.index("buildTabBar(") assert build.count("buildCompanyPill(state)") == 1 assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키 assert "withCompanyPill" not in src tab_bar = _text(TABS) assert "if (trailing) bar.append(trailing);" in tab_bar # 설정 카드 안 알약은 없어짐 assert ( "company-pill" not in src.split("function buildSystemSettingsSection")[1].split("\n}\n")[0] ) def test_새_문구_키가_사전에_있음(): locales = "".join(_text(p) for p in (ROOT / "ui_template").glob("ui_template_locale*.ts")) names = ("Tabs", "Overview", "ProjectsTab", "CompanyTab", "System", "Paging", "Profile", "Projects", "Company", "Admin", "Page") # fmt: skip for name in names: src = _text(B01 / f"B01_Dashboard_UI_{name}.ts") for key in set(re.findall(r'L\(\s*"(B01_[A-Za-z_]+)"', src)): assert f"{key}:" in locales, f"{name}: {key}" # 템플릿 글자로 만드는 키(상태 · 문의 상태)도 모두 있음 for key in ( *( f"B01_Pj_State_{s}" for s in ("NOT_STARTED", "IN_PROGRESS", "COMPLETE", "FAILED", "STALE") ), *(f"B01_Sy_Inquiry_{s}" for s in ("NEW", "REVIEWED", "RESOLVED", "DELETED")), ): assert f"{key}:" in locales, key def test_소스_700줄_이하(): for path in [*B01.glob("B01_Dashboard_UI_*.ts"), *B01.glob("B01_Dashboard_UI_Style*.css")]: assert len(_text(path).splitlines()) <= 700, path.name