"""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 = []; const ev = []; let inCad = null; const fakeCad = { element: node("div"), beginLoading() {}, load: (drawing, meta, frameEdit, fields, extra) => (ev.push("load"), cadLoads.push(extra.recoveryScope), (inCad = drawing)), requestSave: async () => ({ drawing: inCad, quantityTable: null }), placeTable: (entity, link) => ev.push("place " + link), 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)); // 보낸 표를 놓기 전에 탭을 바꾸면 그 장에서 다시 놓기(싣기 뒤) · 놓은 뒤에는 안 걺 const tabNodes = () => bar.kids.filter((k) => k.dataset.tabId); ev.length = 0; sheets.placeTable(table("T9"), "T9"); await tabNodes()[1].on.click(); await new Promise((r) => setTimeout(r, 5)); out.replace = ev.slice(); inCad = { ...inCad, entities: [...inCad.entities, table("T9")] }; // 놓음 ev.length = 0; await tabNodes()[0].on.click(); await new Promise((r) => setTimeout(r, 5)); out.placedOnce = ev.slice(); 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"] == [["단면", 2], ["3장", 1]] # 단면에 T9 표를 놓음 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", q + "SD-1-2"] assert m02["dirty"] is True # 장 더함 = 저장 안 한 고침 def test_놓기_전_탭을_바꾸면_그_장에_다시_놓기(got): assert got["replace"] == ["place T9", "load", "place T9"] # 싣기 뒤에 다시 assert got["placedOnce"] == ["load"] 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),' in main