"""면적 상세 오른쪽 도면 패널 — 저장 전 실시간 표 값(PLAN 112-1) — Node 로 TS 를 돌려 판정. 못박는 것 - 브라우저 `areaTable`(`spreadsheet_area_table.ts`)이 서버 Node 번들 `area_tables` 와 같은 표 · 값 · 까닭을 냄 (한 벌의 코드 — 서버 파일이 그 파일을 가져다 씀). - `AreaDrawing.sync` — 패널이 펼쳐진 동안만 · 값이 바뀐 연결 표만 · CAD 연결 표 갈기를 보기 전용 갈음(preview)으로 · 이름이 지워진 표는 건너뜀 · 보내기 취소한 연결은 뺌 · 접었다 펴면 따라잡음 · 도면 문서는 안 건드림. """ import json import shutil import subprocess from pathlib import Path import pytest from common_util import common_util_spreadsheet as sheet_node ROOT = Path(__file__).resolve().parents[2] SHEET = { "id": "s1", "이름": "면적산출", "칸": { "A1": {"값": "제목", "서식": 2}, "B1": {"값": 1234.5, "서식": 1}, "A2": {"값": "합"}, "B2": {"식": "B1*2", "서식": 1}, "A3": {"값": "숨김행"}, "C1": {"값": "x"}, }, "병합": ["A1:A2", "C1:E5"], "행": {"3": {"숨김": True}}, "열": {"D": {"숨김": True}, "B": {"폭": 12}}, "이름정의": [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}], } BOOK = { "종류": "통합문서", "판": 1, "열": "rv", "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}], "시트": [SHEET], "활성": "s1", } ASKS = [ {"시트": "면적산출", "범위": "A1:B2"}, {"시트": "s1", "범위": "A1:D4", "글자크기": 5}, {"시트": "s1", "범위": "", "이름": "도면_T1"}, {"시트": "s1", "범위": "A1", "이름": "도면_없음"}, {"시트": "없음", "범위": "A1"}, {"시트": "s1", "범위": "가나"}, {"시트": "s1", "범위": "A3"}, ] LOADER = r""" const fs = require("fs"); const path = require("path"); const ROOT = process.argv[1]; const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); const cache = new Map(); function loadTs(file, stubs = {}) { const full = path.resolve(file); if (cache.has(full)) return cache.get(full).exports; const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, }).outputText; const mod = { exports: {} }; cache.set(full, mod); const req = (r) => { if (r in stubs) return stubs[r]; if (r.endsWith(".css")) return {}; if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), `${r}.ts`)); return require(r); }; new Function("exports", "module", "require", js)(mod.exports, mod, req); return mod.exports; } const SS = (name) => path.join(ROOT, "A00_Common", "spreadsheet", `${name}.ts`); """ TABLE_SCRIPT = ( LOADER + r""" const { areaTable } = loadTs(SS("spreadsheet_area_table")); const { book, asks } = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); const out = asks.map((ask) => { try { return { ok: true, ...areaTable({ book: structuredClone(book), ...ask }) }; } catch (e) { return { ok: false, 까닭: e.message }; } }); console.log(JSON.stringify(out)); """ ) LIVE_SCRIPT = ( LOADER + r""" const base = JSON.parse(process.argv[2]); const book = (cells = {}) => ({ ...base, 시트: [{ ...base.시트[0], 칸: { ...base.시트[0].칸, ...cells } }] }); const rows = [ { 연결: "T1", 시트: "s1", 범위: "A1:B2", 이름: "도면_T1", 글자크기: 2.5, 값: {} }, { 연결: "T2", 시트: "s1", 범위: "A1:A1", 이름: "도면_없음", 글자크기: 2.5, 값: {} }, { 연결: "T3", 시트: "s1", 범위: "B1:B1", 글자크기: 2.5, 값: {} }, ]; const same = (a = {}, b = {}) => JSON.stringify(a) === JSON.stringify(b); const calls = []; const handle = { updateTable: async (link, cells, preview) => (calls.push({ link, cells, preview }), 1), destroy() {}, }; const fake = (tag, o = {}) => ({ tag, dataset: {}, ...o, append() {}, replaceChildren() {}, querySelector: () => null, remove() {}, addEventListener() {} }); globalThis.window = { addEventListener() {}, removeEventListener() {}, location: { origin: "" }, setTimeout, clearTimeout }; const { createAreaDrawing } = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_AreaDrawing.ts"), { "@ui/ui_template_elements": { createButton: (o) => fake("button", o), el: fake }, "./M02_MasterTemplete_Api_Fetch": { readTemplate: async () => ({ 문서: { 도면: { entities: [] }, 연결표: rows } }) }, "./M02_MasterTemplete_Drawing": { mountStructureSheets: () => handle }, "./M02_MasterTemplete_AreaToDrawing": { sameValues: same }, }); const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const take = () => calls.splice(0).map((c) => [c.link, c.preview, c.cells.at(-1)[0]?.text ?? null]); (async () => { const cancelled = new Set(); const ad = createAreaDrawing("system", "rv", { cancelled: () => cancelled }); const log = {}; ad.sync(() => book()); await wait(400); log.closed = take(); // 패널이 한 번도 안 열림 — 건너뜀 ad.show(); await wait(500); log.opened = take(); // 열리면 따라잡음 — 이름이 지워진 T2 는 건너뜀 ad.sync(() => book()); await wait(400); log.same = take(); // 같은 값 — 도면에 안 보냄 ad.sync(() => book({ B1: { 값: 10, 서식: 1 } })); await wait(400); log.edited = take(); // 값이 바뀐 표만 ad.hide(); ad.sync(() => book({ B1: { 값: 20, 서식: 1 } })); await wait(400); log.hidden = take(); // 접힌 동안은 건너뜀 ad.show(); await wait(400); log.again = take(); // 다시 펴면 따라잡음 cancelled.add("T1"); ad.sync(() => book({ B1: { 값: 30, 서식: 1 } })); await wait(400); log.cancelled = take(); // 보내기 취소한 연결은 뺌 ad.destroy(); console.log(JSON.stringify(log)); })(); """ ) @pytest.fixture(scope="module") def node() -> str: if shutil.which("node") is None: pytest.skip("node 가 없음") return "node" def test_브라우저_표_변환은_서버_번들과_같은_표_값_까닭(node, tmp_path): src = tmp_path / "ask.json" src.write_text(json.dumps({"book": BOOK, "asks": ASKS}, ensure_ascii=False), encoding="utf-8") proc = subprocess.run( [node, "-e", TABLE_SCRIPT, str(ROOT), str(src)], capture_output=True, text=True, encoding="utf-8", ) assert proc.returncode == 0, proc.stderr browser = json.loads(proc.stdout.strip().splitlines()[-1]) server = sheet_node.area_tables([{"book": BOOK, **ask} for ask in ASKS]) assert browser == server assert [x["ok"] for x in browser] == [True, True, True, False, False, False, False] assert browser[2]["범위"] == "B1:C2" # 이름정의가 가리키는 지금 범위 def test_서버_번들은_그_파일을_가져다_씀(): src = (ROOT / "common_util/common_util_spreadsheet_node.ts").read_text("utf-8") assert 'from "../A00_Common/spreadsheet/spreadsheet_area_table"' in src assert "function areaTable" not in src # 두 벌로 갈라지지 않음 @pytest.fixture(scope="module") def live(node): proc = subprocess.run( [node, "-e", LIVE_SCRIPT, str(ROOT), json.dumps(BOOK, ensure_ascii=False)], capture_output=True, text=True, encoding="utf-8", ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout.strip().splitlines()[-1]) def test_패널이_닫혀_있으면_건너뜀(live): assert live["closed"] == [] def test_열리면_따라잡음_보기_전용_갈음_이름_지워진_표는_건너뜀(live): # [연결, preview, 마지막 줄 첫 칸 글] — T1 = B1:C2 이름 범위 · T3 = B1 한 칸 assert [x[:2] for x in live["opened"]] == [["T1", True], ["T3", True]] assert live["opened"][0][2] == "2,469.00" def test_같은_값이면_안_보내고_바뀐_표만_보냄(live): assert live["same"] == [] assert [x[0] for x in live["edited"]] == ["T1", "T3"] # B1 이 T1 · T3 범위 안 assert live["edited"][0][2] == "20.00" # 식 칸 B2 = B1*2 def test_접은_동안은_건너뛰고_다시_펴면_따라잡음(live): assert live["hidden"] == [] assert [x[0] for x in live["again"]] == ["T1", "T3"] assert live["again"][0][2] == "40.00" def test_보내기_취소한_연결은_뺌(live): assert [x[0] for x in live["cancelled"]] == ["T3"]