feat(B01): 대시보드 항목별 탭 — 해시 탭 줄 · 개요 · 프로젝트 · 회사 · 내 계정 · 시스템 (92-1·92-2)
- 해시 ?tab= 탭 줄(B08 단추 꼴) · 고른 탭 자료만 받음 · 설정 카드는 탭 줄 위 · 회사 고르기 알약은 탭 줄 오른쪽 하나 - 회사 없음 진입 두 곳(router · M02 Layer)은 ?tab=company · 회사 관리자 프로젝트 목록 한 번만 받음 - 개요 숫자 카드(단계별 · 실패 · 재확인 · 가입 대기 · 보관함 · 리소스) · 프로젝트 표 칸 보강 · 찾기 · 거르기 · 쪽 넘김 · 진행 현황 - 회사 정보 칸(사업자번호 · 대표 · 상태) · 팀원 읽기 · 자산 표 · 내 계정 마지막 로그인 · 시스템 리소스 7·30·90일 · 활성 추이 · 전체 회사 · 사용자 · 로그 쪽 · 고객 문의 - 시험 test_b01_dashboard_tabs 신규 · test_b01_settings_cards 고침 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""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
|
||||
@@ -99,14 +99,9 @@ def test_회사_관리자_개인_카드_하나():
|
||||
def test_모든_역할_한_줄_통째로_접힘():
|
||||
src = _text(PAGE)
|
||||
build = _fn(src, "buildPage")
|
||||
# 세 갈래 모두 한 번씩 · 시스템 관리자는 리소스 현황 아래(29-1) · 프로젝트 위
|
||||
assert build.count("buildSystemSettingsSection(state)") == 3
|
||||
sys_part = build.split('} else if (state.user.role === "ADMIN")')[0]
|
||||
assert (
|
||||
sys_part.index('L("B01_Dashboard_Resources")')
|
||||
< sys_part.index("buildSystemSettingsSection(state)")
|
||||
< sys_part.index('L("B01_Dashboard_Projects")')
|
||||
)
|
||||
# 한 번만 · 모든 탭 위(탭 줄보다 앞) — 탭마다 다시 만들지 않음(92-1)
|
||||
assert build.count("buildSystemSettingsSection(state)") == 1
|
||||
assert build.index("buildSystemSettingsSection(state)") < build.index("buildTabBar(")
|
||||
assert "StructureManage" not in src and "buildStructurePanel" not in src
|
||||
assert "top-row" not in src
|
||||
sect = _fn(src, "buildSystemSettingsSection")
|
||||
@@ -120,25 +115,24 @@ def test_모든_역할_한_줄_통째로_접힘():
|
||||
def test_회사_알약_시스템_관리자만():
|
||||
src = _text(PAGE)
|
||||
sect = _fn(src, "buildSystemSettingsSection")
|
||||
assert 'if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);' in sect
|
||||
pill = _fn(src, "withCompanyPill")
|
||||
assert "company-pill" not in sect # 설정 카드 안 알약은 탭 줄 오른쪽으로 옮김(92-1)
|
||||
pill = _fn(src, "buildCompanyPill")
|
||||
assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id))" in pill
|
||||
assert "event.stopPropagation()" in pill # 알약 누름이 접힘으로 새지 않음
|
||||
assert "openCompanyPicker(state.allCompanies" in pill # 조회 후 고르기(29-5)
|
||||
assert '"ui-tag ui-tag--accent b01-dashboard__company-pill"' in pill # 공용 알약 모양
|
||||
assert '.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill)' in pill
|
||||
assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키
|
||||
load = src.split("async function loadRoleData")[1].split("\n}\n")[0]
|
||||
for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)",
|
||||
"fetchCompanyProjects(companyId)"): # fmt: skip
|
||||
load = src.split("async function loadTabData")[1].split("\n}\n")[0]
|
||||
for call in ("fetchAllUsers(scoped)", "fetchCompanyJoinRequests(scoped)",
|
||||
"fetchCompanyProjects(scoped)"): # fmt: skip
|
||||
assert call in load, call
|
||||
assert "const scoped = system ? state.adminCompanyId : undefined;" in load
|
||||
build = _fn(src, "buildPage")
|
||||
# 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약
|
||||
sys_part = build.split('} else if (state.user.role === "ADMIN")')[0]
|
||||
assert sys_part.count("withCompanyPill(") == 4
|
||||
assert "withCompanyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1]
|
||||
assert "companyTable(picked, state.user)" in build
|
||||
assert "openAddMemberModal(undefined, state.adminCompanyId)" in build
|
||||
# 탭 줄 오른쪽 하나 — 시스템 관리자 · 회사 따라 바뀌는 탭에서만
|
||||
assert build.count("buildCompanyPill(state)") == 1
|
||||
assert 'state.user.role === "SYSTEM_ADMIN" && COMPANY_TABS.includes(state.tab)' in build
|
||||
assert "buildTabBar(state.tab, state.user.role, pill)" in build
|
||||
tab_src = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_UI_CompanyTab.ts")
|
||||
assert "openAddMemberModal(undefined, state.adminCompanyId)" in tab_src
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
Reference in New Issue
Block a user