feat(M02): 일위대가 라이브러리 화면 — 좌측 「일위대가 조합」 컨테이너 · 뿌리 셋 나무 · 조합 편집 · M01 컨테이너 뺌(PLAN 27-2b · 27-2c 화면)

- 좌측 「일위대가 조합」(구조물 조합 위) → 메인 칸 왼쪽 뿌리 셋 + 조합 나무(폴더 [구분, 상세구분] 깊이 2 · TreePanel 공용) · 오른쪽 목록 · 상세(옛 M01 조합 화면 옮김 · Labor_Edit)
- 나무 오른쪽 클릭: 폴더 더하기(2 단까지) · 조합 더하기(그 폴더 채운 빈 조합) · 이름 · 옮기기 · 지우기 · 조합 이름 바꾸기 서버 길 더함
- 해시 open=일위대가 로 진입 · 다른 뿌리를 누르면 그 뿌리 일위대가로
- 읽기만 뿌리는 저장 · 지우기 · 로직 더하기 없음
- TreePanel 에 maxDepth · item(부르는 말 · 새 항목 화면) 선택지 더함(구조물 라이브러리는 그대로)
- 조합 고르기(면적산출 옆 목록 · 일위대가 고르기)가 이 층이 쓸 수 있는 세 층 조합 · 알약은 /api/m02/combos/key/{키}/inputs
- M01 좌측 · 페이지에서 일위대가 조합 컨테이너 뺌
- 시험 test_m02_labor_tree(새) · test_m02_combo_layers 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
2026-09-30 13:35:38 +09:00
co-authored by Claude Opus 5.5
parent ca946ebbf1
commit c8686ee3ad
17 changed files with 572 additions and 268 deletions
+14
View File
@@ -156,6 +156,15 @@ def test_나무_폴더_깊이_2_이름_옮기기_지우기(world):
row = next(r for r in got["줄"] if r["키"] == key)
assert (row["구분"], row["상세구분"]) == ("찰쌓기", None)
_tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판")
_new(me, "personal", _combo("다른 것"))
got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌")
assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌"
assert (
"이미 있음"
in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0]
)
other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것")
_tree(me, "personal", op="delete", 키=other)
got = _tree(me, "personal", op="delete", 경로=["찰쌓기"])
assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]]
@@ -198,6 +207,10 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy):
admin = _as(world, "SYSTEM_ADMIN")
before = _list(admin, "system")
first = before["줄"][0]["키"]
got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본")
assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본"
before = _list(admin, "system")
assert ["실무 일위대가"] in before["폴더"]
_tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더")
got = _tree(admin, "system", op="rename", 경로=["실무 일위대가"], 이름="실무")
@@ -272,6 +285,7 @@ def test_면적_알약이_개인_조합을_읽음(world):
up = _new(me, "personal", _combo("찰쌓기", (A, B)))["키"]
got = me.get(f"{URL.format('personal')}/{up}/inputs").json()
assert [p["키"] for p in got["물량"]] == ["돌쌓기 찰쌓기|㎡"]
assert me.get(f"/api/m02/combos/key/{up}/inputs").json() == got
tree = me.post(
"/api/m02/layers/personal/tree",
json={"op": "add-item", "경로": [], "이름": "찰쌓기 옹벽", "단위": "m", "판": _판(me)},
+76
View File
@@ -0,0 +1,76 @@
"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정.
못박는 것
- 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키.
- 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 ·
지우기는 고름 없음.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = r"""
const fs = require("fs");
const ts = require(process.argv[1] + "/config/node_modules/typescript");
const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts";
const src = fs.readFileSync(process.argv[1] + file, "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" });
const list = {
판: "v1",
폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]],
줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null),
row("UP1-000003", "다", null, "무시")],
};
const view = m.toView(list);
const notes = [
{ op: "add-folder", 경로: ["석축"], 이름: "메쌓기" },
{ op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" },
{ op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] },
{ op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] },
{ op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" },
{ op: "delete", 경로: ["석축"], 키: "UP1-000002" },
{ op: "delete", 경로: ["빈"] },
].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : []));
console.log(JSON.stringify({ view, notes }));
"""
@pytest.fixture(scope="module")
def got():
if shutil.which("node") is None:
pytest.skip("node 가 없음")
proc = subprocess.run(
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_조합이_나무_항목으로(got):
items = got["view"]["items"]
assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]]
assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"]
assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]]
assert "알림" not in got["view"]
def test_나무_고침_알림(got):
add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"]
assert add["경로"] == ["석축", "메쌓기"] and "열" not in add
assert rename["경로"] == ["석축", "찰"]
assert move["경로"] == ["찰쌓기"]
assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"]
assert rename_item["경로"] == ["석축", "나나"]
assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"]
assert "경로" not in delete_folder