Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7

This commit is contained in:
2026-09-30 08:16:41 +09:00
10 changed files with 594 additions and 111 deletions
+67 -13
View File
@@ -75,8 +75,26 @@ globalThis.fetch = async (url, init = {}) => {
const [, , folderEnc, partEnc] = m;
if (!folderEnc && method === "GET")
return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) });
if (folderEnc === "new" && method === "POST") {
if (state.noCompany)
return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." });
const item = {
layer: "personal",
owner: "1/3",
key: "SS900001",
name: [...body.경로, body.이름].join(" "),
};
return reply(200, {
key: "SS900001",
이름: item.name,
폴더: body.structure,
summary_item: item,
});
}
if (!folderEnc && method === "POST") {
const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`;
if (!body.structure)
return reply(400, { detail: "미리 가져올 구조물(structure)이 필요합니다." });
const folder = body.structure.replace("#", "n");
state.folders[folder] = {
info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" },
parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null },
@@ -86,9 +104,24 @@ globalThis.fetch = async (url, init = {}) => {
const item = state.folders[decodeURIComponent(folderEnc)];
if (!item) return reply(404, { detail: "복사본이 없음" });
const part = partEnc && decodeURIComponent(partEnc);
if (part === "refresh") {
if (part === "reapply") {
item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] };
return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" });
return reply(200, {
폴더: item.info.폴더,
가져온날: "2026-09-30T02:00:00",
원본판: "새판",
덮임: ["면적"],
});
}
if (part === "save-as" && method === "POST") {
if (state.noCompany)
return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." });
const name = body.이름 ?? "복사";
return reply(200, {
key: "SS900002",
이름: name,
summary_item: { layer: "personal", owner: "1/3", key: "SS900002", name },
});
}
if (part === "calc")
return reply(200, {
@@ -122,17 +155,24 @@ const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner
async function main() {
const out = {};
out.folders = [
project.folderOf(row()),
project.folderOf(row({ layer: "company", owner: "3" })),
project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })),
];
// 구조물 id → 복사본 폴더(서버 `folder_name`) · 관 지점 id(서버 `pipe@{round(측점, 3)}` 파이썬 float 글)
out.folders = ["st-1", "#3", "pipe@85.05"].map(project.structureFolder);
out.pipeIds = [85.05, 100, 258.12, 0.1234, 12.0004].map(project.pipeStructureId);
const items = project.projectItems("P1");
out.listEmpty = await items.list();
const info = await items.importItem(row({ layer: "personal", owner: "1/3" }));
const info = await items.importItem(row({ layer: "personal", owner: "1/3" }), "pipe@85.05");
out.info = info;
out.importCall = state.calls.find((c) => c.method === "POST");
out.sameItem = [
project.sameItem(info, row({ layer: "personal", owner: "1/3" })),
project.sameItem(info, row({ layer: "personal", owner: "1/3", key: "SS000002" })),
project.sameItem(info, row()),
];
await items.importItem(row(), "").then(
() => (out.noStructure = "성공"),
(error) => (out.noStructure = error.message),
);
out.list = (await items.list()).map((x) => x.폴더);
out.read = await items.read(info.폴더, "면적");
@@ -164,11 +204,25 @@ async function main() {
);
state.readOnly = false;
const fresh = await items.refresh(info);
out.refresh = fresh;
out.refreshCall = state.calls
.filter((c) => /refresh$/.test(c.url))
const again = await items.reapply(info);
out.reapply = again;
out.reapplyCall = state.calls
.filter((c) => /reapply$/.test(c.url))
.map((c) => `${c.method} ${c.url}`);
out.saveAs = await items.saveAs(info.폴더, ["sub5", "경로"], "내 항목");
out.saveAsCall = state.calls.find((c) => /save-as$/.test(c.url)).body;
out.newItem = await items.newItem("pipe@85.05", ["개인", "폴더"], "새 항목", "m");
out.newCall = state.calls.find((c) => /items\/new$/.test(c.url)).body;
state.noCompany = true;
await items.saveAs(info.폴더, [], "x").then(
() => (out.noCompanySave = "성공"),
(error) => (out.noCompanySave = error.message),
);
await items.newItem("pipe@85.05", [], "x", "").then(
() => (out.noCompanyNew = "성공"),
(error) => (out.noCompanyNew = error.message),
);
state.noCompany = false;
out.calc = await items.calc(info.폴더);
out.calcMissing = await items.calc("없는폴더");
+52 -16
View File
@@ -36,26 +36,28 @@ def out():
return json.loads(proc.stdout)
def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out):
rows = [
{"layer": "system", "owner": None, "key": "SS000001"},
{"layer": "company", "owner": "3", "key": "SS000001"},
{"layer": "personal", "owner": "3/12", "key": "SS000002"},
def test_구조물_id_는_복사본_폴더와_관_지점_id_로(out):
assert out["folders"] == ["st-1", "n3", "pipe@85.05"] # `#차례` = `n차례` (서버 folder_name)
# 서버 `pipe@{round(측점, 3)}` — 파이썬 float 글(정수도 `100.0`)
assert out["pipeIds"] == [
f"pipe@{round(x, 3)}" for x in (85.05, 100.0, 258.12, 0.1234, 12.0004)
]
assert out["folders"] == [snapshot_folder_name(row) for row in rows]
assert out["info"]["폴더"] == snapshot_folder_name(
{"layer": "personal", "owner": "1/3", "key": "SS000001"}
)
def test_가져오기는_POST_원본_참조_목록은_항목(out):
def test_가져오기는_구조물마다_POST_본문에_structure(out):
assert out["listEmpty"] == []
assert out["importCall"] == {
"method": "POST",
"url": "/api/projects/P1/items",
"body": {"layer": "personal", "owner": "1/3", "key": "SS000001"},
"body": {"structure": "pipe@85.05", "layer": "personal", "owner": "1/3", "key": "SS000001"},
}
assert out["list"] == [out["info"]["폴더"]]
assert out["info"]["폴더"] == "pipe@85.05" # 폴더 = 구조물 id
assert out["list"] == ["pipe@85.05"]
assert out["noStructure"] == "미리 가져올 구조물(structure)이 필요합니다." # 서버 까닭 그대로
def test_같은_구조물이_다른_항목으로_바뀌었는지_원본_참조로_가림(out):
assert out["sameItem"] == [True, False, False]
def test_읽기는_내용만_돌려주고_없으면_null(out):
@@ -76,14 +78,31 @@ def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로
assert out["readOnly"] == "프로젝트 편집 권한이 없음"
def test_새로_가져오기와_계산_한_줄(out):
assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/")
assert out["refreshCall"][0].endswith("/refresh")
assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판"
def test_재적용은_덮임을_돌려주고_계산_한_줄(out):
assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply"
assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로
assert out["reapply"]["info"]["마스터바뀜"] is False
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1}
assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음
def test_개인에_저장과_새_항목_본문_summary_item(out):
assert out["saveAsCall"] == {"to": "personal", "경로": ["sub5", "경로"], "이름": "내 항목"}
assert out["saveAs"]["summary_item"]["key"] == "SS900002"
assert out["newCall"] == {
"structure": "pipe@85.05",
"경로": ["개인", "폴더"],
"이름": "새 항목",
"단위": "m",
}
assert out["newItem"]["summary_item"]["name"] == "개인 폴더 새 항목"
def test_회사_없음_409_는_서버_까닭_그대로(out):
for key in ("noCompanySave", "noCompanyNew"):
assert out[key].startswith("회사부터 등록"), key
def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out):
assert out["inputsHits"] == 1
assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
@@ -164,3 +183,20 @@ def test_소스가_700줄을_넘지_않음():
M02 / "M02_MasterTemplete_Inputs_Model.ts",
):
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name
def test_나무_오른쪽_클릭은_메뉴_항목을_밖에서_주는_자리():
tree = (UI / "ui_template_tree.ts").read_text(encoding="utf-8")
assert "menu?: () => TreeMenuItem[]" in tree and "export function openTreeMenu(" in tree
assert '"contextmenu"' in tree
picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8")
# 새 항목 메뉴는 개인 그룹 · 폴더에만(마스터 · 회사는 메뉴 없음) · 프로젝트 안에서만
assert 'folder.layer !== "personal" || !opts.projectId' in picker
assert "menu: menuOf(node)" in picker
def test_B05_구조물_폼이_구조물_id_를_모달에_넘김():
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(
encoding="utf-8"
)
assert "pipeStructureId(selectedPipeChainage)" in panel and "editingId ??" in panel
+31 -10
View File
@@ -10,6 +10,7 @@ import {
isCurrent,
itemName,
pathIds,
pathOf,
rowId,
rowTip,
shown,
@@ -89,13 +90,8 @@ assert.equal(
"옹벽 H=2m",
);
const placeholderTree = buildTree([fresh], labels);
assert.equal(placeholderTree[2].children?.[0].label, "분류 없음"); // 종류
assert.equal(placeholderTree[2].children?.[0].children?.[0].label, "분류 없음"); // 분류1
assert.deepEqual(pathIds(fresh, labels), [
"personal",
"personal|분류 없음",
"personal|분류 없음|분류 없음",
]);
assert.equal(placeholderTree[2].children?.[0].label, "n1"); // 경로가 비면 폴더 없이 그룹 바로 밑 잎
assert.deepEqual(pathIds(fresh, labels), ["personal"]);
// 툴팁 = 층 이름 + 키
assert.equal(rowTip(rows[7], labels), "개인 · SS000201");
@@ -123,14 +119,38 @@ assert.deepEqual(
const cat = system.children?.[0].children?.[0];
assert.equal(cat?.label, "메쌓기");
assert.equal(cat?.count, 2);
const leaf = cat?.children?.[0];
const size = cat?.children?.[0]; // 폴더 깊이 자유 — 경로 앞부분이 모두 폴더
assert.equal(size?.label, "1.2m");
assert.deepEqual(size?.folder, { layer: "system", path: ["돌기슭막이", "메쌓기", "1.2m"] });
assert.deepEqual(tree[2].folder, { layer: "personal", path: [] }); // 그룹 줄 = 빈 경로
const leaf = size?.children?.[0];
assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유");
assert.equal(leaf?.tip, "마스터 · SS000001"); // 툴팁 = 층 이름 + 키
assert.equal(leaf?.count, null);
assert.ok(leaf?.row);
// 분류가 빈 항목은 「분류 없음」 한 칸에 모임
assert.equal(tree[1].children?.[1].children?.[0].label, "분류 없음");
// 경로가 짧은 항목은 얕은 폴더에(옹벽 H=2m = 폴더 옹벽 · 잎 「옹벽 H=2m」)
assert.equal(tree[1].children?.[1].label, "옹벽");
assert.equal(tree[1].children?.[1].children?.[0].label, "옹벽 H=2m");
assert.equal(tree[1].children?.[1].children?.[0].folder, undefined); // 잎에는 folder 없음
// 서버가 준 `경로` 가 종류 · 분류보다 앞 — 이름 = 경로 이음 · 자리표는 뺌
const deep = row("SS000400", "d1", "무시", ["무시", "", ""], "personal", {
경로: ["가", "나", "다", "라", "마"],
});
assert.deepEqual(pathOf(deep), ["가", "나", "다", "라", "마"]);
assert.equal(itemName(deep), "가 나 다 라 마");
assert.deepEqual(pathIds(deep), [
"personal",
"personal|가",
"personal|가|나",
"personal|가|나|다",
"personal|가|나|다|라",
]);
assert.deepEqual(pathOf({ key: "k", 열: "x", 경로: ["새 분류1", "옹벽"] }), ["옹벽"]);
const deepTree = buildTree([deep], labels)[2];
assert.equal(deepTree.children?.[0].children?.[0].children?.[0].children?.[0].label, "라");
assert.equal(deepTree.count, 1);
// 빈 그룹도 한 줄은 남음(회사 없는 사람의 회사 · 개인 그룹)
const onlySystem = buildTree(
@@ -198,6 +218,7 @@ assert.deepEqual(pathIds(rows[0], labels), [
"system",
"system|돌기슭막이",
"system|돌기슭막이|메쌓기",
"system|돌기슭막이|메쌓기|1.2m",
]);
// 폴더 모양 — 다섯 줄 · 이름은 서버 names · 없으면 키만(작은 글) · 둘 다 없으면 빈 키