Files
Aislo/resources/tester/test_m02_layer_screen.py
T
eomsangdonandClaude Opus 5.5 fb56d7c294 fix(M02): 좌측 두 컨테이너 펼친 채 진입 · 끌기 뒤 놓을 자리 강조 지움 (PLAN 31-5)
- 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 들어옴
- 끌어 놓은 뒤 나무 칸 자신(맨 위 자리) 강조가 남던 흠 — 놓음 · 끝 · 칸 밖 나감에서 모두 지움(칸 안 자식 위로는 그대로)
- 끌기 코드를 TreePanel_Drag 로 나눔(700줄)
- 시험 한 건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:27:02 +09:00

271 lines
15 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) 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