Files
Aislo/resources/tester/test_m02_layer_screen.py

331 lines
18 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 · 39-6) — 남의 층(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)
manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M)
assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음"
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 ", "")
+ "\n"
+ manages.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], ["personal", "3/5", user], ["personal", "9/90", admin],
["company", "9", admin],
] # 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, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침
ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403)
] # 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_가져올_곳_탭():
"""39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 ·
일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터)."""
text = _text(LAYER)
parts = [
re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M),
re.search(r"^export function manages[\s\S]*?^}", text, re.M),
re.search(r"^export interface Source[\s\S]*?^}", text, re.M),
re.search(r"^export function readableSources[\s\S]*?^}", text, re.M),
]
assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음"
script = (
'type ManageLayer = "system" | "company" | "personal";\n'
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
" is_master?: boolean }\n"
"const L = (key: string): string => key;\n"
+ "\n".join(p.group(0).replace("export ", "") for p in parts if p)
+ "\nconst cases = JSON.parse(process.argv[1]);"
+ "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {"
+ "\n const got = readableSources(u, c, { practice });"
+ "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];"
+ "\n})));"
)
sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1}
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 = [
[sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False],
[user, "3", True], [orphan, None, False],
] # 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
assert json.loads(result.stdout) == [
["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄
["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님
["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음
["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자
["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음
["master", ["master"]], # 회사 없음
] # fmt: skip
@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 "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6)
assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침
assert 'L("M02_PeopleEmpty")' 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 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움
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")
# 시스템 층도 층 길 한 벌(39-8 H2 · 옛 /templates/* 는 시스템 관리자 전용)
assert "owned(activeLayer, `/layers/${activeLayer}${path}`)" in api
assert 'activeLayer === "system" ? "" :' not in api
for path in ("/structures/numbers", "/structure-combos"):
assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path
assert "/layers/personal/items/copy" in api
assert '"system"' not in _text(M02 / "M02_MasterTemplete_Structure.ts")
# 열 조합 읽기(옛 Combo_Api · 39-4 D3 지움) 자리 = 열마다 일위대가 문서 — 층을 받아 부름
labor = _text(M02 / "M02_MasterTemplete_Labor_Api.ts")
assert "const laborAt = (layer: Layer, column: string, tail: string): string =>" in labor
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 }\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 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) ·
30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만."""
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
# 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창
assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page
assert "(to === layer && toOwner === here)) return [];" in page
copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts")
assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to
assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text(
M02 / "M02_MasterTemplete_TreePanel.ts"
)
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
assert 'dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path))' in drag
def test_구조물_조합_제목_줄_새_항목():
"""30-5 — 「구조물 조합」 제목 줄 오른쪽 끝 「새 항목」(공용 `m02-main__new` · 쓸 수 있는 층만 ·
구조물 조합일 때만 보임) · 누르면 나무 「항목 추가」와 같은 창(고른 폴더 안 · 없으면 맨 위)."""
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "export function newItemButton(" in main and 'classList.add("m02-main__new")' in main
assert (
"children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]"
in main
)
assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {})" in page
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder())
assert "const newItem = (): void => void addItem(folder());" in panel
assert ".m02-main__new {\n margin-left: auto;" in _text(
M02 / "M02_MasterTemplete_UI_Style.css"
)
def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움():
"""31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸
자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로)."""
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
assert 'summary.classList.remove("is-collapsed");' in side
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
mark = drag.split("const dropMark = ")[1].split("};")[0]
assert 'body.classList.remove("is-drop");' in mark
assert "if (!to || !body.contains(to)) dropMark(null);" in drag