diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py new file mode 100644 index 00000000..85b5d499 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py @@ -0,0 +1,98 @@ +"""일위대가 조합 층 사이 복사(PLAN 30-3) — 읽을 수 있는 층의 조합 · 폴더를 쓸 수 있는 층에 붙여넣기. + +정함(30장): 복사본 = 대상 층의 새 키(마스터 UA · 회사 UC · 개인 UP) · 본뜬키 = 원본 키 · +같은 이름이면 「이름 (2)」 · 담은 로직 그대로 · 폴더 복사는 안의 조합 · 빈 폴더까지. +붙는 자리 = `경로`(대상 폴더 · 깊이 2 안) — 고른 조합은 그 폴더에 · 고른 폴더는 그 아래 한 단으로. +마스터에 붙이면 M01 저장 길(대장 UA) · 본뜬키는 UA 만(마스터 틀) — 다른 층 원본이면 비움. +권한은 라우터 몫(원본 층 읽기 · 대상 층 쓰기) · 대상 층 = `store.at(대상)`. +""" + +from __future__ import annotations + +from typing import Any + +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from M02_MasterTemplete import M02_MasterTemplete_Store as store + + +def _free_name(name: str, taken: set[str]) -> str: + out, n = name, 2 + while out in taken: + out, n = f"{name} ({n})", n + 1 + taken.add(out) + return out + + +def _paths(value: Any, what: str) -> list[list[str]]: + if not isinstance(value, list) or not all(isinstance(p, list) for p in value): + raise store.StoreError(422, f"「{what}」 이 폴더 경로 목록이 아님") + return [[str(x) for x in p] for p in value] + + +def picked(source: dict, keys: list[str], folders: list[list[str]], where: list[str]): + """(붙일 조합 [(원본 줄, 새 경로)], 새 빈 폴더) — 깊이 2 넘으면 422 · 없는 키 · 폴더 404.""" + have = combos.folders_of(source) + rows = {str(r.get("키")): r for r in source.get("줄") or []} + out: dict[str, tuple[dict, list[str]]] = {} + made: list[list[str]] = [] + for folder in folders: + if folder not in have: + raise store.StoreError(404, f"없는 폴더 「{' › '.join(folder)}」") + cut = len(folder) - 1 # 고른 폴더부터 대상 아래로 + made += [where + f[cut:] for f in have if f[: len(folder)] == folder] + for key, row in rows.items(): + path = combos.path_of(row) + if path[: len(folder)] == folder: + out[key] = (row, where + path[cut:]) + for key in keys: + if key not in rows: + raise store.StoreError(404, f"없는 조합 「{key}」") + out.setdefault(key, (rows[key], list(where))) + if any(len(p) > combos.DEPTH for p in [*made, *(p for _, p in out.values())]): + raise store.StoreError(422, f"폴더는 {combos.DEPTH} 단까지 — 더 얕은 폴더에 붙여 넣음") + return list(out.values()), made + + +def paste(source: dict, body: dict) -> dict[str, Any]: + """`source` = 원본 층 표(부르는 쪽이 원본 층에서 읽음) · `body` = `{키?, 폴더?, 경로?, 판}` → + `{…대상 층 목록, 새키: [원본 → 새 키]}`.""" + keys = [str(k) for k in body.get("키") or []] + folders = _paths(body.get("폴더") or [], "폴더") + where = [str(x) for x in body.get("경로") or []] + if not keys and not folders: + raise store.StoreError(422, "붙여 넣을 조합 · 폴더가 없음") + doc, have = combos._load() + if have != (body.get("판") or ""): + raise store.StoreError(409, {"stale": [combos.FILE], "판": have}) + if where and where not in combos.folders_of(doc): + raise store.StoreError(404, f"없는 폴더 「{' › '.join(where)}」") + items, made = picked(source, keys, folders, where) + taken = {str(r.get("이름") or "").strip() for r in doc["줄"]} + master = combos.heads()[-1] == combos.MASTER + rows = [] + for row, path in items: + one = {k: row.get(k) for k in combos.mcb.COLS if k != "키"} + one["이름"] = _free_name(str(row.get("이름") or "").strip(), taken) + one["본뜬키"] = str(row.get("키")) + if master and not one["본뜬키"].startswith(combos.MASTER): + one["본뜬키"] = None # 마스터 틀은 UA 계보만 + combos._set_path(one, path) + rows.append(one) + folders_after = combos.folders_of({**doc, "폴더": [*combos.folders_of(doc), *made]}) + if master: + new = [combos.create(one)["키"] for one in rows] # M01 저장 길(검사 · 대장) — 하나씩 + _, version = combos._load() + combos._write_master_folders(folders_after, version) + else: + number = int(doc.get("다음") or 1) + fixed = [] + for at, one in enumerate(rows): + fixed.append(combos._clean(one, f"{combos.heads()[-1]}{number + at:06d}")) + new_doc = {**doc, "다음": number + len(fixed), "폴더": folders_after} + new_doc["줄"] = [*doc["줄"], *fixed] + for one in fixed: + combos._check(new_doc, one) + combos._save(new_doc, have) + new = [one["키"] for one in fixed] + pairs = [{"원본": str(row.get("키")), "새키": key} for (row, _), key in zip(items, new)] + return {**combos.listing(), "새키": pairs} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 042c76e5..52a84654 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -367,25 +367,44 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: def edit_tree(body: dict) -> dict[str, Any]: """폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · 조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422.""" + return edit_tree_many({"판": body.get("판"), "ops": [body]}) + + +def edit_tree_many(body: dict) -> dict[str, Any]: + """나무 고침 여럿을 한 번에(PLAN 30-3 · 여러 개 옮기기 · 지우기) — `{판, ops: [고침 …]}` · + 차례대로 적용 · 하나라도 막히면 아무것도 안 씀 · 저장 한 번.""" doc, have = _load() if have != (body.get("판") or ""): raise store.StoreError(409, {"stale": [FILE], "판": have}) - new, gone = _apply(doc, body) + ops = body.get("ops") + if not isinstance(ops, list) or not ops: + raise store.StoreError(422, "나무 고침 목록(ops)이 비었음") + new, gone = doc, [] + for op in ops: + new, got = _apply(new, op if isinstance(op, dict) else {}) + gone += got _refuse_used(gone) + renamed = [str(op["키"]) for op in ops if op.get("op") == "rename" and op.get("키")] + commit(doc, have, new, gone, renamed) + return {**listing(), "지운조합": gone} + + +def commit(doc: dict, have: str, new: dict, gone: list[str], checked: list[str] = ()) -> None: + """고친 표 한 벌 저장 — 회사 · 개인 = 파일 한 번(`checked` 조합은 이름 검사) · + 마스터 = 줄은 M01 저장 길 · 머리 `폴더` 따로.""" if heads()[-1] != MASTER: - if body.get("op") == "rename" and body.get("키"): - one = _find(new, str(body["키"])) - if errors := _name_errors(new, one): - raise store.StoreError(422, {"errors": errors}) + for key in checked: + if key in {str(r.get("키")) for r in new["줄"]}: + if errors := _name_errors(new, _find(new, key)): + raise store.StoreError(422, {"errors": errors}) _save(new, have) - return {**listing(), "지운조합": gone} + return was = {str(r["키"]): r for r in doc["줄"]} changes = [{"op": "delete", "key": k} for k in gone] + [ {"op": "edit", "key": str(r["키"]), "row": r} for r in new["줄"] if was[str(r["키"])] != r ] version = _m01(combo_store._write, changes, have)["version"] if changes else have _write_master_folders(new["폴더"], version) - return {**listing(), "지운조합": gone} def _write_master_folders(folders: list[list[str]], version: str) -> None: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 9e31fece..f6791ee2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -12,3 +12,12 @@ .m01-combo .m01-logic__muted { color: var(--color-text-secondary); } + +/* 조합 목록 — 공용 표 모양(줄 선 · 머리 줄) · 줄을 누르면 상세(PLAN 30-5) */ +.m02-labor__list tbody tr { + cursor: pointer; +} + +.m02-labor__list tbody tr:hover { + background: var(--color-mist-violet); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts index b4df0763..fbec69a5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts @@ -11,7 +11,9 @@ import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; import { editComboTree, + editComboTreeMany, fetchComboList, + pasteCombos, type ComboList, type ComboTreeEdit, type LaborScope, @@ -33,6 +35,8 @@ function treeError(error: unknown): Error { export interface LaborLibraryOptions { scope: LaborScope; canEdit: boolean; + /** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */ + copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[]; /** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */ reveal: () => Promise; } @@ -47,6 +51,8 @@ export interface LaborLibrary { /** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */ back: () => Promise; refresh: () => Promise; + /** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */ + tools: HTMLElement | null; /** 메인 칸 「저장 안 한 고침」 손잡이 */ bind: (setDirty: (dirty: boolean) => void) => void; } @@ -86,7 +92,36 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { }); return toView(took(got), noteOf(edit, got.지운조합)); }, + // 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장 + many: async (_layer, body, 판) => { + const ops = body.대상.map((ref) => ({ + op: body.op, + ...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }), + ...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}), + })); + const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => { + throw treeError(error); + }); + return toView(took(got), { op: body.op, 지운항목: got.지운조합 }); + }, + // 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로 + paste: async (layer, owner, clip, dest) => { + const to = { layer, owner } as LaborScope; + const pick = { + 키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])), + 폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])), + }; + const got = await fetchComboList(to) + .then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)) + .catch((error: unknown) => { + throw treeError(error); + }); + const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null); + return toView(took(here ? got : await fetchComboList(scope))); + }, }, + clipKind: "일위대가", + copyTo: opt.copyTo, onPick: async (item) => { await opt.reveal(); tree.select(item.key); @@ -124,13 +159,14 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { editor.showList(); }, refresh: () => tree.refresh(), + tools: editor.newButton, bind: (fn) => (setDirty = fn), }; } export interface LaborSectionOptions { /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ - show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void; /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ confirmLeave: () => Promise; /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ @@ -160,7 +196,7 @@ export function buildLaborSection(opt: LaborSectionOptions): LaborSection { async function open(): Promise { if (opened || !(await opt.confirmLeave())) return; - library.bind(opt.show(tc("Library"), library.root)); + library.bind(opt.show(tc("Library"), library.root, library.tools)); opt.onOpen(); mark(true); await library.refresh(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index fd3cb672..4dfb87e8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -203,6 +203,30 @@ export const editComboTree = ( scope: LaborScope = SYSTEM_SCOPE, ): Promise => request(at(scope, "/tree"), "POST", body); +/** 여러 개 옮기기 · 지우기(PLAN 30-3) — 나무 고침 여럿을 차례대로 · 저장 한 번 */ +export const editComboTreeMany = ( + ops: Omit[], + 판: string, + scope: LaborScope = SYSTEM_SCOPE, +): Promise => + request(at(scope, "/tree-many"), "POST", { 판, ops }); + +/** 층 사이 복사(PLAN 30-3) — `from` 층의 조합 · 폴더를 이 층 `경로` 폴더에 새 키로 */ +export const pasteCombos = ( + from: LaborScope, + pick: { 키?: string[]; 폴더?: string[][] }, + 경로: string[], + 판: string, + scope: LaborScope = SYSTEM_SCOPE, +): Promise => + request(at(scope, "/paste"), "POST", { + from_layer: from.layer, + from_owner: from.owner, + ...pick, + 경로, + 판, + }); + /** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ export const previewCombo = ( combo: Combo | string, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index efc00c27..23990998 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -29,6 +29,7 @@ import { tc } from "./M02_MasterTemplete_Labor_Text"; import { matchText } from "./M02_MasterTemplete_TreePanel_Model"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; const blankCombo = (path: string[] = []): Combo => ({ @@ -75,6 +76,8 @@ export interface LaborEditor { /** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */ startNew: (path: string[]) => void; showList: () => void; + /** 「새 조합」 — 제목 줄 오른쪽 끝에 붙임(읽기 층 = null) */ + newButton: HTMLElement | null; isDirty: () => boolean; /** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */ current: () => string | null; @@ -141,21 +144,15 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { tr.addEventListener("click", () => void open(x.키)); return tr; }); + // 「새 조합」은 제목 줄 오른쪽 끝(`newButton` · PLAN 30-5) · 목록 = 공용 표 모양(줄 선 · 머리 줄) view.replaceChildren( - el("div", { - className: "m01-logic__section-head", - children: [ - ...(edit - ? [createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) })] - : [el("span", { className: "m01-logic__muted", text: tc("ReadOnly") })]), - ], - }), + ...(edit ? [] : [el("p", { className: "m01-logic__muted", text: tc("ReadOnly") })]), shown.length ? el("div", { - className: "m01-master__grid-wrap", + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", children: [ el("table", { - className: "m01-master__grid", + className: "ui-general-block__table m02-labor__list", children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], @@ -387,6 +384,9 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { open, startNew, showList: drawList, + newButton: edit + ? createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) }) + : null, isDirty: dirty, current: () => current?.combo.키 ?? null, setQuery, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 05970da7..71232ea4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -19,6 +19,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -41,6 +42,25 @@ class ComboTreeBody(BaseModel): 판: str +class TreeManyBody(BaseModel): + """여러 개 옮기기 · 지우기(PLAN 30-3) — `ops` = 나무 고침(`ComboTreeBody` 모양) · 저장 한 번.""" + + 판: str + ops: list[dict[str, Any]] + + +class PasteBody(BaseModel): + """층 사이 복사(PLAN 30-3) — 원본 층(`from_layer` · `from_owner`)의 조합 `키` · 폴더 `폴더` → + 이 층 `경로` 폴더에 새 키로. `판` = 이 층 조합 표 판.""" + + from_layer: Layer + from_owner: str | None = None + 키: list[str] = [] + 폴더: list[list[str]] = [] + 경로: list[str] = [] + 판: str = "" + + class PreviewBody(BaseModel): """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다.""" @@ -148,6 +168,30 @@ def post_tree( return _call(folder, combos.edit_tree, body.model_dump()) +@router.post("/layers/{layer}/combos/tree-many") +def post_tree_many( + layer: Layer, + body: TreeManyBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + folder = _root(session, layer, owner, write=True) + return _call(folder, combos.edit_tree_many, body.model_dump()) + + +@router.post("/layers/{layer}/combos/paste") +def post_paste( + layer: Layer, + body: PasteBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """원본 층 읽기 권한 · 이 층 쓰기 권한(M02_Layer_Access) — 마스터 붙이기 = 시스템 관리자.""" + source = _call(_root(session, body.from_layer, body.from_owner), lambda: combos._load()[0]) + folder = _root(session, layer, owner, write=True) + return _call(folder, combo_copy.paste, source, body.model_dump()) + + @router.post("/layers/{layer}/combos/preview") def post_preview( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 4c3e6bdd..8eb639a8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -93,7 +93,11 @@ export interface MainHandle { /** 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ confirmLeave: () => Promise; /** 양식 밖 화면(좌측 자재 조합 상세)을 띄움 — 머리는 제목만 · 고침 표시 손잡이를 돌려줌 */ - showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; + showView: ( + label: string, + node: HTMLElement, + tools?: HTMLElement | null, + ) => (dirty: boolean) => void; /** 구조물집계표를 열고 그 열을 고름 — 이미 표면 다시 안 읽음(`fresh` 면 다시 읽음) */ openTable: (layer: Layer, colId?: string | null, fresh?: boolean) => Promise; /** 나무를 고치기 전 — 저장 안 한 고침이 있으면 저장 · 버림 · 취소를 물음 */ @@ -526,7 +530,12 @@ export function createMain( } } - function showView(label: string, node: HTMLElement): (d: boolean) => void { + /** `tools` = 제목 줄 오른쪽 끝(빈 slot 에 · 다음에 열 때 `drop` 이 비움 · 일위대가 「새 조합」) */ + function showView( + label: string, + node: HTMLElement, + tools?: HTMLElement | null, + ): (d: boolean) => void { ++seq; dirty = false; drop(); @@ -537,6 +546,8 @@ export function createMain( showAside(); title.textContent = label; host.replaceChildren(node); + if (tools) + slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] })); return (d) => (dirty = d); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index ae4021c6..c735cae8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -165,7 +165,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise layerBar("일위대가", add, back), wrap: (panel) => peopleTree("일위대가", panel), - make: (reveal) => buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal }), + make: (reveal) => + buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal, copyTo }), }), }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 3238b2fe..6d03e654 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -56,6 +56,12 @@ min-width: 0; } +/* 제목 줄 오른쪽 끝 도구(일위대가 「새 조합」 · PLAN 30-5) */ +.m02-main__tools { + display: inline-flex; + margin-left: auto; +} + .m02-main__notice { display: flex; align-items: center; diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index d6fecefd..c8113cd7 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -309,3 +309,83 @@ def test_담을_로직_읽기는_로그인이면_됨(world): assert user.get("/api/m02/logic-lib/logic", params={"key": "GF999999"}).status_code == 404 assert user.get("/api/m02/logic-lib/subs").json()["subs"] assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200 + + +# ── 층 사이 복사 · 여러 개 옮기기(PLAN 30-3) ───────────────────────────── +def _paste(client: TestClient, layer: str, body: dict, want: int = 200, **params) -> dict: + body.setdefault("판", _list(client, layer, **params)["판"] if want != 403 else "") + res = client.post(URL.format(layer) + "/paste", json=body, params=params) + assert res.status_code == want, res.text + return res.json() + + +def test_마스터_조합_셋을_개인에_붙임_새_키_본뜬키_같은_이름은_2(world): + user = _as(world) + master = _list(user, "system")["줄"][:3] + keys = [r["키"] for r in master] + got = _paste(user, "personal", {"from_layer": "system", "키": keys}) + assert [p["새키"] for p in got["새키"]] == ["UP42-000001", "UP42-000002", "UP42-000003"] + rows = {r["키"]: r for r in got["줄"]} + for pair, src in zip(got["새키"], master): + one = rows[pair["새키"]] + assert (one["본뜬키"], one["이름"], one["count"]) == (src["키"], src["이름"], src["count"]) + assert (one["구분"], one["상세구분"]) == (None, None) # 붙인 자리 = 맨 위 + again = _paste(user, "personal", {"from_layer": "system", "키": keys[:1]}) + assert next(r for r in again["줄"] if r["키"] == "UP42-000004")["이름"].endswith(" (2)") + _paste(user, "company", {"from_layer": "system", "키": keys}, 403) # 회사 쓰기 권한 없음 + _paste(user, "personal", {"from_layer": "personal", "from_owner": "8/9", "키": keys}, 403) + _paste(user, "personal", {"from_layer": "system", "키": ["UA999999"]}, 404) + stale = {"from_layer": "system", "키": keys, "판": "낡은판"} + assert user.post(URL.format("personal") + "/paste", json=stale).status_code == 409 + + +def test_회사관리자_개인_폴더를_회사에_붙임_깊이_2_넘으면_422(world): + admin = _as(world, "ADMIN") + for name, where in (("가", ["석축"]), ("나", ["석축", "찰쌓기"])): + _new(admin, "personal", _combo(name, 구분=where[0], 상세구분=(where + [None])[1])) + _tree(admin, "personal", op="add-folder", 경로=["석축"], 이름="빈 곳") + body = {"from_layer": "personal", "폴더": [["석축"]]} + got = _paste(admin, "company", body) + assert [p["새키"] for p in got["새키"]] == ["UC7-000001", "UC7-000002"] + assert sorted(got["폴더"]) == [["석축"], ["석축", "빈 곳"], ["석축", "찰쌓기"]] + by = {r["이름"]: (r["구분"], r["상세구분"], r["본뜬키"]) for r in got["줄"]} + assert by["가"] == ("석축", None, "UP42-000001") and by["나"][:2] == ("석축", "찰쌓기") + _tree(admin, "company", op="add-folder", 경로=[], 이름="다른 곳") + deep = {"from_layer": "personal", "폴더": [["석축"]], "경로": ["다른 곳"]} + assert "2 단까지" in _paste(admin, "company", deep, 422)["detail"] + one = {"from_layer": "personal", "폴더": [["석축", "찰쌓기"]], "경로": ["다른 곳"]} + got = _paste(admin, "company", one) # 한 단 폴더는 들어감 + assert ["다른 곳", "찰쌓기"] in got["폴더"] + + +def test_여러_개_옮기기_지우기_한_번에(world): + me = _as(world) + keys = [_new(me, "personal", _combo(n))["키"] for n in ("가", "나", "다")] + _tree(me, "personal", op="add-folder", 경로=[], 이름="석축") + 판 = _list(me, "personal")["판"] + ops = [{"op": "move", "키": k, "새경로": ["석축"]} for k in keys[:2]] + res = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}) + assert res.status_code == 200, res.text + assert [r["구분"] for r in res.json()["줄"]] == ["석축", "석축", None] + bad = {"판": res.json()["판"], "ops": [*ops[:1], {"op": "move", "키": "UP42-999999"}]} + assert me.post(URL.format("personal") + "/tree-many", json=bad).status_code == 404 + assert [r["구분"] for r in _list(me, "personal")["줄"]] == ["석축", "석축", None] # 안 씀 + 판 = _list(me, "personal")["판"] + ops = [{"op": "delete", "키": k} for k in keys[1:]] + got = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}).json() + assert got["지운조합"] == keys[1:] and [r["키"] for r in got["줄"]] == keys[:1] + stale = {"판": "낡은판", "ops": ops} + assert me.post(URL.format("personal") + "/tree-many", json=stale).status_code == 409 + + +def test_시스템_관리자_개인_조합을_마스터에_붙임_본뜬키는_UA_만(world, master_copy): + admin = _as(world, "SYSTEM_ADMIN") + up = _new(admin, "personal", _combo("개인 것"))["키"] + before = len(_list(admin, "system")["줄"]) + got = _paste(admin, "system", {"from_layer": "personal", "키": [up]}) + new = got["새키"][0]["새키"] + assert new.startswith("UA") and len(got["줄"]) == before + 1 + row = next(r for r in got["줄"] if r["키"] == new) + assert row["본뜬키"] is None and row["이름"] == "개인 것" + doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) + assert m01.cm.check_form(combos.FILE, doc) == [] diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index dec0d577..9268309c 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -90,3 +90,35 @@ def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤(): assert 'mark("일위대가")' in labor # 여는 순간 해시 open assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림) assert 'if (main.current() && view === "일위대가") mark("구조물조합")' in page + + +def test_일위대가_나무에_여러_개_복사_붙여넣기_연결(): + """PLAN 30-3 — 공용 나무 부품(30-2) 콜백을 일위대가 서버 길에 이음 · + 붙임 종류는 따로(구조물과 안 섞임).""" + labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") + assert 'clipKind: "일위대가"' in labor and "copyTo: opt.copyTo" in labor + assert "editComboTreeMany(ops, 판, scope)" in labor # 여러 개 옮기기 · 지우기 + assert "pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)" in labor # 대상 층 + page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") + assert "canEdit: isAdmin, reveal, copyTo })" in page # 구조물 나무와 같은 「〇〇로 복사」 목록 + + +def test_새_조합_단추는_제목_줄_오른쪽_목록은_공용_표(): + """PLAN 30-5 — 「새 조합」 = 제목 줄 오른쪽 끝(읽기 층 없음) · 목록 = 공용 표(줄 선). + 머리 줄도 공용 표 모양.""" + edit = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts").read_text("utf-8") + assert ( + "newButton: edit" in edit and 'className: "ui-general-block__table m02-labor__list"' in edit + ) + assert ( + 'tc("New"), variant: "filled", onClick: () => startNew([]) })]' not in edit + ) # 목록 안 옛 자리 + labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") + assert 'library.bind(opt.show(tc("Library"), library.root, library.tools))' in labor + main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") + assert ( + 'slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] }))' + in main + ) + css = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Style.css").read_text("utf-8") + assert ".m02-main__tools {\n display: inline-flex;\n margin-left: auto;" in css