From 064d9ebae7d7dae588da1f187a12fe2c5e5eb42c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 21:30:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20=EB=82=98?= =?UTF-8?q?=EB=AC=B4=20=3D=20=EC=9E=90=EC=9C=A0=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=E2=80=94=20=EB=B6=84=EB=A5=98=20=EA=B3=A0=EC=A0=95=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EA=B1=B7=EC=96=B4=20=EB=83=84(158-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: 층 뿌리 precombo_폴더.json 에 폴더 · 문서 자리만 적음(문서 파일 제자리) · GET/POST /layers/{층}/precombo-tree · 새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(빈 폴더만) · 판 409 · 층 권한 그대로 - 화면: 오른쪽 클릭 [새 폴더] · [여기에 새 사전조합] · [이름 바꾸기] · [지우기] · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기(구조물 조합 부품) · 자리 없는 문서 = 뿌리 - 구분 · 상세구분은 문서 칸 그대로(고르기 창 거름 precomboClasses) - 시험: test_m02_precombo_folders 새로 · test_m02_precombo_scope 나무 시험 갈음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- M02_MasterTemplete/M02_Labor_Router.py | 33 +- .../M02_MasterTemplete_Api_Fetch.ts | 21 ++ .../M02_MasterTemplete_Labor_Text.ts | 5 + .../M02_MasterTemplete_UI_Side_Precombo.ts | 283 ++++++++++++------ M02_MasterTemplete/M02_Precombo_Folders.py | 136 +++++++++ .../tester/helper_m02_precombo_scope.cjs | 17 +- resources/tester/test_m02_precombo_folders.py | 155 ++++++++++ resources/tester/test_m02_precombo_scope.py | 98 +++--- 8 files changed, 587 insertions(+), 161 deletions(-) create mode 100644 M02_MasterTemplete/M02_Precombo_Folders.py create mode 100644 resources/tester/test_m02_precombo_folders.py diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index ae135a0c4..5ddb1792d 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -2,7 +2,8 @@ 문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · -지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버). +지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + +사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ @@ -20,7 +21,14 @@ from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Material_Own as own -from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root +from M02_MasterTemplete import M02_Precombo_Folders as pre_folders +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( + Layer, + Session, + TreeBody, + _call, + _root, +) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) @@ -105,6 +113,27 @@ def new_library( return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref) +@router.get("/layers/{layer}/precombo-tree") +def precombo_tree( + layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """사전조합 나무 폴더 — `{판, 폴더: [경로 …], 자리: {키: 경로}}`(자리 없는 문서 = 뿌리).""" + return _call(_root(session, layer, owner), pre_folders.view) + + +@router.post("/layers/{layer}/precombo-tree") +def edit_precombo_tree( + layer: Layer, + body: TreeBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """새 폴더 · 이름 바꾸기 · 옮기기(폴더 · 문서 `키`) · 지우기(빈 폴더만) → 새 나무 · + 판 다르면 409.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, pre_folders.edit, body.model_dump()) + + @router.get("/layers/{layer}/materials") def own_materials( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 455daa988..e59d594fb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -159,6 +159,27 @@ export const newLibrary = ( body: JSON.stringify(body), }); +/** 사전조합 나무 자유 폴더(158-1) — 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) */ +export interface PrecomboTree { + 판: string; + 폴더: string[][]; + 자리: Record; +} +export const fetchPrecomboTree = (layer: Layer, owner: string | null): Promise => + call(owned(layer, `/layers/${layer}/precombo-tree`, owner)); +export const editPrecomboTree = ( + layer: Layer, + edit: Pick & { + op: "add-folder" | "rename" | "move" | "delete"; + }, + 판: string, + owner: string | null, +): Promise => + call(owned(layer, `/layers/${layer}/precombo-tree`, owner), { + method: "POST", + body: JSON.stringify({ ...edit, 판 }), + }); + /* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) --- * 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index edfb56371..1c2655062 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -146,6 +146,11 @@ const TEXT = { Lib_None: ["사전조합 없음", "No pre-combination"], Lib_Unsorted: ["미분류", "Unsorted"], Lib_NewHere: ["여기에 새 사전조합", "New pre-combination here"], + Lib_FolderFull: [ + "폴더 안에 사전조합 {n}개 — 먼저 옮기거나 지운 뒤 지움", + "Folder holds {n} pre-combination(s) — move or delete them first", + ], + Lib_FolderDelete: ["「{value}」 빈 폴더를 지움", "Delete empty folder “{value}”"], Lib_Create: ["생성", "Create"], Lib_Copy: ["복사", "Copy"], Lib_Delete: ["삭제", "Delete"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index a109149ba..9bf42cb15 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -1,22 +1,42 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts * 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 · - * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무(구분 › 상세구분 2단 폴더 · 빈 구분 = 미분류). - * 폴더 = 분류 뼈대 고정(149-1 · 구조물군 › 공통 + 종류 · 빈 폴더도 · 문서 수 · 문서 든 폴더만 펼침) · - * 문서 옮김 = 상세 머리에서 분류 바꿈 · 폴더 오른쪽 클릭 [여기에 새 사전조합] = 그 분류로 생성. + * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무. + * 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — + * 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 사전조합] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽 + * 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김. + * 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `precomboClasses`) — 나무 폴더와 무관. * 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표 * (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄. * ========================================================================== */ -import { el, showToast } from "@ui/ui_template_elements"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { createTreeRow, renderTree, type TreeNode } from "@ui/ui_template_tree"; -import { listTemplates, newLibrary, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + createTreeRow, + openTreeMenu, + renderTree, + type TreeMenuItem, + type TreeNode, +} from "@ui/ui_template_tree"; +import { + StaleError, + editPrecomboTree, + fetchPrecomboTree, + listTemplates, + newLibrary, + type Kind, + type Layer, + type PrecomboTree, +} from "./M02_MasterTemplete_Api_Fetch"; import { nameDialog } from "./M02_MasterTemplete_Library_List"; -import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; import type { Root } from "./M02_MasterTemplete_Layer"; import type { StructDef } from "./StructOptions/M02_SO_Def_Model"; import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; +import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; +import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; export type PrecomboKind = Extract; @@ -83,52 +103,49 @@ interface Row { 상세구분?: string | null; } -type Classes = { name: string; details: string[] }[]; - -/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) · - * `classes`(149-1) = 분류 뼈대 먼저(그 차례 · 빈 묶음도) → 목록 밖 값은 뒤에 가나다 */ -export function groupRows( - rows: Row[], - unsorted: string, - classes: Classes = [], -): { 구분: string; 상세: Map }[] { - const out = new Map>( - classes.map((c) => [c.name, new Map(c.details.map((d) => [d, []]))]), - ); - for (const row of rows) { - const top = row.구분 || ""; - const sub = row.구분 ? row.상세구분 || "" : ""; - if (!out.has(top)) out.set(top, new Map()); - const subs = out.get(top)!; - subs.set(sub, [...(subs.get(sub) ?? []), row]); - } - const order = (a: string, b: string): number => - !a ? 1 : !b ? -1 : a.localeCompare(b, "ko-KR", { numeric: true }); - const rank = (list: string[], key: string): number => - list.includes(key) ? list.indexOf(key) : list.length; - const tops = classes.map((c) => c.name); - return [...out.keys()] - .sort((a, b) => rank(tops, a) - rank(tops, b) || order(a, b)) - .map((top) => { - const subs = classes.find((c) => c.name === top)?.details ?? []; - return { - 구분: top || unsorted, - 상세: new Map( - [...out.get(top)!].sort(([a], [b]) => rank(subs, a) - rank(subs, b) || order(a, b)), - ), - }; - }); +export interface PrecomboFolder { + path: string[]; + folders: PrecomboFolder[]; + docs: Row[]; } +/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ +export function buildPrecomboFolders( + rows: Row[], + view: Pick, +): PrecomboFolder { + const root: PrecomboFolder = { path: [], folders: [], docs: [] }; + const at = (path: string[]): PrecomboFolder => + path.reduce((node, name, i) => { + let next = node.folders.find((f) => f.path[i] === name); + if (!next) { + next = { path: path.slice(0, i + 1), folders: [], docs: [] }; + node.folders.push(next); + } + return next; + }, root); + view.폴더.forEach(at); + for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); + return root; +} + +/** 폴더 안 문서 수(아래 폴더까지) */ +export const docCount = (node: PrecomboFolder): number => + node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); + +type TreeBody = Parameters[1]; +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); + export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { const barHost = el("div"); const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } }); const leaves = new Map(); const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 + const targets = new Map(); let rows: Row[] = []; - let classes: Promise | null = null; // 분류 뼈대(구조물정의 · 한 번 받음 · 못 받으면 다음에 다시) - let shape: Classes = []; + let view: PrecomboTree | null = null; let seq = 0; + let busy = false; const open = async (row: Row): Promise => { const at = opt.scope(); @@ -139,85 +156,158 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, }); }; - const leaf = (row: Row): TreeNode => ({ - id: `doc:${row.이름}`, - label: row.제목 || row.이름, - tip: row.이름, - count: null, - run: () => void open(row), - }); - /** 폴더 메뉴 — [여기에 새 사전조합] = 그 구분 · 상세구분으로 생성 → 나무 다시 · 상세 엶 */ - const make = (구분: string, 상세구분: string): void => { + + /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(빈 폴더 아님 · 같은 이름) 그대로 */ + const edit = async (body: TreeBody): Promise => { + const at = opt.scope(); + if (!view || busy) return; + busy = true; + try { + view = await editPrecomboTree(at.layer, body, view.판, at.owner); + for (const path of [body.경로, body.새경로]) + path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); + draw(); + showToast(L("M02_TreeDone"), "success"); + } catch (error) { + if (error instanceof StaleError && error.message === "HTTP 409") { + await refresh(); + showToast(L("M02_TreeStale"), "error"); + } else { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + } finally { + busy = false; + } + }; + + /** [여기에 새 사전조합] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */ + const make = (path: string[]): void => { const at = opt.scope(); nameDialog(tc("Lib_NewHere"), "", async (name) => { - const 문서 = { 이름: name, 구분, 상세구분, 단위: null, 담은로직: [] }; + const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner); showToast(L("M02_Created"), "success"); await refresh(); + if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); await open({ 이름: got.이름, 종류: "precombo", 제목: name }); }); }; - const docs = (node: TreeNode): number => - node.run ? 1 : (node.children ?? []).reduce((n, c) => n + docs(c), 0); - const folder = ( - id: string, - label: string, - children: TreeNode[], - at: [string, string], - ): TreeNode => { - const count = children.reduce((n, c) => n + docs(c), 0); + const addFolder = async (parent: string[]): Promise => { + const got = await askText(L("M02_TreeAddFolder"), [ + { key: "name", label: L("M02_TreeFolderName") }, + ]); + if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); + }; + const rename = async (path: string[]): Promise => { + const was = path[path.length - 1]; + const got = await askText(L("M02_TreeRename"), [ + { key: "name", label: L("M02_TreeFolderName"), value: was }, + ]); + if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); + }; + /** 지우기 — 빈 폴더만(안에 문서가 있으면 알림만) */ + const remove = async (path: string[], count: number): Promise => { + if (count) return void showToast(tc("Lib_FolderFull").replace("{n}", String(count)), "error"); + const ask = tc("Lib_FolderDelete").replace("{value}", path[path.length - 1]); + if (await showConfirmDialog(ask, L("M02_Delete"))) await edit({ op: "delete", 경로: path }); + }; + + const menuAt = (path: string[], count: number): TreeMenuItem[] => { + const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); + const item = (label: string, run: () => void): TreeMenuItem => ({ + label, + run, + disabledReason: off, + }); + return [ + item(L("M02_TreeAddFolder"), () => void addFolder(path)), + item(tc("Lib_NewHere"), () => make(path)), + ...(path.length + ? [ + item(L("M02_TreeRename"), () => void rename(path)), + item(L("M02_Delete"), () => void remove(path, count)), + ] + : []), + ]; + }; + + const leaf = (row: Row, folder: string[]): TreeNode => { + const id = `doc:${row.이름}`; + const label = row.제목 || row.이름; + const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; + targets.set(id, { kind: "item", item }); + return { id, label, tip: row.이름, count: null, run: () => void open(row) }; + }; + const folderNode = (node: PrecomboFolder): TreeNode => { + const id = folderIdOf(node.path); + const count = docCount(node); + targets.set(id, { kind: "folder", path: node.path }); return { id, - label, + label: node.path[node.path.length - 1], count, folder: true, open: toggled.get(id) ?? count > 0, - children, - menu: () => [ - { - label: tc("Lib_NewHere"), - run: () => make(...at), - disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"), - }, - ], + children: kids(node), + menu: () => menuAt(node.path, count), }; }; + const kids = (node: PrecomboFolder): TreeNode[] => [ + ...node.folders.map(folderNode), + ...node.docs.map((row) => leaf(row, node.path)), + ]; + + /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 · 제자리면 안 함 */ + const drag = attachTreeDrag({ + body: treeHost, + targetOf: (id) => targets.get(id), + listFor: (id) => (targets.has(id) ? [targets.get(id)!] : null), + move: ([t], dest) => { + const from = t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); + if (samePath(from, dest)) return; + void edit( + t.kind === "item" + ? { op: "move", 키: t.item.key, 새경로: dest } + : { op: "move", 경로: t.path, 새경로: dest }, + ); + }, + }); const draw = (): void => { leaves.clear(); - if (!rows.length && !shape.length) + targets.clear(); + const tree = buildPrecomboFolders(rows, view ?? { 폴더: [], 자리: {} }); + if (!tree.docs.length && !tree.folders.length) return void treeHost.replaceChildren( el("p", { className: "m02-side__empty", text: tc("Lib_None") }), ); - const unsorted = tc("Lib_Unsorted"); - const nodes = groupRows(rows, unsorted, shape).map((g) => { - const top = g.구분 === unsorted ? "" : g.구분; - return folder( - `sub:${g.구분}`, - g.구분, - [...g.상세].flatMap(([sub, list]) => - sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf), [top, sub])] : list.map(leaf), - ), - [top, top ? ([...g.상세.keys()][0] ?? "") : ""], - ); - }); + const canWrite = opt.scope().canWrite; treeHost.replaceChildren( - ...renderTree(nodes, (node, caret, depth, onClick) => { + ...renderTree(kids(tree), (node, caret, depth, onClick) => { const row = createTreeRow(node, caret, depth, () => { onClick(); if (!node.children?.length) return; toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); }); + row.dataset.node = node.id; if (node.run) { row.dataset.precomboKey = node.id.slice(4); leaves.set(node.id.slice(4), row); } + if (canWrite) drag.arm(row, node.id); return row; }), ); paint(); }; + // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 사전조합) + treeHost.addEventListener("contextmenu", (event) => { + if (!view || (event.target as HTMLElement).closest("[data-node]")) return; + event.preventDefault(); + openTreeMenu(event, menuAt([], 0)); + }); + const paint = (): void => { const cur = opt.getOpen(); const at = opt.scope(); @@ -231,20 +321,17 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { const at = opt.scope(); barHost.replaceChildren(opt.bar()); treeHost.replaceChildren(el("p", { className: "m02-side__empty", text: tc("Loading") })); - const shapeAt = (classes ??= fetchStructureDef().then(precomboClasses, () => { - classes = null; - return []; // 못 받으면 문서 값만으로 묶음(지난 모양) - })); - let got: Row[] = []; - try { - got = (await listTemplates(at.layer, null, at.owner)).filter((r) => r.종류 === "precombo"); - } catch { - got = []; // 못 받으면 빈 나무(목록표에서 까닭) - } - const cls = await shapeAt; + const [got, tree] = await Promise.all([ + // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 + listTemplates(at.layer, null, at.owner).then( + (list): Row[] => list.filter((r) => r.종류 === "precombo"), + (): Row[] => [], + ), + fetchPrecomboTree(at.layer, at.owner).catch(() => null), + ]); if (mine !== seq) return; rows = got; - shape = cls; + view = tree; draw(); }; diff --git a/M02_MasterTemplete/M02_Precombo_Folders.py b/M02_MasterTemplete/M02_Precombo_Folders.py new file mode 100644 index 000000000..110768d11 --- /dev/null +++ b/M02_MasterTemplete/M02_Precombo_Folders.py @@ -0,0 +1,136 @@ +"""M02 일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1) — 구조물 조합 나무와 같은 꼴(깊이 자유). + +자리 = 층 뿌리 `precombo_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — +층(마스터 · 회사 · 개인)마다 따로. 문서 파일(`{층}/precombo/SC-######.json`)은 안 옮김 · +폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · +문서 옮기기)은 판이 다르면 409 · 지우기는 빈 폴더만(안에 문서가 있으면 409). +이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌. +""" + +from __future__ import annotations + +import json +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree + +FILE = "precombo_폴더.json" +OPS = ("add-folder", "rename", "move", "delete") + + +def _keys() -> set[str]: + """지금 층에 있는 사전조합 키(파일 이름).""" + return {p.stem for p in (store.root() / "precombo").glob("[!._]*.json")} + + +def _read() -> tuple[dict[str, Any], str]: + path = store.root() / FILE + if not path.is_file(): + return {"폴더": [], "자리": {}}, "" + raw = path.read_bytes() + return json.loads(raw), store.version_of(raw) + + +def _folders(doc: dict[str, Any], keys: set[str]) -> list[list[str]]: + """폴더 전부 — 적어 둔 폴더 + 있는 문서 자리의 앞마디(빈 경로 뺌 · 겹침 뺌).""" + out: list[list[str]] = [] + paths = [tree._clean(p) for p in doc.get("폴더") or []] + paths += [tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys] + for path in paths: + for n in range(1, len(path) + 1): + if path[:n] not in out: + out.append(path[:n]) + return out + + +def view_of(doc: dict[str, Any], 판: str, keys: set[str]) -> dict[str, Any]: + places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + return {"판": 판, "폴더": _folders(doc, keys), "자리": {k: p for k, p in places.items() if p}} + + +def view() -> dict[str, Any]: + doc, 판 = _read() + return view_of(doc, 판, _keys()) + + +def _shift(path: list[str], old: list[str], new: list[str]) -> list[str]: + return [*new, *path[len(old) :]] if tree._under(path, old) else path + + +def apply( + doc: dict[str, Any], body: dict[str, Any], keys: set[str] +) -> tuple[dict[str, Any], dict[str, Any]]: + """고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장은 `edit`).""" + op = body.get("op") + if op not in OPS: + raise tree.TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}") + folders = _folders(doc, keys) + places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + where = tree._path(body.get("경로")) + key = str(body.get("키") or "") + note: dict[str, Any] = {"op": op} + + def need(path: list[str], what: str) -> None: + if path and path not in folders: + raise tree.TreeError(404, f"없는 폴더 「{' / '.join(path)}」") + if not path and what: + raise tree.TreeError(422, f"{what} 폴더 「경로」 가 없음") + + if op == "add-folder": + need(where, "") + made = [*where, tree._name(body.get("이름"))] + if made in folders: + raise tree.TreeError(409, f"이미 있는 폴더 「{' / '.join(made)}」") + folders.append(made) + note["경로"] = made + elif op == "move" and key: + if key not in keys: + raise tree.TreeError(404, f"없는 사전조합 「{key}」") + to = tree._path(body.get("새경로"), "새경로") + need(to, "") + places[key] = to + note["경로"] = to + elif op in ("rename", "move"): + need(where, "바꿀" if op == "rename" else "옮길") + if op == "rename": + new = [*where[:-1], tree._name(body.get("이름"))] + if new != where and new in folders: + raise tree.TreeError(409, f"이미 있는 폴더 「{' / '.join(new)}」") + else: + to = tree._path(body.get("새경로"), "새경로") + need(to, "") + if tree._under(to, where): + raise tree.TreeError(422, "폴더를 제 안으로 옮길 수 없음") + new = tree.unique_path([*to, where[-1]], lambda p: p != where and p in folders) + if new[-1] != where[-1]: + note["바뀐이름"] = [new[-1]] + folders = [_shift(f, where, new) for f in folders] + places = {k: _shift(p, where, new) for k, p in places.items()} + note["경로"] = new + else: # delete — 빈 폴더만 + need(where, "지울") + inside = sorted(k for k, p in places.items() if tree._under(p, where)) + if inside: + raise tree.TreeError(409, f"폴더 안에 사전조합 {len(inside)}개 — 먼저 옮기거나 지움") + folders = [f for f in folders if not tree._under(f, where)] + note["경로"] = where + out = {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}} + return out, note + + +# ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임 +# (키 재사용을 막을 때 함께 고침) +def edit(body: dict[str, Any]) -> dict[str, Any]: + """지금 층 자리 파일을 고쳐 씀 → 새 나무 + 알림 · 판 다르면 409.""" + doc, 판 = _read() + if 판 != str(body.get("판") or ""): + raise store.StoreError(409, {"stale": [FILE], "판": 판}) + keys = _keys() + try: + out, note = apply(doc, body, keys) + except tree.TreeError as e: + raise store.StoreError(e.status, e.detail) from e + atomic_write_json(store.root() / FILE, out) + return {**view(), "알림": note} diff --git a/resources/tester/helper_m02_precombo_scope.cjs b/resources/tester/helper_m02_precombo_scope.cjs index e9b06ac38..71261dffc 100644 --- a/resources/tester/helper_m02_precombo_scope.cjs +++ b/resources/tester/helper_m02_precombo_scope.cjs @@ -1,7 +1,7 @@ /* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 · - * `M02_MasterTemplete_UI_Side_Precombo.ts` `groupRows`(구분 › 상세구분 2단) 를 트랜스파일해 Node 로 돌림 · + * `M02_MasterTemplete_UI_Side_Precombo.ts` `buildPrecomboFolders`(158-1 자유 폴더) 를 트랜스파일해 Node 로 돌림 · * `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 · - * `classes`(149-1 분류 뼈대) · stdin = {rows, def?, classes?} · stdout = {urls, groups, classes} JSON. */ + * stdin = {rows, def?, view?} · stdout = {urls, tree, classes} JSON. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -45,10 +45,13 @@ global.fetch = async (url) => { await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인 await api.listTemplates("company", null, "8"); // 사전조합 = 볼 회사 await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null); - const groups = side.groupRows(input.rows, "미분류", input.classes ?? []).map((g) => ({ - 구분: g.구분, - 상세: [...g.상세].map(([k, v]) => [k, v.map((r) => r.이름)]), - })); + const shape = (f) => ({ + path: f.path, + count: side.docCount(f), + docs: f.docs.map((r) => r.이름), + folders: f.folders.map(shape), + }); + const tree = shape(side.buildPrecomboFolders(input.rows, input.view ?? { 폴더: [], 자리: {} })); const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5 - process.stdout.write(JSON.stringify({ urls, groups, classes })); + process.stdout.write(JSON.stringify({ urls, tree, classes })); })(); diff --git a/resources/tester/test_m02_precombo_folders.py b/resources/tester/test_m02_precombo_folders.py new file mode 100644 index 000000000..3c19a088f --- /dev/null +++ b/resources/tester/test_m02_precombo_folders.py @@ -0,0 +1,155 @@ +"""일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1 · `M02_Precombo_Folders`) — 임시 storage. + +새 폴더(깊이 자유) · 문서 옮기기(파일은 제자리 · 자리만) · 폴더 이름 바꾸기 · +옮기기(아래 자리 따라감) · 지우기(빈 폴더만 · 문서 있으면 409 까닭) · 판 409 · +층(마스터 · 회사 · 개인)마다 따로 · 문서 수(양식 목록) 그대로 · 쓰기 권한 = 층 규칙(마스터 403). +""" + +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_Labor_Router as labor_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Precombo_Folders as pre +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TREE = "/api/m02/layers/{}/precombo-tree" + + +@pytest.fixture() +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + app = FastAPI() + app.include_router(labor_router.router) + app.include_router(layers_router.router) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, + "company_id": 7, + "role": "USER", + "is_master": False, + } + got = TestClient(app) + for name in ("가", "나", "다"): # 개인 층 문서 셋 — SC-000001~3 + body = {"문서": {"이름": name, "구분": "C 사면안정", "상세구분": "옹벽", "담은로직": []}} + assert got.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200 + return got + + +class Tree: + def __init__(self, client: TestClient, layer: str = "personal"): + self.client, self.url = client, TREE.format(layer) + self.now = client.get(self.url).json() + + def do(self, want: int = 200, **body) -> dict: + res = self.client.post(self.url, json={"판": self.now["판"], **body}) + assert res.status_code == want, res.text + if want == 200: + self.now = res.json() + assert self.now == {**self.client.get(self.url).json(), "알림": self.now["알림"]} + return res.json() + + +def _docs(client: TestClient, layer: str = "personal") -> list[str]: + rows = client.get(f"/api/m02/layers/{layer}/templates").json()["양식"] + return [r["이름"] for r in rows if r["종류"] == "precombo"] + + +def test_처음은_모두_뿌리_새_폴더_깊이_자유(client: TestClient): + t = Tree(client) + assert t.now == {"판": "", "폴더": [], "자리": {}} # 문서 셋 = 뿌리 + t.do(op="add-folder", 경로=[], 이름="옹벽") + t.do(op="add-folder", 경로=["옹벽"], 이름="찰쌓기") + t.do(op="add-folder", 경로=["옹벽", "찰쌓기"], 이름="높이 3m") + assert t.now["폴더"] == [["옹벽"], ["옹벽", "찰쌓기"], ["옹벽", "찰쌓기", "높이 3m"]] + assert t.do(409, op="add-folder", 경로=[], 이름="옹벽")["detail"].startswith("이미 있는 폴더") + assert t.do(404, op="add-folder", 경로=["없음"], 이름="x")["detail"].startswith("없는 폴더") + assert t.do(422, op="add-folder", 경로=[], 이름=" ") # 빈 이름 + + +def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: TestClient, tmp_path: Path): + t = Tree(client) + t.do(op="add-folder", 경로=[], 이름="옹벽") + t.do(op="add-folder", 경로=["옹벽"], 이름="찰쌓기") + t.do(op="move", 키="SC-000001", 새경로=["옹벽", "찰쌓기"]) + t.do(op="move", 키="SC-000002", 새경로=["옹벽"]) + assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"], "SC-000002": ["옹벽"]} + t.do(op="move", 키="SC-000002", 새경로=[]) # 뿌리로 = 자리 지움 + assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"]} + assert t.do(404, op="move", 키="SC-000009", 새경로=[])["detail"].startswith("없는 사전조합") + assert t.do(404, op="move", 키="SC-000001", 새경로=["없음"]) + personal = layers.personal_dir(7, 42) + assert sorted(p.name for p in (personal / "precombo").iterdir()) == [ + "SC-000001.json", + "SC-000002.json", + "SC-000003.json", + ] + assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] # 자리 파일은 목록에 안 섞임 + assert (personal / pre.FILE).is_file() + + +def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: TestClient): + t = Tree(client) + for parent, name in (([], "가"), (["가"], "나"), ([], "다")): + t.do(op="add-folder", 경로=parent, 이름=name) + t.do(op="move", 키="SC-000001", 새경로=["가", "나"]) + t.do(op="rename", 경로=["가"], 이름="라") + assert t.now["폴더"] == [["라"], ["라", "나"], ["다"]] + assert t.now["자리"] == {"SC-000001": ["라", "나"]} + assert t.do(409, op="rename", 경로=["라"], 이름="다") + t.do(op="move", 경로=["라", "나"], 새경로=["다"]) + assert t.now["자리"] == {"SC-000001": ["다", "나"]} and ["라", "나"] not in t.now["폴더"] + assert t.do(422, op="move", 경로=["다"], 새경로=["다", "나"])["detail"].startswith("폴더를 제") + t.do(op="add-folder", 경로=[], 이름="나") + t.do(op="move", 경로=["다", "나"], 새경로=[]) # 같은 이름 = 「나(1)」 + assert t.now["알림"]["바뀐이름"] == ["나(1)"] and t.now["자리"]["SC-000001"] == ["나(1)"] + + +def test_지우기는_빈_폴더만_문서가_있으면_까닭(client: TestClient): + t = Tree(client) + t.do(op="add-folder", 경로=[], 이름="가") + t.do(op="add-folder", 경로=["가"], 이름="나") + t.do(op="add-folder", 경로=["가", "나"], 이름="다") + t.do(op="move", 키="SC-000003", 새경로=["가", "나", "다"]) + assert "사전조합 1개" in t.do(409, op="delete", 경로=["가"])["detail"] + t.do(op="move", 키="SC-000003", 새경로=[]) + t.do(op="delete", 경로=["가"]) # 빈 아래 폴더까지 + assert t.now["폴더"] == [] and t.now["자리"] == {} + assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] + + +def test_판이_다르면_409_고침_안_함(client: TestClient): + t = Tree(client) + t.do(op="add-folder", 경로=[], 이름="가") + old = t.now["판"] + t.do(op="add-folder", 경로=[], 이름="나") + res = client.post(TREE.format("personal"), json={"판": old, "op": "delete", "경로": ["가"]}) + assert res.status_code == 409 and ["가"] in client.get(TREE.format("personal")).json()["폴더"] + + +def test_층마다_따로_마스터_쓰기는_층_규칙(client: TestClient): + Tree(client).do(op="add-folder", 경로=[], 이름="내 폴더") + company = Tree(client, "company") + assert company.now["폴더"] == [] and Tree(client, "system").now["폴더"] == [] + assert Tree(client, "system").do(403, op="add-folder", 경로=[], 이름="x") + assert client.get(TREE.format("personal")).json()["폴더"] == [["내 폴더"]] + + +def test_지운_문서_자리는_안_보임(): + doc = {"폴더": [["가"]], "자리": {"SC-000001": ["가"], "SC-000002": ["가", "나"]}} + got = pre.view_of(doc, "v", {"SC-000001"}) + assert got == {"판": "v", "폴더": [["가"]], "자리": {"SC-000001": ["가"]}} + out, _ = pre.apply(doc, {"op": "add-folder", "경로": [], "이름": "다"}, {"SC-000001"}) + assert out["자리"] == {"SC-000001": ["가"]} # 고칠 때 걷힘 diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index c41363ee6..77a4ce3db 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -1,8 +1,8 @@ -"""일위대가 사전조합 층 따로(PLAN 134-3) — 페이지 층과 따로 · 명시 owner · 2단 폴더 나무. +"""일위대가 사전조합 층 따로(PLAN 134-3) — 페이지 층과 따로 · 명시 owner · 자유 폴더 나무(158-1). - 서버 길: 명시 owner 를 넘기면 그 주인만(null = 자기 층) — 남의 개인을 보는 중에도 사전조합 길에 안 섞임 · 안 넘기면 지금처럼 페이지 층 주인(Node · fetch 대역) -- 나무: 구분 › 상세구분 2단 · 빈 구분 = 미분류 맨 뒤 · 빈 상세구분 = 구분 바로 아래 +- 나무: 자유 폴더(158-1) — 적어 둔 폴더 차례 · 자리 없는 문서 = 뿌리 · 문서 수 = 아래 폴더까지 - 사전조합 층 단추 = 해시를 바꿔 페이지를 다시 세우지 않음(replaceState · 그 컨테이너만 다시 그림) · 구조물 조합 층 단추는 그대로 해시 이동 · 주소 `pre` 에 남음 · 고름에 owner · canWrite """ @@ -20,10 +20,10 @@ def _text(name: str) -> str: return (M02 / name).read_text(encoding="utf-8") -def _run(rows, classes=None): +def _run(rows, view=None): proc = subprocess.run( ["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")], - input=json.dumps({"rows": rows, "classes": classes}, ensure_ascii=False), + input=json.dumps({"rows": rows, "view": view}, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", @@ -40,19 +40,30 @@ def test_명시_owner_길은_페이지_층_주인과_안_섞임(): assert urls[3] == "/api/m02/layers/system/templates/precombo/SC-000002" -def test_나무는_구분_상세구분_2단_미분류는_맨_뒤(): +def test_나무는_자유_폴더_자리_없는_문서는_뿌리(): rows = [ - {"이름": "SC-000003", "종류": "precombo", "구분": "C 사면안정", "상세구분": "옹벽"}, - {"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": "07장"}, - {"이름": "SC-000002", "종류": "precombo", "구분": "A 횡단배수", "상세구분": ""}, - {"이름": "SC-000004", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"}, - {"이름": "SC-000005", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "공통"}, + {"이름": "SC-000001", "종류": "precombo", "구분": "C 사면안정", "상세구분": "옹벽"}, + {"이름": "SC-000002", "종류": "precombo"}, + {"이름": "SC-000003", "종류": "precombo"}, ] - groups = _run(rows)["groups"] - assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "미분류"] - assert groups[0]["상세"] == [["공통", ["SC-000005"]], ["", ["SC-000002"]]] - assert groups[1]["상세"] == [["돌쌓기", ["SC-000004"]], ["옹벽", ["SC-000003"]]] - assert groups[2]["상세"] == [["", ["SC-000001"]]] # 구분 없으면 상세구분도 안 봄 + view = { + "폴더": [["옹벽"], ["옹벽", "찰쌓기"], ["빈 폴더"]], + "자리": {"SC-000001": ["옹벽", "찰쌓기"], "SC-000009": ["옹벽"]}, # 없는 문서 자리 = 무시 + } + tree = _run(rows, view)["tree"] + assert tree["docs"] == ["SC-000002", "SC-000003"] and tree["count"] == 3 # 구분과 무관 + wall, empty = tree["folders"] + assert (wall["path"], wall["count"], wall["docs"]) == (["옹벽"], 1, []) + assert wall["folders"][0] == { + "path": ["옹벽", "찰쌓기"], + "count": 1, + "docs": ["SC-000001"], + "folders": [], + } + assert (empty["path"], empty["count"]) == (["빈 폴더"], 0) + # 폴더를 못 받으면(빈 자리) 모두 뿌리 · 폴더 없음 + flat = _run(rows)["tree"] + assert flat["docs"] == ["SC-000001", "SC-000002", "SC-000003"] and flat["folders"] == [] def test_사전조합_층_단추는_페이지를_다시_안_세움(): @@ -88,46 +99,25 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): assert "opt.owner !== undefined" in detail -def test_분류_뼈대_폴더_고정_빈_폴더도_목록_밖_값_뒤_미분류_맨_뒤(): - """149-1 — 분류 차례대로 빈 폴더도 · 목록 밖 지금 값은 뒤 · 미분류는 문서 있을 때만 맨 뒤""" - classes = [ - {"name": "A 횡단배수", "details": ["공통", "배관", "BOX암거"]}, - {"name": "C 사면안정", "details": ["공통", "돌쌓기"]}, - ] - rows = [ - {"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": ""}, - {"이름": "SC-000002", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"}, - {"이름": "SC-000003", "종류": "precombo", "구분": "건설품셈 공통", "상세구분": "07장"}, - {"이름": "SC-000004", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "옛 종류"}, - ] - groups = _run(rows, classes)["groups"] - assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "건설품셈 공통", "미분류"] - assert groups[0]["상세"] == [ - ["공통", []], - ["배관", []], - ["BOX암거", []], - ["옛 종류", ["SC-000004"]], - ] - assert groups[1]["상세"] == [["공통", []], ["돌쌓기", ["SC-000002"]]] - assert groups[3]["상세"] == [["", ["SC-000001"]]] - # 문서 없으면 미분류 폴더 없음 · 뼈대는 그대로 - empty = _run([], classes)["groups"] - assert [g["구분"] for g in empty] == ["A 횡단배수", "C 사면안정"] - - -def test_나무_폴더_문서수_처음_펼침_폴더_메뉴_생성(): +def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기(): side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") - assert "groupRows(rows, unsorted, shape)" in side - assert "fetchStructureDef().then(precomboClasses" in side - assert "const count = children.reduce((n, c) => n + docs(c), 0);" in side # 수 = 문서 수 - assert "open: toggled.get(id) ?? count > 0," in side # 문서 든 폴더만 펼침 - assert "if (!rows.length && !shape.length)" in side # 뼈대 있으면 「없음」 아님 - assert 'label: tc("Lib_NewHere")' in side and "run: () => make(...at)" in side - assert 'disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly")' in side - head = "const make = (구분: string, 상세구분: string): void => {" - make = side.split(head)[1].split("\n };")[0] - assert "const 문서 = { 이름: name, 구분, 상세구분, 단위: null, 담은로직: [] };" in make - assert 'newLibrary(at.layer, "precombo", { 문서 }, at.owner)' in make + for gone in ("groupRows", "fetchStructureDef", "Lib_Unsorted", "shape"): + assert gone not in side, gone + assert "fetchPrecomboTree(at.layer, at.owner)" in side + assert "editPrecomboTree(at.layer, body, view.판, at.owner)" in side + menu = side.split("const menuAt = (")[1].split("\n };")[0] + for label in ('L("M02_TreeAddFolder")', 'tc("Lib_NewHere")', 'L("M02_TreeRename")'): + assert label in menu, label + assert 'L("M02_Delete"), () => void remove(path, count)' in menu + assert 'opt.scope().canWrite ? undefined : tc("ReadOnly")' in menu # 읽기만 층 = 흐림 + remove = side.split("const remove = async (")[1].split("\n };")[0] + assert 'if (count) return void showToast(tc("Lib_FolderFull")' in remove # 빈 폴더만 + make = side.split("const make = (path: string[]): void => {")[1].split("\n };")[0] + assert 'const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };' in make + assert 'if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path });' in make + assert "attachTreeDrag({" in side and "if (canWrite) drag.arm(row, node.id);" in side + assert "openTreeMenu(event, menuAt([], 0));" in side # 빈 자리 = 뿌리 메뉴 + assert "open: toggled.get(id) ?? count > 0," in side lib = _text("M02_MasterTemplete_Library_List.ts") assert "export const nameDialog = (" in lib