diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index b39d8309b..c27d84018 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -352,18 +352,21 @@ export function createMain( async function mount(doc: unknown, at: Selection): Promise { const readOnly = !writable(at); + const mine = seq; // 모듈 · 집계표 번호를 기다리는 사이 다른 화면(옵션 정의 등)이 열렸으면 그만둠 host.replaceChildren(); if (at.kind === "table") { const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( SHEET_MODS, "createSheet", ); + if (mine !== seq) return; if (!create) return empty(L("M02_NoSheet")); // 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 줄에 번호 · 칸을 고르면 아래 컨테이너 · // 고칠 수 있으면 일위대가 · 도면 · 면적산출 칸은 조회 후 선택 const struct = at.layer !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null; if (struct) await struct.reload(); + if (mine !== seq) return struct?.destroy(); panel = struct; // ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이 // 헐린 뒤에도 안 떼인 이벤트가 이 줄을 부르면 sheet?.getDoc() 가 undefined 라 @@ -417,6 +420,7 @@ export function createMain( // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 · // 단가승격 · 사전조합(50-3) = 같은 상세 모드(도면 · 면적 · 자재 없음 · 이름 = 키) const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); + if (mine !== seq) return; const mode = isPrecombo(at.kind) ? at.kind : undefined; const labor = mountLaborDetail(host, doc, { layer: at.layer, @@ -447,6 +451,7 @@ export function createMain( DRAWING_MODS, at.kind === "structure" ? "mountStructureSheets" : "mountDrawingTemplate", ); + if (mine !== seq) return; if (mountDrawing) { editor = mountDrawing(host, doc, { onSave: () => void doSave(), diff --git a/resources/tester/test_197_1_dashboard_option_card.py b/resources/tester/test_197_1_dashboard_option_card.py new file mode 100644 index 000000000..44e89195e --- /dev/null +++ b/resources/tester/test_197_1_dashboard_option_card.py @@ -0,0 +1,139 @@ +"""197-1 대시보드 「구조물 옵션」 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 사용자. + +원인 — 진입 해시 `open=옵션` 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면(집계표)의 `mount` 가 +모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 집계표를 덮음(`mount` 에 낡음 검사 없었음). + +· 카드 셋 → 해시(open=구조물조합 · 옵션 · 도면) · 해시 → 진입(`entryFromHash`) Node 로 바로 돌림 +· `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함 +· `mount` 집계표 갈래(진짜 소스 조각)를 Node 로 돌림 — 기다리는 사이 다른 화면이 열리면 그리지 않음 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +sys.path.insert(0, str(Path(__file__).parent)) + +NODE = ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"] +needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _run(script: str, *args: str) -> object: + result = subprocess.run( + [*NODE, "-e", script, *args], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@needs_node +def test_카드_셋_해시(): + from test_b01_settings_cards import _cards + + cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) + assert [c[2].split("?")[1].split("&")[0] for c in cards[1:]] == [ + "open=구조물조합", + "open=옵션", + "open=도면", + ] + assert all("&root=master" in c[2] for c in cards[1:]) + + +@needs_node +def test_해시_진입_셋(): + layer = _text(M02 / "M02_MasterTemplete_Layer.ts") + fn = re.search(r"^export function entryFromHash\([\s\S]*?^}", layer, re.M) + assert fn, "entryFromHash 를 못 찾음" + opens = re.search(r"^const OPENS[^\n]*", layer, re.M) + roots = re.search(r"^const ROOTS[^\n]*", layer, re.M) + assert opens and roots + script = ( + "const currentQuery = () => new URLSearchParams(process.argv[1]);\n" + "const defaultRoot = () => 'master';\n" + "type Root = string; type OpenTarget = string; type Entry = any; type SessionUser = any;\n" + f"{opens.group(0)}\n{roots.group(0)}\n" + + fn.group(0).replace("export function", "function") + + "\nconsole.log(JSON.stringify(entryFromHash(null)));" + ) + for want in ("구조물조합", "옵션", "도면"): + got = _run(script, f"open={want}&root=master&company=3") + assert got["open"] == want and got["root"] == "master" and got["company"] == "3" + + +def test_reveal_옵션만_줄을_누름(): + side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") + body = side.split("const reveal = ")[1].split("\n };")[0] + assert "if (!sec) return;" in body # 시스템 층 아니면 옵션 · 도면 줄 없음 + assert 'sec.classList.remove("is-collapsed");' in body # 도면 = 펼치기만 + assert ( + 'if (target === "옵션") options.body.querySelector("button")?.click();' in body + ) + # 옵션 컨테이너 안 단추 = 「구조물 옵션 정의」 줄 하나뿐 + options = _text(M02 / "StructOptions" / "M02_SO_Options.ts") + assert options.count('el("button"') == 1 + assert "openDefScreen" in options + # 옵션 컨테이너 = 구조물 옵션 정의 줄 · 시스템 층에만 + assert "옵션: section(options.title, options.body)," in side + page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") + assert 'if (entry.open !== "구조물조합") side.reveal(entry.open);' in page + + +@needs_node +def test_집계표_갈래_기다리는_사이_다른_화면이_열리면_그리지_않음(): + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + start = main.index(" async function mount(doc: unknown, at: Selection)") + stop = main.index(' if (at.kind === "labor" || isPrecombo(at.kind)) {', start) + table = main[start:stop] + "\n}\n" + assert table.count("if (mine !== seq)") == 2 # 모듈 받은 뒤 · 번호 받은 뒤 + script = ( + "type Selection = any; type EditorHandle = any; type SheetEditorHandle = any;\n" + "type StructureColumn = any;\n" + "let seq = 0; let panel: any = null; let editor: any = null; let sheet: any = null;\n" + "let dirty = false; const log: string[] = [];\n" + "const STRUCTURE_TABLE = 'T'; const SHEET_MODS = {}; const L = (k: string) => k;\n" + "const loaded: any = null; const extras: any = {};\n" + "const host = { replaceChildren() {} }; const writable = () => true;\n" + "const empty = (m: string) => log.push('empty:' + m);\n" + "const comboView = (d: any) => d; const comboMerge = (_: any, d: any) => d;\n" + "const laborSumText = () => ''; const laborSumState = () => '';\n" + "const gate = (): [Promise, () => void] => {\n" + " let go = () => {}; return [new Promise((r) => (go = r)), () => go()];\n" + "};\n" + "let modGate = gate(); let numGate = gate();\n" + "const findExport = async () => {\n" + " await modGate[0]; return (_h: any, _d: any, _o: any) => (log.push('create')," + " { destroy() {}, getDoc: () => ({}) });\n" + "};\n" + "const buildPanel = () => ({ root: {}, numbers: () => ({}), async reload() {\n" + " await numGate[0]; }, dirty: () => false, destroy: () => log.push('panel.destroy') });\n" + + table + + "\nasync function run(bumpAt: string): Promise {\n" + " seq = 1; log.length = 0; panel = null; modGate = gate(); numGate = gate();\n" + " const going = mount({}, { layer: 'system', kind: 'table', name: STRUCTURE_TABLE," + " projectId: null });\n" + " if (bumpAt === 'mod') seq++;\n" + " modGate[1](); await new Promise((r) => setTimeout(r, 5));\n" + " if (bumpAt === 'num') seq++;\n" + " numGate[1](); await going; return [...log, 'panel:' + (panel ? 'set' : 'null')];\n" + "}\n" + "console.log(JSON.stringify({ none: await run('none'), mod: await run('mod')," + " num: await run('num') }));" + ) + got = _run(script) + assert got["none"] == ["create", "panel:set"] # 안 끼어들면 그대로 그림 + assert got["mod"] == ["panel:null"] # 모듈 기다리는 사이 다른 화면 — 그리지 않음 + assert got["num"] == ["panel.destroy", "panel:null"] # 번호 기다리는 사이 — 패널도 버림