"""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" 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) 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: [title, back, 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(() => tree.newItem()) } : {})" in page ) panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert 'void addItem(at?.kind === "folder" ? at.path : []);' in panel assert ".m02-main__new {\n margin-left: auto;" in _text( M02 / "M02_MasterTemplete_UI_Style.css" ) def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움(): """31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸 자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로).""" side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side 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