Files
Aislo/resources/tester/test_cad_host_tabs.py
T
eomsangdonandClaude Opus 5.5 4e18bca4e4 feat(B07): 목록에서 연 도면 = 탭 — 열기 · 닫기 · 전환 · 이전/다음 · 탭 전환 때 미저장 편집 지킴(82-4)
- 공용 탭 줄: 마지막 한 탭 닫기 없음 · 지금 탭 미저장 점 · 탭 하나만 저장 표시 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-08 12:12:58 +09:00

234 lines
11 KiB
Python

"""CAD 공용 탭 줄(PLAN 82-1 `cad_host_tabs.ts`) · M02 구조물 도면 여러 장(82-2 `mountStructureSheets`)
— Node 로 TS 를 돌려 판정(가짜 DOM · 가짜 CAD).
못박는 것
- 탭 바꿀 때 지금 장을 받아 두고(capture) 다음 장을 실음 · 탭 셋 왕복 뒤 장마다 엔티티 수 그대로.
- 고정 탭(1장) 앞으로는 못 끎 · 지우기 · 같은 이름 막음.
- 여러 장 문서 getDoc = `도면`(1장) + `더한장[{장id, 이름, 도면}]` · 새 장 · 장 도번 SD-1-2 · 백업 칸 장마다.
- 안 실린 장 표 갈기(updateTable) = 받아 둔 장에서.
"""
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 root = process.argv[1];
const ts = require(root + "/config/node_modules/typescript");
const load = (file, stubs) => {
const src = fs.readFileSync(root + "/" + 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, (n) =>
n.endsWith(".css") ? {} : (stubs[n] ?? require(n)),
);
return box.exports;
};
// 가짜 DOM — 쓰는 것만
const node = (tag) => ({
tag, kids: [], dataset: {}, on: {}, attrs: {}, hidden: false, textContent: "", title: "", className: "",
classList: { add() {}, toggle() {} }, style: {},
append(...x) { this.kids.push(...x); }, prepend(...x) { this.kids.unshift(...x); },
replaceChildren(...x) { this.kids = x; }, replaceWith() {}, remove() {}, select() {}, focus() {},
setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, f) { this.on[k] = f; },
getBoundingClientRect: () => ({ left: 0, width: 100 }), querySelector: () => null,
});
globalThis.document = { createElement: node };
const toasts = [];
const confirms = [];
const elements = {
showToast: (m, k) => toasts.push([k, m]),
showConfirmDialog: async (m) => (confirms.push(m), true),
createButton: () => node("button"),
createInputField: (o) => ({ root: node("div"), input: { value: o.value, disabled: false, addEventListener() {} }, setError() {} }),
};
const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template_elements": elements });
(async () => {
const out = {};
// ① 탭 셋 왕복 — CAD 하나에 장을 갈아 실음
let shown = { id: "a", n: 3 };
const log = [];
const tabs = tabsMod.createCadTabs({
features: { add: true, rename: true, remove: true, reorder: true },
capture: async () => ({ drawing: { ...shown, n: shown.n + (shown.id === "b" ? 1 : 0) }, dirty: shown.id === "b" }),
show: (tab) => (log.push(tab.id), (shown = tab.drawing)),
onChange: () => log.push("change"),
});
tabs.set([
{ id: "a", name: "1장", drawing: { id: "a", n: 3 }, fixed: true },
{ id: "b", name: "2장", drawing: { id: "b", n: 5 } },
{ id: "c", name: "3장", drawing: { id: "c", n: 0 } },
], "a");
for (const id of ["b", "c", "a", "b", "a"]) await tabs.select(id);
const all = await tabs.collect();
out.roundtrip = all.map((t) => [t.id, t.drawing.n, !!t.dirty]);
out.shownOrder = log.filter((x) => x !== "change");
// ② 끌기 — c 를 a(고정) 앞에 놓아도 a 뒤
const tabNode = (id) => tabs.element.kids.find((k) => k.dataset.tabId === id);
tabNode("c").on.dragstart({ dataTransfer: { setData() {} } });
tabNode("a").on.dragover({ preventDefault() {}, clientX: 10 });
tabNode("a").on.drop({ preventDefault() {} });
out.order = tabs.tabs().map((t) => t.id);
out.fixedDraggable = !!tabNode("a").draggable;
out.fixedX = tabNode("a").kids.length; // 고정 탭 = 글만(지우기 없음)
// ③ 지우기 — 물음 뒤 · 지금 탭이면 다음 탭을 실음
await tabs.select("c");
const xc = tabNode("c").kids.find((k) => k.className === "cad-tabs__x");
await xc.on.click({ stopPropagation() {} });
out.afterRemove = { ids: tabs.tabs().map((t) => t.id), active: tabs.active().id, confirms: confirms.length };
// ④ [+] — create 없으면 아무것도 안 함 · 닫기 기능(B07) = 미저장 탭만 물음
const b07 = [];
const closer = tabsMod.createCadTabs({
features: { close: true },
capture: async () => ({ drawing: "편집본", dirty: true }),
show: (tab) => b07.push(["show", tab.id, tab.drawing ?? null, !!tab.dirty]),
onClosed: (tab) => b07.push(["closed", tab.id]),
});
await closer.open({ id: "d1", name: "종단" });
await closer.open({ id: "d2", name: "횡단" });
await closer.open({ id: "d1", name: "종단" });
const before = confirms.length;
const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2");
await d2.kids.find((k) => k.className === "cad-tabs__x").on.click({ stopPropagation() {} });
const last = closer.element.kids.find((k) => k.dataset.tabId === "d1").kids.length; // 마지막 탭 = 닫기 없음
out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id), last };
// ⑤ M02 여러 장 — 가짜 CAD(requestSave = 지금 실린 도면 + 편집)
const cadLoads = [];
let inCad = null;
const fakeCad = {
element: node("div"),
beginLoading() {},
load: (drawing, meta, frameEdit, fields, extra) => (cadLoads.push(extra.recoveryScope), (inCad = drawing)),
requestSave: async () => ({ drawing: inCad, quantityTable: null }),
placeTable() {},
updateTable: async () => 1,
destroy() {},
};
const urls = [];
globalThis.fetch = async (url) => (urls.push(url), { ok: true, json: async () => ({ entities: [{ type: "Text", url, layerId: "m02-structure-frame" }], layer: { id: "m02-structure-frame" } }) });
const drawingMod = load("M02_MasterTemplete/M02_MasterTemplete_Drawing.ts", {
"@config/config_frontend": { API_BASE_URL: "/api" },
"@ui/cad_host/cad_host": { createCadHost: () => fakeCad },
"@ui/cad_host/cad_host_tabs": tabsMod,
"@ui/ui_template_elements": elements,
"@ui/ui_template_modal": { openModal: () => ({ close() {} }) },
});
const table = (link) => ({ type: "Table", shapeData: { cells: [[{ text: "옛" }]], style: { 연결: link } } });
const doc = {
도번: "SD-1",
도면: { format: 6, entities: [{ type: "Line" }, { type: "Line" }], layers: [] },
더한장: [{ 장id: "s2", 이름: "단면", 도면: { format: 6, entities: [table("T2")], layers: [] } }],
};
const dirt = [];
const host = node("div");
const sheets = drawingMod.mountStructureSheets(host, doc, {
name: "rv", layer: "system", noArea: true, frame: true, onChanged: (d) => dirt.push(d),
});
await new Promise((r) => setTimeout(r, 5));
const bar = host.kids[0].kids.find((k) => k.className === "cad-tabs");
const plus = bar.kids.find((k) => k.className === "cad-tabs__add");
await plus.on.click();
await new Promise((r) => setTimeout(r, 5));
inCad = { ...inCad, entities: [...inCad.entities, { type: "Circle" }] }; // 새 장에 하나 그림
const third = bar.kids.find((k) => k.dataset.tabId && k.dataset.active === "true").dataset.tabId;
await bar.kids.find((k) => k.dataset.tabId === "#1").on.click();
await new Promise((r) => setTimeout(r, 5));
out.updated = await sheets.updateTable("T2", [[{ text: "새" }]]);
const got = await sheets.getDoc();
out.m02 = {
first: got.도면.entities.length,
more: got.더한장.map((s) => [s.이름, s.도면.entities.length]),
t2: got.더한장[0].도면.entities[0].shapeData.cells[0][0].text,
newId: got.더한장[1].장id === third,
scopes: cadLoads,
active: sheets.activeSheet(),
dirty: dirt.at(-1),
number: drawingMod.sheetNumber("SD-1", 2),
frames: urls,
};
console.log(JSON.stringify(out));
})();
"""
@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.strip().splitlines()[-1])
def test_탭_셋_왕복_엔티티_수_그대로_고친_장만_표시(got):
# b 는 들를 때마다 하나씩 더 그림(+1 두 번) · a · c 는 그대로
assert got["roundtrip"] == [["a", 3, False], ["b", 7, True], ["c", 0, False]]
assert got["shownOrder"] == ["b", "c", "a", "b", "a"]
def test_고정_탭_앞으로는_못_끎(got):
assert got["order"] == ["a", "c", "b"]
assert got["fixedDraggable"] is False and got["fixedX"] == 1
def test_지우기는_물음_뒤_다음_탭을_실음(got):
assert got["afterRemove"] == {"ids": ["a", "b"], "active": "b", "confirms": 1}
def test_닫기_기능은_미저장_탭만_물음(got):
b07 = got["b07"]
assert b07["log"][:3] == [
["show", "d1", None, False],
["show", "d2", None, False],
["show", "d1", "편집본", True], # 받아 둔 편집본으로 돌아옴
]
assert b07["asked"] == 1 and ["closed", "d2"] in b07["log"] and b07["ids"] == ["d1"]
assert b07["last"] == 1
def test_B07_목록에서_연_도면이_탭_미저장_편집_지킴():
page = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text("utf-8")
assert "features: { close: true }," in page # [+] · 지우기 없음(서버 길 없음)
assert "dirty: cadDirty }" in page # 떠날 때 받아 둠
assert "tab.dirty ? tab.drawing : undefined" in page # 돌아오면 받아 둔 편집본
assert "cadDirty = !!stash;" in page and "cad.load(stash ?? response.drawing" in page
assert "if (!cadDirty && !tabs.anyDirty()) return true;" in page
assert "void openTab(drawings[target]);" in page # 이전 · 다음 = 그 도면 탭
def test_여러_장_문서_장_도번_백업_칸(got):
m02 = got["m02"]
assert m02["first"] == 2
assert m02["more"] == [["단면", 1], ["3장", 1]]
assert m02["newId"] is True and m02["active"] == ""
assert m02["scopes"][0] == "m02:system:rv" and m02["scopes"][1].startswith("m02:system:rv#s")
assert m02["number"] == "SD-1-3"
q = "/api/m02/structure-frame?number="
assert m02["frames"] == [q + "SD-1", q + "SD-1-3"] # 장 도번마다 한 번(1장은 다시 안 받음)
assert m02["dirty"] is True # 장 더함 = 저장 안 한 고침
def test_안_실린_장_표도_갈음(got):
assert got["updated"] == 2 # 받아 둔 둘째 장 1 + 실린 장(가짜 CAD) 1
assert got["m02"]["t2"] == "새"
def test_카드는_1장_그리고_장_수():
card = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts").read_text("utf-8")
assert "const sheets = 1 + (doc.더한장?.length ?? 0);" in card
assert "badge.textContent = `장 ${sheets}`;" in card
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
assert '"mountStructureSheets"' in main
assert '...(edited as Pick<StructureDoc, "도면" | "더한장">),' in main