- 오른쪽 클릭 「마스터 · 회사 · 개인으로 복사」 — 쓸 수 있는 자기 층(지금 층 뺌) 맨 위에 바로 · 새 키 · 같은 이름 「(2)」 - 끌어다 옮기기 — 같은 층 · 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 고른 것 전부 · 제 안으로 막음 · 읽기 층은 끌기 없음 - 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 · 저장 안 한 고침은 물음) - 시험 맞춤 · 30-4 시험 한 건 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
13 KiB
Python
236 lines
13 KiB
Python
"""M02 한 화면 뿌리 셋(PLAN 27-1 · 진입 해시 `?open=&root=&owner=`) 붙는 자리 시험 — 조작은 ORCA.
|
|
|
|
· 권한 규칙(`accessFor`)을 Node 로 바로 돌림 — 시스템 SYSTEM_ADMIN ·
|
|
회사 ADMIN/is_master/SYSTEM_ADMIN · 개인 본인 · 회사 없음 · 남의 층 읽기만
|
|
· 주소 인자를 라우트에서 떼는지 · 대시보드 카드 역할별 단추 · 층 길 접두어 · 뿌리 셋
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
M02 = ROOT / "M02_MasterTemplete"
|
|
LAYER = M02 / "M02_MasterTemplete_Layer.ts"
|
|
|
|
|
|
def _text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
|
def test_층별_고칠_권한():
|
|
"""서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만."""
|
|
text = _text(LAYER)
|
|
own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M)
|
|
found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M)
|
|
assert own and found, "ownOwner · accessFor 를 못 찾음"
|
|
script = (
|
|
'type ManageLayer = "system" | "company" | "personal";\n'
|
|
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
|
|
" is_master?: boolean }\n"
|
|
"interface LayerAccess { canWrite: boolean; needCompany: boolean }\n"
|
|
+ own.group(0).replace("export ", "")
|
|
+ found.group(0).replace("export ", "")
|
|
+ "\nconst cases = JSON.parse(process.argv[1]);"
|
|
+ "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));"
|
|
)
|
|
sysadmin = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": None}
|
|
sys3 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 3}
|
|
admin = {"id": "5", "role": "ADMIN", "company_id": 3}
|
|
master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True}
|
|
user = {"id": "7", "role": "USER", "company_id": 3}
|
|
orphan = {"id": "8", "role": "USER", "company_id": None}
|
|
cases = [
|
|
["system", None, sysadmin], ["system", None, admin], ["system", None, None],
|
|
["company", None, admin], ["company", None, master], ["company", None, user],
|
|
["company", None, orphan], ["company", None, sys3],
|
|
["personal", None, user], ["personal", None, admin], ["personal", None, orphan],
|
|
["personal", None, sysadmin],
|
|
["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin],
|
|
["personal", "9/90", sysadmin],
|
|
] # fmt: skip
|
|
result = subprocess.run(
|
|
["node", "--experimental-strip-types", "--no-warnings",
|
|
"--input-type=module-typescript", "-e", script, json.dumps(cases)],
|
|
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr
|
|
ok = {"canWrite": True, "needCompany": False}
|
|
ro = {"canWrite": False, "needCompany": False}
|
|
no_company = {"canWrite": False, "needCompany": True}
|
|
assert json.loads(result.stdout) == [
|
|
ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만
|
|
ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만
|
|
ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내
|
|
ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만
|
|
] # fmt: skip
|
|
|
|
|
|
def test_주소_인자를_라우트에서_뗌():
|
|
router = _text(ROOT / "A00_Common" / "router.ts")
|
|
assert '.split("?")[0]' in router
|
|
assert "export function currentQuery()" in router
|
|
assert "query?: Record<string, string>" in router
|
|
layer = _text(LAYER)
|
|
# 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로
|
|
assert 'query.get("root") ?? query.get("layer")' in layer
|
|
assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer
|
|
assert '["구조물조합", "일위대가", "옵션", "도면"]' in layer
|
|
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
|
|
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
|
def test_볼_회사는_시스템_관리자만_고름():
|
|
"""29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 ·
|
|
그 밖 = 자기 회사(해시 company 무시) · 모양 틀린 값은 버림."""
|
|
found = re.search(r"^export function viewCompany[\s\S]*?^}", _text(LAYER), re.M)
|
|
assert found, "viewCompany 를 못 찾음"
|
|
script = (
|
|
"interface SessionUser { role?: string; company_id?: number | null }\n"
|
|
"interface Entry { company: string | null }\n"
|
|
"let saved: string | null = null;\n"
|
|
"(globalThis as any).sessionStorage = { getItem: () => saved };\n"
|
|
+ found.group(0).replace("export ", "")
|
|
+ "\nconst sys = { role: 'SYSTEM_ADMIN', company_id: 1 };"
|
|
+ "\nconst admin = { role: 'ADMIN', company_id: 3 };"
|
|
+ "\nconst out = [viewCompany({ company: '3' }, sys), viewCompany({ company: null }, sys)];"
|
|
+ "\nsaved = '5';"
|
|
+ "\nout.push(viewCompany({ company: null }, sys), viewCompany({ company: '3' }, sys));"
|
|
+ "\nout.push(viewCompany({ company: '9' }, admin), viewCompany({ company: 'x' }, sys));"
|
|
+ "\nconsole.log(JSON.stringify(out));"
|
|
)
|
|
result = subprocess.run(
|
|
["node", "--experimental-strip-types", "--no-warnings",
|
|
"--input-type=module-typescript", "-e", script],
|
|
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == ["3", "1", "5", "3", "3", "1"]
|
|
|
|
|
|
def test_층_단추_셋_남의_회사_사람_나무():
|
|
"""27-1b · 29-2 · 29-6 — 층 단추 셋 · 다른 층 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) ·
|
|
떠나면 층 길을 시스템으로 되돌림 · 「사람 고르기」 없음 · 시스템 관리자가 남의 회사를 보면
|
|
「개인」 = 그 회사 사람 나무(사람 줄 → 라이브러리 · 읽기 · 안 골랐으면 첫 사람) ·
|
|
나무 부품은 owner · 자료 길을 받음."""
|
|
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
|
assert "createLayerBar({" in page and "setActiveLayer(layer, owner)" in page
|
|
assert "const company = viewCompany(entry, user);" in page
|
|
assert "await main.confirmLeave()" in page.split("const go =")[1].split("navigateTo(")[0]
|
|
assert 'dispose: () => setActiveLayer("system")' in page
|
|
assert "side.reveal(entry.open)" in page
|
|
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
|
assert 'fetchOwners("company")' not in roots # 회사 목록 펼침 없앰
|
|
assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰
|
|
assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무
|
|
assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기
|
|
assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots
|
|
assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page
|
|
assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page
|
|
assert 'peopleTree("구조물조합", tree.root)' in page
|
|
assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page
|
|
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
|
assert (
|
|
"api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel
|
|
)
|
|
# 읽기 층 = 고치기 메뉴 없음 · [복사]만(30-2) · 붙여넣기는 쓸 수 있는 층만
|
|
assert "...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {})" in panel
|
|
assert "const got = opt.canEdit && api.paste ? clipOf(clipKind) : null;" in panel
|
|
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
|
assert "owner=${enc(activeOwner)}" in api and "/owners" in api
|
|
|
|
|
|
def test_층_길_접두어와_고정_풀림():
|
|
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
|
assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api
|
|
for path in ("/structures/numbers", "/structure-combos", "/material-combos"):
|
|
assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path
|
|
assert "/layers/personal/items/copy" in api
|
|
for name in ("Structure", "Combo_Api"):
|
|
assert '"system"' not in _text(M02 / f"M02_MasterTemplete_{name}.ts")
|
|
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
|
assert "const layer = opt.layer;" in side
|
|
assert 'layer === "system"' in side # 도면 양식은 시스템 층에만
|
|
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
|
assert 'sel.layer !== "system"' not in main
|
|
assert 'return layer === "project" || isAdmin;' in main
|
|
|
|
|
|
def test_읽기만_회사용_개인용으로_복사_막대():
|
|
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
|
assert 'layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user)' in page
|
|
assert (
|
|
"readOnlyBar?.setColumn(column)" in page
|
|
) # 열 고름 → 막대 · 구조물 라이브러리가 함께 받음
|
|
assert "accessFor(layer, owner, user).needCompany" in page # 회사 없음 = 마스터로
|
|
assert "buildNeedCompany()" in _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
|
assert 'title: L("M02_Title")' in page # 한 화면 한 제목
|
|
layer = _text(LAYER)
|
|
assert 'fromLayer: "company"' in layer and "M02_CopyToPersonal" in layer
|
|
|
|
|
|
SHEET = ROOT / "ui_template" / "sheet"
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
|
def test_열_0_첫_열_만들기():
|
|
"""빈 개인 · 회사 층 — 열이 하나도 없어도 첫 열(머리 글 + 빈 아래 층)을 만듦."""
|
|
ops = _text(SHEET / "ui_template_sheet_ops.ts")
|
|
fresh = re.search(r"^function freshId[\s\S]*?^}", ops, re.M)
|
|
first = re.search(r"^export function addFirstColumn[\s\S]*?^}", ops, re.M)
|
|
assert fresh and first, "freshId · addFirstColumn 을 못 찾음"
|
|
script = (
|
|
"interface SheetColumn { id: string; 머리: (string | null)[]; 단위?: string | null;"
|
|
" 꼴?: string; 일위대가?: string | null; 자재조합?: string | null }\n"
|
|
"interface SheetDoc { 열: SheetColumn[] }\n"
|
|
+ fresh.group(0)
|
|
+ "\n"
|
|
+ first.group(0).replace("export ", "")
|
|
+ "\nconst doc: SheetDoc = { 열: [] };"
|
|
+ "\nconst a = addFirstColumn(doc, 4, '새 열');"
|
|
+ "\nconsole.log(JSON.stringify([a, doc.열[0].머리, doc.열[0].꼴, doc.열.length]));"
|
|
)
|
|
result = subprocess.run(
|
|
["node", "--experimental-strip-types", "--no-warnings",
|
|
"--input-type=module-typescript", "-e", script],
|
|
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == ["c1", ["새 열", "", "", ""], "수", 1]
|
|
|
|
|
|
def test_읽기만_표는_단추_고치기_모두_막음():
|
|
sheet = _text(SHEET / "ui_template_sheet.ts")
|
|
assert "const ro = !!opts.readOnly;" in sheet
|
|
assert sheet.count("if (ro") + sheet.count("!ro") + sheet.count("ro ||") >= 7
|
|
# 열 0 = 「열 더하기」 하나만 켜짐 · 첫 열
|
|
assert "b.disabled = !hasCol && !empty" in sheet and "addFirstColumn(" in sheet
|
|
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
|
assert re.search(r"readOnly,\s*lockHead: !!struct,\s*onChange", main)
|
|
assert ".ui-sheet__toolbar[hidden]" in _text(SHEET / "ui_template_sheet.css")
|
|
|
|
|
|
def test_켜진_층_단추는_목록으로_복사_끌기():
|
|
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 ·
|
|
저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) ·
|
|
끌어다 옮기기는 쓸 수 있는 층만."""
|
|
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
|
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
|
|
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
|
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
|
|
assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page
|
|
assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page
|
|
labor = _text(M02 / "M02_MasterTemplete_Labor.ts")
|
|
assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor
|
|
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
|
assert "if (opt.canEdit) {\n button.draggable = true;" in panel
|
|
assert 'dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path))' in panel
|