Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -60,7 +60,13 @@ def version_of(raw: bytes) -> str:
|
||||
def _path(kind: str, name: str) -> Path:
|
||||
if kind not in KINDS:
|
||||
raise StoreError(404, f"없는 종류 「{kind}」")
|
||||
if not name or name != name.strip() or name.startswith(".") or _BAD_NAME.search(name):
|
||||
if (
|
||||
not name
|
||||
or name != name.strip()
|
||||
or name.startswith((".", "_")) # `_` 도 막음 — 새 프로젝트 복사(layers.check_name)가 거절함
|
||||
or len(name) > 80 # 파일 이름이 길면 OS 가 죽음(500) — 화면 규칙과 같은 80자
|
||||
or _BAD_NAME.search(name)
|
||||
):
|
||||
raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」")
|
||||
return FOLDER / kind / f"{name}.json"
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
@@ -87,7 +86,10 @@ export interface SideHandle {
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
const badName = (name: string): boolean =>
|
||||
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".");
|
||||
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) ||
|
||||
name.startsWith(".") ||
|
||||
name.startsWith("_") ||
|
||||
name.length > 80;
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const failed = (error: unknown): void =>
|
||||
@@ -113,25 +115,30 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const columnBodies = COLUMN_KINDS.map(() =>
|
||||
el("div", { className: "m02-side__items ui-sidebar-list" }),
|
||||
);
|
||||
/** 새로 · 본떠 만들기 · 지우기 — 표 · 도면 컨테이너 안에 하나씩(그 종류로 동작) · 관리자만 */
|
||||
const editButtons = new Map<Kind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
|
||||
const actionRow = (kind: Kind): HTMLElement[] => {
|
||||
if (!canEdit(layer, opt.isAdmin)) return [];
|
||||
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
|
||||
const copy = createButton({ label: L("M02_Copy"), variant: "ghost" });
|
||||
const del = createButton({ label: L("M02_Delete"), variant: "danger" });
|
||||
editButtons.set(kind, { copy, del });
|
||||
btnNew.addEventListener("click", () => newTemplate(kind));
|
||||
copy.addEventListener("click", copyTemplate);
|
||||
del.addEventListener("click", () => void deleteCurrent());
|
||||
return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })];
|
||||
};
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
section(L("M02_KindTable"), bodies.get("table")!),
|
||||
section(L("M02_KindDrawing"), bodies.get("drawing")!),
|
||||
section(L("M02_KindTable"), bodies.get("table")!, ...actionRow("table")),
|
||||
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])),
|
||||
],
|
||||
});
|
||||
attachCollapsible(listHost);
|
||||
|
||||
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
|
||||
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost" });
|
||||
const btnDel = createButton({ label: L("M02_Delete"), variant: "danger" });
|
||||
const editRow = el("div", {
|
||||
className: "m02-side__row ui-sidebar-card",
|
||||
children: [btnNew, btnCopy, btnDel],
|
||||
});
|
||||
|
||||
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost, editRow] });
|
||||
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
|
||||
|
||||
/** 고름 표시 — `picked`(방금 누름) 이 있으면 그것 · 없으면 지금 열린 것과 비교 */
|
||||
const isPicked = (kind: Kind, name: string): boolean => {
|
||||
@@ -186,11 +193,10 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const paintColumns = (): void =>
|
||||
COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key));
|
||||
|
||||
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
|
||||
function paintButtons(): void {
|
||||
const editable = canEdit(layer, opt.isAdmin);
|
||||
btnNew.disabled = !editable;
|
||||
btnCopy.disabled = !editable || !currentInfo();
|
||||
btnDel.disabled = !editable || !currentInfo();
|
||||
const cur = currentInfo();
|
||||
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
|
||||
}
|
||||
|
||||
async function select(info: TemplateInfo): Promise<void> {
|
||||
@@ -243,16 +249,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(표 · 도면만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
const nameDialog = (
|
||||
title: string,
|
||||
withKind: boolean,
|
||||
onOk: (kind: Kind, name: string) => Promise<void>,
|
||||
): void => {
|
||||
const nameDialog = (title: string, onOk: (name: string) => Promise<void>): void => {
|
||||
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
|
||||
const kind = createSelectField({
|
||||
label: L("M02_Kind"),
|
||||
options: KINDS.map((k) => ({ value: k.kind, text: k.label() })),
|
||||
});
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
@@ -265,7 +263,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (badName(text)) return showToast(L("M02_BadName"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(kind.select.value as Kind, text);
|
||||
await onOk(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
|
||||
@@ -274,7 +272,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
body.append(...(withKind ? [kind.root] : []), name.root, ok);
|
||||
body.append(name.root, ok);
|
||||
name.input.focus();
|
||||
},
|
||||
});
|
||||
@@ -287,22 +285,25 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (info) await select(info);
|
||||
};
|
||||
|
||||
btnNew.addEventListener("click", () =>
|
||||
nameDialog(L("M02_NewTitle"), true, async (kind, name) => {
|
||||
/** 이 컨테이너 종류의 새 양식 — 종류 고르기 없이 이름만 */
|
||||
function newTemplate(kind: Kind): void {
|
||||
nameDialog(L("M02_NewTitle"), async (name) => {
|
||||
await saveTemplate("system", kind, name, null, "", stubDoc(kind, name));
|
||||
await openCreated(kind, name);
|
||||
}),
|
||||
);
|
||||
btnCopy.addEventListener("click", () => {
|
||||
});
|
||||
}
|
||||
function copyTemplate(): void {
|
||||
const from = currentInfo();
|
||||
if (!from) return;
|
||||
nameDialog(L("M02_CopyTitle"), false, async (_kind, name) => {
|
||||
nameDialog(L("M02_CopyTitle"), async (name) => {
|
||||
const got = await readTemplate("system", from.종류, from.이름, null);
|
||||
await saveTemplate("system", from.종류, name, null, "", got.문서);
|
||||
// 표 문서 머리 이름(`양식`)도 새 이름으로 — 안 바꾸면 복사본이 원본 이름을 달고 있음
|
||||
const doc = from.종류 === "table" ? { ...(got.문서 as object), 양식: name } : got.문서;
|
||||
await saveTemplate("system", from.종류, name, null, "", doc);
|
||||
await openCreated(from.종류, name);
|
||||
});
|
||||
});
|
||||
btnDel.addEventListener("click", async () => {
|
||||
}
|
||||
async function deleteCurrent(): Promise<void> {
|
||||
await refresh(); // 저장 직후여도 최신 판으로
|
||||
const info = currentInfo();
|
||||
if (!info) return;
|
||||
@@ -315,7 +316,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void refresh();
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router
|
||||
|
||||
|
||||
@@ -47,10 +48,42 @@ def test_new_save_reopen_delete_and_stale(client):
|
||||
|
||||
|
||||
def test_bad_names_and_kinds(client):
|
||||
for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab"):
|
||||
for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab", "_밑줄", "가" * 81):
|
||||
got = client.put(f"/api/m02/templates/table/{bad}", json={"판": "", "문서": {"열": []}})
|
||||
assert got.status_code == 400 and "쓸 수 없는" in got.json()["detail"]
|
||||
assert client.put("/api/m02/templates/other/x", json={"판": "", "문서": {}}).status_code == 404
|
||||
edge = "가" * 80 # 80자는 됨
|
||||
assert (
|
||||
client.put(
|
||||
f"/api/m02/templates/table/{edge}", json={"판": "", "문서": {"열": []}}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
def test_copy_is_independent_and_seeds_new_project(client, tmp_path, monkeypatch):
|
||||
"""본떠 만들기 = 읽어 새 이름으로 새로 저장 — 원본은 안 바뀜 · 새 프로젝트 복사에 실림."""
|
||||
doc = {"format": 6, "entities": [{"type": "line"}], "drawing_area": [1, 2, 3, 4]}
|
||||
client.put("/api/m02/templates/drawing/원본", json={"판": "", "문서": doc})
|
||||
got = client.get("/api/m02/templates/drawing/원본").json()
|
||||
made = client.put("/api/m02/templates/drawing/복사", json={"판": "", "문서": got["문서"]})
|
||||
assert made.status_code == 200
|
||||
assert (
|
||||
client.put("/api/m02/templates/drawing/복사", json={"판": "", "문서": doc}).status_code
|
||||
== 409
|
||||
)
|
||||
copy = client.get("/api/m02/templates/drawing/복사").json()
|
||||
assert copy["문서"] == doc and copy["판"] == got["판"] # 내용 같음
|
||||
client.put(
|
||||
"/api/m02/templates/drawing/복사",
|
||||
json={"판": copy["판"], "문서": {**doc, "entities": [{"type": "line"}, {"type": "arc"}]}},
|
||||
)
|
||||
assert client.get("/api/m02/templates/drawing/원본").json()["문서"] == doc # 원본 그대로
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
|
||||
copied = layers.seed_project(tmp_path / "seed")
|
||||
assert {"drawing/원본", "drawing/복사"} <= set(copied["작업본"])
|
||||
client.delete("/api/m02/templates/drawing/복사")
|
||||
assert [r["이름"] for r in client.get("/api/m02/templates").json()] == ["원본"]
|
||||
|
||||
|
||||
def test_empty_doc_rejected_and_file_untouched(client):
|
||||
|
||||
@@ -140,6 +140,14 @@ def test_미리보기_CAD_패널은_숨기지_않고_접힌_채로_엶():
|
||||
assert "isInspectorCollapsed()" in _text(cad / "components/Toolbar.tsx")
|
||||
|
||||
|
||||
def test_좌측_새로_본떠_지우기는_표_도면_컨테이너_안에_하나씩():
|
||||
side = _text(SIDE)
|
||||
assert 'actionRow("table")' in side and 'actionRow("drawing")' in side
|
||||
assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만
|
||||
assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만
|
||||
assert "양식: name" in side.split("function copyTemplate")[1] # 복사본 머리 이름 = 새 이름
|
||||
|
||||
|
||||
def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘():
|
||||
ribbon = _text(ROOT / "B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx")
|
||||
assert "if (collapsed) setPeek(true);" in ribbon # 탭 = 잠깐 펼침(접힘 모드 유지)
|
||||
|
||||
@@ -42,8 +42,8 @@ export const ui_locales_m2 = {
|
||||
M02_NoSpreadsheet: ["산출근거 편집 부품이 아직 없음", "Cost basis editor not available yet"],
|
||||
M02_NameExists: ["이미 있는 이름", "That name already exists"],
|
||||
M02_BadName: [
|
||||
'쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨',
|
||||
'Invalid name — no / \\ : * ? " < > | or .. or leading dot',
|
||||
'쓸 수 없는 이름 — / \\ : * ? " < > | 와 .. · 앞의 점 · 밑줄은 안 됨 · 80자까지',
|
||||
'Invalid name — no / \\ : * ? " < > | or .. or leading dot/underscore · up to 80 characters',
|
||||
],
|
||||
M02_DiscardConfirm: [
|
||||
"저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
|
||||
Reference in New Issue
Block a user