diff --git a/resources/tester/_pdf_gap_report.py b/resources/tester/_pdf_gap_report.py deleted file mode 100644 index ce459c148..000000000 --- a/resources/tester/_pdf_gap_report.py +++ /dev/null @@ -1,172 +0,0 @@ -# -*- coding: utf-8 -*- -"""건설 PDF ↔ md 구멍을 **장별 목록 파일**로 뽑는다 — 2026-09-18 브레인 B18. - -왜 — 구멍 총량(`test_const_pdf_coverage.py`)만으로는 누가 무엇을 고칠지 모른다. -**쪽 · 표 제목 · 줄 글자**를 담아 장별로 나눠 두면 md 를 고치는 창(랩탑서브·안티그래비티)이 -제 몫만 열어 고칠 수 있다. - -담는 것 - - **결손** — PDF 줄에 있는 값이 md 에 없음. 줄 글자를 그대로 적어 어디를 옮겨 적을지 보임. - - **허구** — md 줄에만 있는 값. 대개 **붙은 줄**(`0230-00020.23,0008900.90`)이라 같은 자리에서 - 결손과 짝을 이룬다 — 그 줄을 풀면 둘이 함께 사라진다. - -⚠ 값 셈은 `test_const_pdf_coverage.py` 의 잣대를 그대로 쓴다(두 벌 금지). -돌리기: `./venv/Scripts/python.exe resources/tester/_pdf_gap_report.py` -""" - -from __future__ import annotations - -import collections -import importlib.util -import re -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -OUT = ROOT / "docs/raw/verification/2026-09-18_건설PDF대조" - -_spec = importlib.util.spec_from_file_location( - "cov", Path(__file__).with_name("test_const_pdf_coverage.py") -) -cov = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(cov) - -#: 쪽 머리의 장 이름 — 「제8장 건설기계」·「공통부문」. -_CHAPTER = re.compile(r"제\s*(\d+)\s*장\s*([가-힣]{2,10})") -#: 표 제목 자리 — 「(0230) 대형 브레이커」·「8-3-1 …」·「2. 전동환산(q 표)」. -#: ⚠ **글자가 있어야 제목**이다 — 수만 늘어선 자료 줄을 제목으로 잡으면 목록이 헷갈린다. -_CAPTION = re.compile(r"^\(?\d[\d\-.]*\)?[.\s]*[가-힣A-Za-z]") -_HANGUL = re.compile(r"[가-힣]") - - -def _missing_lines(limit_pages: int | None = None) -> dict[str, list[dict]]: - """장별 결손 줄 — `{장: [{쪽, 제목, 줄, 빠진값}]}`.""" - import pymupdf - - left = collections.Counter(cov.md_numbers()) # 아직 안 쓴 md 값 - found: dict[str, list[dict]] = collections.defaultdict(list) - chapter = "장 모름" # 쪽 머리가 없는 쪽은 앞 쪽의 장을 잇는다 - with pymupdf.open(cov.PDF) as doc: - for number, page in enumerate(doc, start=1): - if limit_pages and number > limit_pages: - break - rows = cov._page_rows(page) - # ⚠ **쪽 머리를 먼저 훑는다** — 쪽 머리가 줄 차례의 첫 줄이 아니라서(좌표 정렬) - # 차례대로 보면 앞 줄들이 「장 모름」으로 떨어진다. - for line in rows: - if m := _CHAPTER.search(line): - chapter = f"제{m.group(1)}장 {m.group(2)}".strip() - break - caption = "" - for line in rows: - if cov._LEADER.search(line): - continue - tokens = [ - cov._norm(x) for x in cov._NUM.findall(line) if cov._valuish(cov._norm(x)) - ] - if not tokens or cov._ONLY_NUMBER.fullmatch(line.strip()): - # 값이 없는 줄은 **표 제목 후보**로 기억한다 - text = line.strip() - if _CAPTION.match(text) and len(_HANGUL.findall(text)) >= 2: - caption = text[:60] - continue - missing = [] - for token in tokens: - if left[token] > 0: - left[token] -= 1 - else: - missing.append(token) - if missing and not cov._PAGE_HEAD.search(line): # 쪽 머리 줄은 목록에서 가림 - found[chapter].append( - {"쪽": number, "제목": caption, "줄": line.strip()[:120], "빠진값": missing} - ) - return found - - -def _invented_lines() -> dict[str, list[dict]]: - """장별 허구 줄 — md 에만 있는 값이 든 줄(대개 붙은 줄).""" - left = collections.Counter(cov.pdf_numbers()[0]) - found: dict[str, list[dict]] = collections.defaultdict(list) - chapter = "장 모름" - for number, line in enumerate(cov.MD.read_text(encoding="utf-8").splitlines(), start=1): - if m := _CHAPTER.search(line): - chapter = f"제{m.group(1)}장 {m.group(2)}".strip() - if cov._LEADER.search(line): - continue - tokens = [cov._norm(x) for x in cov._NUM.findall(line) if cov._valuish(cov._norm(x))] - if not tokens or cov._ONLY_NUMBER.fullmatch(line.strip()): - continue - extra = [] - for token in tokens: - if left[token] > 0: - left[token] -= 1 - else: - extra.append(token) - # 쪽 머리가 본문에 붙은 줄(`173제8장 건설기계`)은 고칠 거리가 아니라 가린다 - if extra and not (cov._PAGE_HEAD.search(line) and len(line.strip()) < 30): - found[chapter].append({"md줄": number, "줄": line.strip()[:160], "없는값": extra}) - return found - - -def write() -> dict[str, int]: - missing, invented = _missing_lines(), _invented_lines() - OUT.mkdir(parents=True, exist_ok=True) - for stale in OUT.glob("*.md"): # 다시 뽑을 때 옛 목록이 남지 않게 — 이 폴더는 이 스크립트 것 - stale.unlink() - counts: dict[str, int] = {} - for chapter in sorted(set(missing) | set(invented)): - safe = re.sub(r"[^\w가-힣]+", "_", chapter).strip("_") or "장모름" - rows_missing, rows_invented = missing.get(chapter, []), invented.get(chapter, []) - counts[chapter] = len(rows_missing) + len(rows_invented) - lines = [ - f"# {chapter} — PDF ↔ md 구멍 (2026-09-18 뽑음)", - "", - f"결손 줄 {len(rows_missing)} · 허구 줄 {len(rows_invented)}", - "", - "## 결손 — PDF 에 있는데 md 에 없음", - "", - "| 쪽 | 표 제목 | PDF 줄 | 빠진 값 |", - "| --- | --- | --- | --- |", - ] - for row in rows_missing: - line = row["줄"].replace("|", "·") - lines.append( - f"| {row['쪽']} | {row['제목'].replace('|', '·')} | {line} | {' '.join(row['빠진값'])} |" - ) - lines += [ - "", - "## 허구 — md 에만 있음 (대개 붙은 줄 · 위 결손과 같은 자리)", - "", - "| md 줄 | 줄 글자 | PDF 에 없는 값 |", - "| --- | --- | --- |", - ] - for row in rows_invented: - lines.append( - f"| {row['md줄']} | {row['줄'].replace('|', '·')} | {' '.join(row['없는값'])} |" - ) - (OUT / f"{safe}.md").write_text("\n".join(lines) + "\n", encoding="utf-8") - index = [ - "# 건설 PDF ↔ md 구멍 — 장별 목록 (2026-09-18 뽑음)", - "", - "쓰는 법 — 제 장 파일을 열어 **결손** 줄을 PDF 그 쪽에서 확인해 md 에 옮겨 적고,", - "**허구** 줄(대개 붙은 줄)은 PDF 대로 풀어 적는다. 고친 뒤 다시 뽑으면 줄어든다.", - "", - "```", - "다시 뽑기 ./venv/Scripts/python.exe resources/tester/_pdf_gap_report.py", - "총량 시험 ./venv/Scripts/python.exe -m pytest resources/tester/test_const_pdf_coverage.py", - "```", - "", - "| 장 | 줄 수 | 파일 |", - "| --- | --- | --- |", - ] - for chapter, count in sorted(counts.items(), key=lambda kv: -kv[1]): - name = re.sub(r"[^\w가-힣]+", "_", chapter).strip("_") or "장모름" - index.append(f"| {chapter} | {count} | [{name}.md]({name}.md) |") - (OUT / "000_목차.md").write_text("\n".join(index) + "\n", encoding="utf-8") - return counts - - -if __name__ == "__main__": - got = write() - for chapter, count in sorted(got.items(), key=lambda kv: -kv[1]): - print(f"{count:5d} {chapter}") - print(f"합 {sum(got.values())} 줄 · {OUT.relative_to(ROOT)}") diff --git a/resources/tester/diag_structures.py b/resources/tester/diag_structures.py deleted file mode 100644 index 9a1f8198c..000000000 --- a/resources/tester/diag_structures.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8 -*- -"""신규 프로젝트에서 구조물이 안 보이는 원인 진단 — 읽기 전용. - -실행: ./venv/Scripts/python.exe resources/tester/diag_structures.py [프로젝트UUID] -인자를 생략하면 storage 아래에서 **가장 최근에 만들어진** 프로젝트를 고른다. - -자동설계 체인이 남겨야 할 것을 한 줄씩 대조한다: - 1. 배관 정본(edits/pipe_points.json) — 관·세월교·BOX 지정 - 2. 종단 정본 stations[].structure — 구조물 측점 표식 - 3. 횡단 정본 cross_*.json 의 structure — 측점 파일 - 4. B06 읽기 경로(load_culvert_sets) — 실제로 세트가 붙는가 - 5. 초기값 스냅샷 — 체인 직후 상태가 떠졌는가 -""" - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - - -def newest_project() -> Path | None: - candidates = [p for p in (ROOT / "storage").glob("*/*/*") if (p / "project_manifest.json").is_file()] - return max(candidates, key=lambda p: p.stat().st_mtime) if candidates else None - - -def read_json(path: Path): - try: - return json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - - -def main() -> int: - if len(sys.argv) > 1: - matches = [p for p in (ROOT / "storage").glob(f"*/*/{sys.argv[1]}")] - project = matches[0] if matches else None - else: - project = newest_project() - if project is None or not project.is_dir(): - print("프로젝트를 찾지 못했습니다.") - return 1 - print(f"프로젝트: {project}") - - # 1) 배관 정본 - pipes_path = project / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json" - pipes = read_json(pipes_path) - points = (pipes or {}).get("points") or [] - print(f"1) pipe_points.json 존재={pipes_path.is_file()} 관={len(points)}건") - for point in points: - print( - f" - {point.get('chainage_m')}m facility={point.get('facility') or 'pipe'}" - f" options={list((point.get('options') or {}).keys())}" - ) - if pipes: - print(f" route_signature={str(pipes.get('route_signature'))[:16]}…") - - # 2) 종단 정본 구조물 표식 - long_path = project / "B06_Section" / "longitudinal" / "longitudinal.json" - longitudinal = read_json(long_path) or {} - stations = longitudinal.get("stations") or [] - marked = [s for s in stations if s.get("structure")] - print(f"2) longitudinal.json 측점={len(stations)} 구조물 표식={len(marked)}건") - for station in marked: - print(f" - {station.get('chainage_m')}m {station.get('structure')} kind={station.get('kind')}") - - # 3) 횡단 정본 파일 - cross_dir = project / "B06_Section" / "cross_sections" - files = sorted(cross_dir.glob("cross_*.json")) if cross_dir.is_dir() else [] - with_structure = [(p.name, read_json(p).get("structure")) for p in files if (read_json(p) or {}).get("structure")] - print(f"3) cross_*.json 파일={len(files)}개 구조물 표식={len(with_structure)}건 {with_structure}") - - # 4) B06 읽기 경로 — 실제로 세트가 붙는가 - from B06_Section.B06_Section_Engine_Culvert_Sets import load_culvert_sets - - sets = load_culvert_sets(project) - print(f"4) load_culvert_sets 세트={len(sets)}건") - for chainage, spec in sorted(sets.items()): - print(f" - {chainage}m kind={spec.get('kind')}") - # 측점과 붙을 수 있는가 (허용오차 0.02m) - chainages = [float(s.get("chainage_m", -1)) for s in stations] - for chainage in sorted(sets): - hit = any(abs(chainage - c) <= 0.02 for c in chainages) - if not hit: - print(f" ⚠ {chainage}m 에 붙을 측점이 없다 — 횡단도에 구조물이 안 뜬다") - - # 5) 초기값 스냅샷 - snapshot = project / "initial_snapshot" - snap_pipes = read_json(snapshot / "B04_PreProcess__drainage__edits" / "pipe_points.json") - print( - f"5) initial_snapshot 존재={snapshot.is_dir()}" - f" 스냅샷 관={len((snap_pipes or {}).get('points') or [])}건" - ) - lock = project / "initial_design.lock" - print(f" initial_design.lock(체인 진행 중 표시)={lock.exists()}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/resources/tester/helper_313_clear_all.cjs b/resources/tester/helper_313_clear_all.cjs deleted file mode 100644 index 118801d67..000000000 --- a/resources/tester/helper_313_clear_all.cjs +++ /dev/null @@ -1,183 +0,0 @@ -/* 313 [구조물 전체 삭제] 헬퍼 — 진짜 TS(목록 그리기 · 입력 배선)를 Node 로 돌려 표준출력 JSON 한 벌로. - * 확인창(@ui showConfirmDialog)은 답을 정해 둔 가짜로 막음. - */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) - -const ROOT = path.join(__dirname, "..", ".."); -const cache = new Map(); -const confirms = []; -let answer = true; -const UI = { - showConfirmDialog: async (message, ok) => { - confirms.push([message, ok]); - return answer; - }, - showToast: () => {}, -}; -const load = (file) => { - if (cache.has(file)) return cache.get(file); - const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(file, box.exports); - const near = (m) => { - if (CATALOG.isCatalogRequest(m)) return CATALOG; - if (m.startsWith("@ui/")) return UI; - if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); - if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); - if (m.endsWith(".json")) return require(path.join(path.dirname(file), m)); - if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); - return new Proxy({}, { get: () => () => {} }); - }; - new Function("exports", "module", "require", js)(box.exports, box, near); - return box.exports; -}; - -// DOM 흉내 — 목록이 쓰는 것만 -class Node { - constructor(tag) { - this.tag = tag; - this.children = []; - this.className = ""; - this.textContent = ""; - this.style = { setProperty() {} }; - this.listeners = {}; - this.title = ""; - } - get classList() { - const names = () => this.className.split(" ").filter(Boolean); - return { - add: (c) => (this.className = [...names(), c].join(" ")), - toggle: (c, on) => { - const rest = names().filter((n) => n !== c); - this.className = (on ? [...rest, c] : rest).join(" "); - }, - contains: (c) => names().includes(c), - }; - } - append(...kids) { - this.children.push(...kids); - } - replaceChildren(...kids) { - this.children = [...kids]; - } - addEventListener(type, fn) { - this.listeners[type] = fn; - } - scrollIntoView() {} -} -globalThis.document = { createElement: (tag) => new Node(tag), activeElement: null }; -globalThis.requestAnimationFrame = (fn) => fn(); -const memory = new Map(); -globalThis.window = { - sessionStorage: { - getItem: (k) => memory.get(k) ?? null, - setItem: (k, v) => memory.set(k, String(v)), - removeItem: (k) => memory.delete(k), - }, - requestAnimationFrame: (fn) => fn(), -}; -globalThis.sessionStorage = globalThis.window.sessionStorage; - -const B05 = (f) => load(path.join(ROOT, "B05_Profile", `${f}.ts`)); -const { renderStructureList } = B05("B05_Profile_UI_Structures_List"); -const { bindStructuresEvents } = B05("B05_Profile_UI_Structures_Panel_Events"); - -const typeMap = new Map([ - ["sign", { type_id: "sign", name: "표지", group: "E" }], - ["pipe", { type_id: "pipe", name: "배수관", group: "A" }], -]); -const sign = { structure_id: "s1", type_id: "sign", placement: "point", chainage_m: 40 }; -const pipe = { chainage_m: 80, facility: "pipe", source: "auto" }; -const draw = (structures, pipes, withClear = true) => { - const list = new Node("ul"); - let cleared = 0; - renderStructureList({ - list, - structures, - pipeFacilities: pipes, - typeMap, - intervalM: 20, - editingId: null, - selectedPipeChainage: null, - onSelectStructure: () => {}, - onSelectPipe: () => {}, - ...(withClear ? { onClearAll: () => (cleared += 1) } : {}), - }); - const last = list.children.at(-1); - const button = last?.children?.[0]; - const clear = last?.className === "b05-route__irregular-clear"; - if (clear) button.listeners.click(); - return { - rows: list.children.length, - lastClass: last?.className ?? null, - button: clear - ? { text: button.textContent, className: button.className, type: button.type } - : null, - cleared, - }; -}; -const listCases = { - filled: draw([sign], [pipe]), - empty: draw([], []), - noHandler: draw([sign], [pipe], false), -}; - -// 입력 배선 — 가짜 ctx(누름 듣개는 담기만) -const el = () => new Node("div"); -const makeCtx = (structures, pipes) => { - const calls = { reset: 0, release: 0, removed: [], emit: 0 }; - let pipeList = pipes.map((p) => ({ ...p })); - const ctx = { - groupSelect: el(), - typeSelect: el(), - primary: el(), - removeButton: el(), - resetButton: el(), - positionRow: el(), - memoField: el(), - optionRow: el(), - optionInputs: [], - anchorFields: { station: el(), remainder: el() }, - startFields: { station: el(), remainder: el() }, - endFields: { station: el(), remainder: el() }, - facilityOptions: { root: el() }, - structures, - pipeFacilities: () => pipeList, - projectId: "p", - callbacks: { - onListRelease: () => (calls.release += 1), - onPipeRemove: (at) => { - calls.removed.push(at); - pipeList = pipeList.filter((p) => p.chainage_m !== at); // 하나씩 빼기 길이 목록을 갈아 끼움 - }, - }, - resetForm: () => (calls.reset += 1), - emit: () => (calls.emit += 1), - }; - return { ctx, calls, left: () => structures.length + pipeList.length }; -}; -const runClear = async (structures, pipes, ok) => { - answer = ok; - confirms.length = 0; - const { ctx, calls, left } = makeCtx(structures, pipes); - const { clearAll } = bindStructuresEvents(ctx); - await clearAll(); - return { confirms: [...confirms], calls, left: left() }; -}; - -(async () => { - const pipes = [pipe, { chainage_m: 160.37, facility: "ford_pavement", source: "user" }]; - const result = { - listCases, - confirmed: await runClear([{ ...sign }, { ...sign, structure_id: "s2" }], pipes, true), - cancelled: await runClear([{ ...sign }], pipes, false), - pipesOnly: await runClear([], [pipe], true), - nothing: await runClear([], [], true), - }; - process.stdout.write(JSON.stringify(result)); -})(); diff --git a/resources/tester/helper_349_0g_draft_b06_loop.cjs b/resources/tester/helper_349_0g_draft_b06_loop.cjs deleted file mode 100644 index 9276b3628..000000000 --- a/resources/tester/helper_349_0g_draft_b06_loop.cjs +++ /dev/null @@ -1,235 +0,0 @@ -/* PLAN 349-0g sub7 헬퍼 — 노선 초안을 둔 채 횡단 보기(B06)가 묶음 새 노선 상세를 종단도에 그려도 묶음이 다시 - * 안 돎(고리 없음) · 누가토량 그대로. B06 흉내 = 묶음 profile · cross 알림마다 새 상세를 종단도에 render - * (`design_profile.render` → 처음부터 다시 · 유토곡선 설정 → 횡단 입력)과 같은 부름. - * 진짜 TS 를 트랜스파일해 Node 로 · 서버 창구만 가짜(`helper_349_pipeline_once` 와 같은 틀). - * 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_349_0g_draft_b06_loop.py`. */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const finalCumulative = require("./helper_454_final_cumulative.cjs"); -const CATALOG = require("./helper_322_catalog_module.cjs"); - -const ROOT = path.join(__dirname, "..", ".."); -const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); - -function memoryStorage() { - const map = new Map(); - return { - getItem: (key) => (map.has(key) ? map.get(key) : null), - setItem: (key, value) => map.set(key, String(value)), - removeItem: (key) => map.delete(key), - key: (index) => [...map.keys()][index] ?? null, - get length() { - return map.size; - }, - }; -} -globalThis.window = { - sessionStorage: memoryStorage(), - localStorage: memoryStorage(), - setTimeout, - clearTimeout, -}; - -// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 -globalThis.fetch = (url, init) => - new Promise((resolve, reject) => { - const timer = setTimeout( - () => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }), - 20, - ); - init.signal.addEventListener("abort", () => { - clearTimeout(timer); - reject(new Error("aborted")); - }); - }); - -const STUBS = {}; -const cache = new Map(); -function loadTs(full) { - if (cache.has(full)) return cache.get(full); - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (CATALOG.isCatalogRequest(request)) return CATALOG; - const key = Object.keys(STUBS).find((name) => request.endsWith(name)); - if (key) return STUBS[key]; - if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; - if (request.startsWith("@util/")) - return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); - if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); - return require(request); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} -const load = (...parts) => loadTs(path.join(ROOT, ...parts)); - -const sampler = load("common_util", "common_util_surface_sampler.ts"); -const bytes = fs.readFileSync(process.argv[3]); -const grid = sampler.parseDtmGrid( - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), -); -const local = { grid, design: "las" }; - -STUBS["B05_Profile_Api_Replan"] = { - analysisTerrain: () => "las", - fetchRoutePlan: async () => ({ - planned: job.line, - curves: job.curves, - min_radius_m: job.minRadius, - station_snap: job.stationSnap, - }), -}; -STUBS["B05_Profile_Api_Structures"] = { - fetchStructures: async () => ({ structures: [] }), - fetchStructureTypes: async () => job.types, -}; -STUBS["B05_Profile_Api_TerrainGrid"] = { - loadDesignTerrainGrid: async () => local, - groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), -}; -STUBS["B04_PreProcess_Api_Fetch"] = { - fetchDetailPipePoints: async () => ({ - pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), - }), -}; -STUBS["B06_Section_UI_Standard_Panel"] = { - effectiveStandardCross: () => job.standard, - readRockBoundaryDefault: () => -0.5, -}; - -const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); -const storeMod = load("A00_Common", "design", "design_store.ts"); -const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); -const page = load("A00_Common", "b_page_state.ts"); - -const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); -const runs = () => globalThis.__aisloPipeline?.runs ?? []; -/** 묶음이 조용해질 때까지 — 마지막 기록이 끝나고 새 기록이 더 안 생김(고리면 계속 생김). */ -async function quiet() { - let seen = -1; - for (let i = 0; i < 400; i += 1) { - await tick(15); - const last = runs()[runs().length - 1]; - const count = runs().length; - if (count === seen && (!last || last.outcome !== "running")) return; - seen = count; - } - throw new Error("묶음이 조용해지지 않음"); -} -const n = job.nodes.length; -const draftOf = (nodes) => ({ - nodes, - curveOn: Array(n).fill(true), - curveRadius: Array(n).fill(null), - curveLock: Array(n).fill(null), - curveArc: Array(n).fill(null), - stationSnap: job.stationSnap, -}); -const shift = (index, dx, dy) => - job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); -const chainagesOf = (stations) => (stations ?? []).map((s) => s.chainageM); - -const P = "proj-3490g"; - -async function run() { - window.localStorage.setItem("frd_current_project_id", P); - const savedAlignment = job.detail.longitudinal.profile_alignment; - const savedBase = alignment.toAlignmentBase(savedAlignment); - let detail = job.detail; // 종단도가 지금 그린 상세 - let b06 = false; - const link = { - pipelineSource: () => ({ base: savedBase, edits: savedAlignment.edits, detail, routeId: 7 }), - adoptPipeline: (base, edits, draft) => - pageMod.pipelineGrade(P, draft ? edits : savedAlignment.edits), - redraw: () => undefined, - side: null, - }; - const bridge = pageMod.routeDraftBridge(P, link, { stationIntervalM: job.interval }); - /** 횡단 보기 render — 카드 상세(새 노선 상세 ?? 저장)를 종단도에 · 유토곡선 설정. */ - const renderB06 = () => { - detail = pageMod.pipelineDetail(P) ?? job.detail; - pageMod.restartPagePipeline(P); - pageMod.pipelineEarthwork(P, { conversion: job.conversion }); - }; - pageMod.pipelineEarthwork(P, { conversion: job.conversion }); - await quiet(); - const pipe = pageMod.pagePipeline(P); - let drawn = pageMod.pipelineDetail(P); - pipe.subscribe((event) => { - if (!b06 || event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) - return; - const next = pageMod.pipelineDetail(P); - if (next === drawn) return; - drawn = next; - void Promise.resolve().then(renderB06); - }); - const final = () => finalCumulative(load, pipe, job.conversion); - const cut = () => - Object.values(pipe.output("cross")?.byKey ?? {}).reduce((s, c) => s + (c.cutAreaM2 ?? 0), 0); - - // B05 — 꼭짓점 한 번 끌어 놓기 - let mark = runs().length; - bridge.edited(draftOf(shift(1, -8, 0))); - await quiet(); - const b05 = { - runs: runs() - .slice(mark) - .map((r) => r.sources), - final: final(), - cut: cut(), - }; - - // B06 로 — 그리기 여러 번 - b06 = true; - mark = runs().length; - for (let i = 0; i < 5; i += 1) { - renderB06(); - await quiet(); - } - const inB06 = { - runs: runs().slice(mark).length, - final: final(), - cut: cut(), - drawnIsDraft: detail === pageMod.pipelineDetail(P), - }; - - // B06 에서 조작 하나(계획고) = 한 번 - mark = runs().length; - const edits = { - station_offsets: { [alignment.chainageKey(savedBase.stations[4].chainage_m)]: 0.3 }, - curve_radii: {}, - }; - pageMod.pipelineGrade(P, edits); - await quiet(); - const grade = { - runs: runs() - .slice(mark) - .map((r) => r.sources), - }; - - // B05 로 돌아옴 - b06 = false; - mark = runs().length; - detail = job.detail; - pageMod.restartPagePipeline(P); - await quiet(); - const back = { runs: runs().slice(mark).length, blocked: pipe.blockedWrites }; - pageMod.disposePagePipeline(P); - page.clearDrafts(P); - return { b05, inB06, grade, back }; -} - -run() - .then((out) => { - process.stdout.write(JSON.stringify(out)); - process.exit(0); - }) - .catch((error) => { - process.stderr.write(String(error && error.stack ? error.stack : error)); - process.exit(1); - }); diff --git a/resources/tester/helper_349_0k_undo_saved.cjs b/resources/tester/helper_349_0k_undo_saved.cjs deleted file mode 100644 index b7a1e03b5..000000000 --- a/resources/tester/helper_349_0k_undo_saved.cjs +++ /dev/null @@ -1,281 +0,0 @@ -/* PLAN 349-0k sub7 헬퍼 — 열기 → 노선 꼭짓점 끌기 → 되돌리기(초안 버림)면 상세 = 열 때 저장 정본 그대로. - * 진짜 TS(`design_pipeline_page` · 단계 파일 · 상세 가게 `B06_Section_Section_Store`)를 트랜스파일해 Node 로 · - * 서버 창구만 가짜. 종단 보기 고리 흉내 = `design_view_profile_sections.followSwap`(가게 stations → 갈아끼움 · - * 새 상세 → render) · `design_view_profile` 의 `holdDraft`(초안 전 상태 · 되돌림) 그대로. 초안 동안 갈아끼운 횡단은 - * 브라우저 다시 셈 흉내로 면적을 바꿔 둠(옛 길이면 되돌린 뒤 그 값이 남음). - * 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_349_0k_undo_saved.py`. */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const CATALOG = require("./helper_322_catalog_module.cjs"); - -const ROOT = path.join(__dirname, "..", ".."); -const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); - -function memoryStorage() { - const map = new Map(); - return { - getItem: (key) => (map.has(key) ? map.get(key) : null), - setItem: (key, value) => map.set(key, String(value)), - removeItem: (key) => map.delete(key), - key: (index) => [...map.keys()][index] ?? null, - get length() { - return map.size; - }, - }; -} -globalThis.window = { - sessionStorage: memoryStorage(), - localStorage: memoryStorage(), - setTimeout, - clearTimeout, -}; - -// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 -globalThis.fetch = (url, init) => - new Promise((resolve, reject) => { - const timer = setTimeout( - () => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }), - 20, - ); - init.signal.addEventListener("abort", () => { - clearTimeout(timer); - reject(new Error("aborted")); - }); - }); - -const STUBS = {}; -const cache = new Map(); -function loadTs(full) { - if (cache.has(full)) return cache.get(full); - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (CATALOG.isCatalogRequest(request)) return CATALOG; - const key = Object.keys(STUBS).find((name) => request.endsWith(name)); - if (key) return STUBS[key]; - if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; - if (request.startsWith("@ui/")) return { showToast: () => undefined }; - if (request.endsWith(".css")) return {}; - if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); - if (request.startsWith("@util/")) - return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); - if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); - return require(request); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} -const load = (...parts) => loadTs(path.join(ROOT, ...parts)); - -const sampler = load("common_util", "common_util_surface_sampler.ts"); -const bytes = fs.readFileSync(process.argv[3]); -const grid = sampler.parseDtmGrid( - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), -); -const local = { grid, design: "las" }; - -STUBS["B05_Profile_Api_Replan"] = { - analysisTerrain: () => "las", - fetchRoutePlan: async () => ({ - planned: job.line, - curves: job.curves, - min_radius_m: job.minRadius, - station_snap: job.stationSnap, - }), -}; -STUBS["B05_Profile_Api_Structures"] = { - fetchStructures: async () => ({ structures: [] }), - fetchStructureTypes: async () => job.types, - readPendingStructures: () => null, -}; -STUBS["B05_Profile_Api_TerrainGrid"] = { - loadDesignTerrainGrid: async () => local, - groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), -}; -STUBS["B04_PreProcess_Api_Fetch"] = { - fetchDetailPipePoints: async () => ({ - pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), - }), -}; -STUBS["B06_Section_UI_Standard_Panel"] = { - effectiveStandardCross: () => job.standard, - readRockBoundaryDefault: () => -0.5, -}; - -// 구조물 초안 얹기(측점 계획 · 형상) — 이 시험 밖 · 아무 일 없음 -STUBS["B06_Section_Section_Drafts"] = { - supersedeDraftStations: () => undefined, - withDraftStations: async () => undefined, - withDraftShapes: async () => undefined, - forgetDraftStations: () => undefined, -}; -STUBS["B06_Section_Api_Fetch"] = { - fetchSectionDetail: async () => JSON.parse(JSON.stringify(job.detail)), - saveSections: async () => undefined, -}; - -const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); -const storeMod = load("A00_Common", "design", "design_store.ts"); -const sectionStore = load("B06_Section", "B06_Section_Section_Store.ts"); -const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); -const massHaul = load("common_util", "common_util_mass_haul.ts"); - -const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); -const runs = () => globalThis.__aisloPipeline?.runs ?? []; -/** 묶음 · 상세 가게가 조용해질 때까지 — 마지막 기록이 끝나고 새 기록이 더 안 생김. */ -async function quiet() { - let seen = -1; - for (let i = 0; i < 400; i += 1) { - await tick(15); - const last = runs()[runs().length - 1]; - const count = runs().length; - if (count === seen && (!last || last.outcome !== "running")) return; - seen = count; - } - throw new Error("묶음이 조용해지지 않음"); -} -const n = job.nodes.length; -const draftOf = (nodes) => ({ - nodes, - curveOn: Array(n).fill(true), - curveRadius: Array(n).fill(null), - curveLock: Array(n).fill(null), - curveArc: Array(n).fill(null), - stationSnap: job.stationSnap, -}); -const shift = (index, dx, dy) => - job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); -const sliceRuns = (mark) => - runs() - .slice(mark) - .map((r) => ({ sources: r.sources, outcome: r.outcome })); -const finalOf = (detail) => { - const [series] = massHaul.computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - job.conversion, - undefined, - ["cross"], - ); - const points = series?.result.points ?? []; - return points.length ? points[points.length - 1].cumulative_volume_m3 : null; -}; -const crossOf = (detail) => - JSON.stringify(detail.cross_sections.map((s) => [s.chainage_m, s.design, s.samples])); -/** 브라우저 다시 셈 흉내 — 면적 칸(`*_m2`)을 두 배로(카드 · 종단도가 갈아끼운 상세 횡단을 다시 셈). */ -const recompute = (detail) => - detail.cross_sections.forEach((section) => { - if (!section.design) return; - const design = { ...section.design }; - for (const [key, value] of Object.entries(design)) - if (key.endsWith("_m2") && typeof value === "number") design[key] = value * 2; - section.design = design; - }); - -async function run() { - const P = "proj-349k"; - const R = 7; - window.localStorage.setItem("frd_current_project_id", P); - const savedAlignment = job.detail.longitudinal.profile_alignment; - const savedBase = alignment.toAlignmentBase(savedAlignment); - let detail = await sectionStore.loadSectionDetail(P, R); - const opened = detail; - const link = { - pipelineSource: () => ({ base: savedBase, edits: savedAlignment.edits, detail, routeId: R }), - adoptPipeline: (base, edits, draft) => - pageMod.pipelineGrade(P, draft ? edits : savedAlignment.edits), - redraw: () => undefined, - side: null, - }; - const swaps = []; - const restores = []; - pageMod.routeDraftBridge(P, link, { - stationIntervalM: job.interval, - // `design_view_profile` 의 holdDraft 그대로(349-0o) - holdDraft: (routeId, saved) => { - const hold = saved && sectionStore.holdSectionDetail(P, routeId, saved); - return async (stations) => { - if (!hold) { - restores.push({ saved: false, back: null }); - await sectionStore.swapSectionStations(P, routeId, stations); - return "no-saved"; - } - const back = await sectionStore.restoreSectionDetail(P, routeId, hold, stations); - restores.push({ saved: true, back: back ? { same: back.same } : null }); - return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; - }; - }, - }); - // followSwap — 새 상세 → 종단도 render(처음부터 다시 · 비정규 측점) · 가게 stations → 갈아끼움 - const stopListen = sectionStore.subscribeSectionDetail(P, R, (next) => { - swaps.push(next.cross_sections.length); - detail = next; - pageMod.restartPagePipeline(P); - pageMod.restartPagePipeline(P); - }); - const stopStore = storeMod.useDesignStore(P, null).subscribe("stations", (stations) => { - if (stations?.length) void sectionStore.swapSectionStations(P, R, stations); - }); - pageMod.pipelineEarthwork(P, { conversion: job.conversion }); - pageMod.restartPagePipeline(P); - await quiet(); - const open = { - runs: sliceRuns(0), - stations: detail.cross_sections.map((s) => s.chainage_m), - final: finalOf(detail), - cross: crossOf(detail), - same: detail === opened, - }; - - // 노선 꼭짓점 한 번 끌기 — 상세가 새 노선 측점으로 갈아끼워짐 · 그 횡단을 다시 셈 - let mark = runs().length; - const bridge = pageMod.routeDraftBridge(P, link); - bridge.edited(draftOf(shift(1, 6, -4))); - await quiet(); - const cached = await sectionStore.loadSectionDetail(P, R); - recompute(cached); - const drag = { - runs: sliceRuns(mark), - swapped: cached !== opened && detail === cached, - stations: cached.cross_sections.map((s) => s.chainage_m), - final: finalOf(cached), - }; - - // 되돌리기 한 번(초안 버림) - mark = runs().length; - const swapMark = swaps.length; - bridge.edited(null); - await quiet(); - const back = await sectionStore.loadSectionDetail(P, R); - const undo = { - runs: sliceRuns(mark), - restores, - swaps: swaps.length - swapMark, - sameObject: back === opened && detail === opened, - stations: back.cross_sections.map((s) => s.chainage_m), - final: finalOf(back), - cross: crossOf(back), - }; - // 되돌린 뒤 조용함 — 더 안 돎 - mark = runs().length; - await quiet(); - const after = { runs: runs().length - mark, blocked: pageMod.pagePipeline(P).blockedWrites }; - stopListen(); - stopStore(); - pageMod.disposePagePipeline(P); - return { open, drag, undo, after }; -} - -run() - .then((out) => { - process.stdout.write(JSON.stringify(out)); - process.exit(0); - }) - .catch((error) => { - process.stderr.write(String(error && error.stack ? error.stack : error)); - process.exit(1); - }); diff --git a/resources/tester/helper_349_0l_undo_draw.cjs b/resources/tester/helper_349_0l_undo_draw.cjs deleted file mode 100644 index 7248b18be..000000000 --- a/resources/tester/helper_349_0l_undo_draw.cjs +++ /dev/null @@ -1,357 +0,0 @@ -/* PLAN 349-0l sub7 헬퍼 — 열기 → 노선 꼭짓점 끌기 → 되돌리기 → 횡단 보기 → 종단 보기, 누가토량 · 측점 · 횡단이 - * 열 때와 같음 · 다시 셈 0. 진짜 TS — 종단도(`design_profile` · 낡음 판정 → crossPreview 예약) · 계산 묶음 · - * 상세 가게(`B06_Section_Section_Store`). 화면 부품(DOM · SVG · 오버레이 · 도구줄)과 서버 창구만 가짜 · - * 그리기는 진짜 `renderProfile` 의 낡음 판정 줄만 · 다시 셈 창구(`refreshCrossDesigns`)는 부른 수를 세고 면적을 - * 바꿈(서버와 다른 값 흉내). 검증_sub7 노선(관 11 · 구조물 1 은 시험이 관 자리에 놓음). - * 되돌리기 = 화면 Ctrl+Z 꼴 — 사진첩이 세션 노선 초안 칸을 먼저 비운 뒤 다리에 알림(349-0m). - * 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_349_0l_undo_draw.py`. */ -const fs = require("fs"); -const path = require("path"); -const { makeLoader, memoryStorage } = require("./helper_ts_stub_loader.cjs"); - -const ROOT = path.join(__dirname, "..", ".."); -const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); - -const windowBase = { - sessionStorage: memoryStorage(), - localStorage: memoryStorage(), - setTimeout, - clearTimeout, - addEventListener: () => undefined, - removeEventListener: () => undefined, -}; -globalThis.window = new Proxy(windowBase, { - get: (t, key) => (key in t ? t[key] : undefined), - set: (t, key, value) => ((t[key] = value), true), -}); - -// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 -globalThis.fetch = (url, init) => - new Promise((resolve, reject) => { - const timer = setTimeout( - () => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }), - 20, - ); - init.signal.addEventListener("abort", () => { - clearTimeout(timer); - reject(new Error("aborted")); - }); - }); - -const STUBS = {}; -const { load, loadTs } = makeLoader({ root: ROOT, stubs: STUBS, ui: () => anyModule() }); - -const sampler = load("common_util", "common_util_surface_sampler.ts"); -const bytes = fs.readFileSync(process.argv[3]); -const grid = sampler.parseDtmGrid( - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), -); -const local = { grid, design: "las" }; - -/** 아무 화면 부품 — 무엇을 읽든 · 부르든 또 그것(DOM · 오버레이 · 도구줄 흉내). */ -function anyNode() { - const box = { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() {} } }; - return new Proxy(function node() {}, { - get: (_t, key) => { - if (key in box) return box[key]; - if (key === "then" || typeof key === "symbol") return undefined; - return (box[key] = anyNode()); - }, - set: (_t, key, value) => ((box[key] = value), true), - apply: () => anyNode(), - }); -} -const anyModule = (own = {}) => - new Proxy(own, { - get: (t, key) => - key in t - ? t[key] - : key === "__esModule" - ? true - : typeof key === "symbol" - ? undefined - : anyNode(), - }); -globalThis.document = anyNode(); -globalThis.HTMLElement = function HTMLElement() {}; - -STUBS["B05_Profile_Api_Replan"] = { - analysisTerrain: () => "las", - fetchRoutePlan: async () => ({ - planned: job.line, - curves: job.curves, - min_radius_m: job.minRadius, - station_snap: job.stationSnap, - }), -}; -STUBS["B05_Profile_Api_Structures"] = anyModule({ - fetchStructures: async () => ({ structures: job.structures }), - fetchStructureTypes: async () => job.types, - readPendingStructures: () => null, -}); -STUBS["B05_Profile_Api_TerrainGrid"] = { - loadDesignTerrainGrid: async () => local, - groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), -}; -STUBS["B04_PreProcess_Api_Fetch"] = { - fetchDetailPipePoints: async () => ({ - pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), - }), -}; -STUBS["B06_Section_UI_Standard_Panel"] = { - effectiveStandardCross: () => job.standard, - readRockBoundaryDefault: () => -0.5, -}; -// 구조물 초안 얹기(측점 계획 · 형상) — 이 시험 밖 · 아무 일 없음 -STUBS["B06_Section_Section_Drafts"] = { - supersedeDraftStations: () => undefined, - withDraftStations: async () => undefined, - withDraftShapes: async () => undefined, - forgetDraftStations: () => undefined, -}; -STUBS["B06_Section_Api_Fetch"] = { - fetchSectionDetail: async () => JSON.parse(JSON.stringify(job.detail)), - saveSections: async () => undefined, -}; -// 횡단 다시 셈(브라우저 · B05 crossPreview · B06 카드 같은 창구) — 부른 횟수 · 면적은 서버와 달라짐(두 배) -let refreshCalls = 0; -const doubled = (detail) => - detail.cross_sections.forEach((section) => { - if (!section.design) return; - const design = { ...section.design }; - for (const [key, value] of Object.entries(design)) - if (key.endsWith("_m2") && typeof value === "number") design[key] = value * 2; - section.design = design; - }); -STUBS["B06_Section_Cross_Refresh"] = { - refreshCrossDesigns: async ({ detail }) => { - refreshCalls += 1; - doubled(detail); - return detail.cross_sections.map((s) => s.chainage_m); - }, - holdScreenAlignment: () => () => undefined, - USER_TOUCHED_KEYS: [], -}; -// 그리기 — 진짜 `renderProfile` 의 낡음 판정 줄만(같은 함수 · 같은 인자) · SVG 는 안 그림 -const draws = []; -let opened = null; -STUBS["B05_Profile_UI_Profile_Render"] = { - renderProfile: (ctx) => { - const detail = ctx.detail(); - if (!detail) return; - const alignment = ctx.alignment(); - const longitudinal = detail.longitudinal; - const designProfiles = alignment - ? [profileData.toDesignProfile(alignment, longitudinal.design_profiles?.[0])] - : (longitudinal.design_profiles ?? []); - const key = common.staleKey({ - longitudinal: { design_profiles: designProfiles }, - cross_sections: detail.cross_sections, - }); - // 저장 상세를 그림 = 묶음 새 노선 상세(preview)가 아님 · 그린 객체(가게 것인지 견줌) - draws.push({ key, saved: !detail.cross_sections.some((s) => s.preview), ref: detail }); - ctx.onStaleDesigns?.(key); - }, -}; -STUBS["common_util_view_window"] = { frameOf: (_root, fn) => fn() }; -for (const name of [ - "B05_Profile_UI_Profile_Balance", - "B05_Profile_UI_Profile_Heights", - "B05_Profile_UI_Profile_Panel_Tools", - "B05_Profile_UI_Profile_Zoom", - "B05_Profile_UI_Profile_Drag", - "B05_Profile_UI_Profile_MassHaul", - "design_profile_overlays", - "design_profile_viewport", - "design_save", - "B05_Profile_Api_Fetch", - "common_util_mass_haul_balance_view", -]) - STUBS[name] = anyModule(); - -const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); -const storeMod = load("A00_Common", "design", "design_store.ts"); -const sectionStore = load("B06_Section", "B06_Section_Section_Store.ts"); -const common = load("B06_Section", "B06_Section_UI_Section_Common.ts"); -const layouts = load("B06_Section", "B06_Section_Structure_Layouts.ts"); -const profileData = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Data.ts"); -const alignmentMod = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); -const baseMod = load("A00_Common", "design", "design_profile_base.ts"); -const pageState = load("A00_Common", "b_page_state.ts"); -const massHaul = load("common_util", "common_util_mass_haul.ts"); -const profileMod = load("A00_Common", "design", "design_profile.ts"); - -const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); -const runs = () => globalThis.__aisloPipeline?.runs ?? []; -const PID = "proj-349l"; -/** 묶음 · 상세 가게 · 다시 셈이 조용해질 때까지. */ -async function quiet() { - let seen = -1; - for (let i = 0; i < 400; i += 1) { - await tick(30); - const last = runs()[runs().length - 1]; - // 버림 그리기 멈춤(`pipelineHolding`)이 풀리고 그 다음 틀 그림까지(472-5 덧 — 묶음이 조용해진 뒤 풂) - const held = pageMod.pipelineHolding(PID); - const count = runs().length + refreshCalls * 1000 + draws.length * 1e6 + (held ? 0.5 : 0); - if (count === seen && !held && (!last || last.outcome !== "running")) return; - seen = count; - } - throw new Error("묶음이 조용해지지 않음"); -} -const n = job.nodes.length; -const draftOf = (nodes) => ({ - nodes, - curveOn: Array(n).fill(true), - curveRadius: Array(n).fill(null), - curveLock: Array(n).fill(null), - curveArc: Array(n).fill(null), - stationSnap: job.stationSnap, -}); -const shift = (index, dx, dy) => - job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); -const sliceRuns = (mark) => - runs() - .slice(mark) - .map((r) => ({ - sources: r.sources, - outcome: r.outcome, - restored: r.restored ?? null, - why: r.restoreWhy ?? null, - })); -const finalOf = (detail) => { - const [series] = massHaul.computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - job.conversion, - undefined, - ["cross"], - ); - const points = series?.result.points ?? []; - return points.length ? points[points.length - 1].cumulative_volume_m3 : null; -}; -const crossOf = (detail) => - JSON.stringify(detail.cross_sections.map((s) => [s.chainage_m, s.design, s.samples])); -/** 서버 정본 흉내 — 저장 횡단 계획고 = 열 때 종단도 계획선(낡음 0 · 서버가 낸 상세와 같음). */ -function settleDesigns(detail) { - const pa = detail.longitudinal.profile_alignment; - const base = baseMod.listedBase(alignmentMod.toAlignmentBase(pa), detail, []); - const samples = alignmentMod.buildAlignment(base, pa.edits).samples; - const at = (c) => { - const i = samples.findIndex((s) => s.chainage_m >= c); - if (i < 0) return samples[samples.length - 1].elevation_m; - if (i === 0) return samples[0].elevation_m; - const a = samples[i - 1]; - const b = samples[i]; - return ( - a.elevation_m + - ((b.elevation_m - a.elevation_m) * (c - a.chainage_m)) / (b.chainage_m - a.chainage_m) - ); - }; - detail.cross_sections.forEach((s) => (s.design.design_elevation_m = at(s.chainage_m))); -} - -async function run() { - const P = PID; - const R = 7; - window.localStorage.setItem("frd_current_project_id", P); - opened = await sectionStore.loadSectionDetail(P, R); - settleDesigns(opened); - const profile = profileMod.createDesignProfile({ - projectId: P, - view: "profile", - onSelectStation: () => undefined, - }); - const live = () => sectionStore.loadSectionDetail(P, R); - pageMod.routeDraftBridge(P, profile, { - stationIntervalM: job.interval, - structureAreas: ({ cross_sections: rows }) => - layouts.applyStructureAreaRows(rows, layouts.structureAreaRows(rows)), - // `design_view_profile` 의 holdDraft 그대로(349-0o) - holdDraft: (routeId, saved) => { - const hold = saved && sectionStore.holdSectionDetail(P, routeId, saved); - return async (stations) => { - if (!hold) { - await sectionStore.swapSectionStations(P, routeId, stations); - return "no-saved"; - } - const back = await sectionStore.restoreSectionDetail(P, routeId, hold, stations); - return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; - }; - }, - }); - // 종단 보기 `renderSections` · followSwap 그대로 — 새 상세 → render · 비정규 측점 · 가게 stations → 갈아끼움 - const show = (detail) => { - profile.render(detail, job.interval, R); - profile.setIrregularStations([]); - }; - const stopListen = sectionStore.subscribeSectionDetail(P, R, (next) => show(next)); - const stopStore = storeMod.useDesignStore(P, null).subscribe("stations", (stations) => { - if (stations?.length) void sectionStore.swapSectionStations(P, R, stations); - }); - show(opened); - profile.setEarthworkContext({ conversion: job.conversion }); - await quiet(); - const measure = async (mark, refreshMark) => { - const detail = await live(); - return { - runs: sliceRuns(mark), - refresh: refreshCalls - refreshMark, - lastKey: draws[draws.length - 1]?.key ?? null, - drawnSaved: draws[draws.length - 1]?.saved ?? null, - drawnLive: draws[draws.length - 1]?.ref === detail, - sameObject: detail === opened, - stations: detail.cross_sections.map((s) => s.chainage_m), - final: finalOf(detail), - cross: crossOf(detail), - edits: JSON.stringify(profile.pipelineSource().edits), - pipeEdits: JSON.stringify(pageMod.pagePipeline(P).input("alignment")), - }; - }; - const open = await measure(0, 0); - - // 노선 꼭짓점 한 번 끌기 — 상세가 새 노선 측점으로 갈아끼워짐 · 그 횡단을 카드가 다시 셈(흉내) - let mark = runs().length; - let refreshMark = refreshCalls; - const bridge = pageMod.routeDraftBridge(P, profile); - bridge.edited(draftOf(shift(1, 6, -4))); - await quiet(); - doubled(await live()); - const drag = await measure(mark, refreshMark); - - // 되돌리기 한 번(초안 버림) — 화면 Ctrl+Z 꼴(349-0m): 페이지 사진첩이 세션 route-draft 칸을 먼저 되쓰고 - // 편집기 되살림(fromPage → tell)이 다리에 null 을 알림 · 같은 알림이 한 번 더 와도 한 차례 - mark = runs().length; - refreshMark = refreshCalls; - pageState.writeState("route-draft", null, P); - bridge.edited(null); - bridge.edited(null); - await quiet(); - const undo = await measure(mark, refreshMark); - - // 횡단 보기(B06) 다녀옴 — 종단도는 횡단 보기 자리에서 `shownDetail`(묶음 새 상세 없으면 저장 상세)을 그림 - mark = runs().length; - refreshMark = refreshCalls; - profile.setView("cross"); - show(pageMod.pipelineDetail(P) ?? (await live())); - profile.setEarthworkContext({ conversion: job.conversion }); - await quiet(); - profile.setView("profile"); - profile.setActive(true); - await quiet(); - const back = await measure(mark, refreshMark); - const blocked = pageMod.pagePipeline(P).blockedWrites; - stopListen(); - stopStore(); - pageMod.disposePagePipeline(P); - return { open, drag, undo, back, blocked }; -} - -run() - .then((out) => { - process.stdout.write(JSON.stringify(out)); - process.exit(0); - }) - .catch((error) => { - process.stderr.write(String(error && error.stack ? error.stack : error)); - process.exit(1); - }); diff --git a/resources/tester/helper_349_0s_draw_work.cjs b/resources/tester/helper_349_0s_draw_work.cjs deleted file mode 100644 index 83562a574..000000000 --- a/resources/tester/helper_349_0s_draw_work.cjs +++ /dev/null @@ -1,379 +0,0 @@ -/* PLAN 349-0s sub7 헬퍼 — 노선 꼭짓점 끌기 여러 걸음 · 되돌리기의 묶음 기록(단계 · 알림 받는 쪽 · 그리기 몫 ms)과 - * 걸음마다 종단도 그리기 수. 화면 틀(rAF)은 16 ms 뒤 부름으로 흉내 — 같은 틀 안 여러 번 그리기 요청은 한 번. - * 진짜 TS — 종단도 · 계산 묶음 · 상세 가게(그리기 본문 · 서버 창구만 가짜 · 종단 보기 renderSections 꼴 그대로). - * 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_349_0s_draw_work.py`. */ -const fs = require("fs"); -const path = require("path"); -const { makeLoader, memoryStorage } = require("./helper_ts_stub_loader.cjs"); - -const ROOT = path.join(__dirname, "..", ".."); -const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); - -const windowBase = { - sessionStorage: memoryStorage(), - localStorage: memoryStorage(), - setTimeout, - clearTimeout, - addEventListener: () => undefined, - removeEventListener: () => undefined, -}; -globalThis.window = new Proxy(windowBase, { - get: (t, key) => (key in t ? t[key] : undefined), - set: (t, key, value) => ((t[key] = value), true), -}); - -// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 -globalThis.fetch = (url, init) => - new Promise((resolve, reject) => { - const timer = setTimeout( - () => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }), - 20, - ); - init.signal.addEventListener("abort", () => { - clearTimeout(timer); - reject(new Error("aborted")); - }); - }); - -const STUBS = {}; -const { load, loadTs } = makeLoader({ root: ROOT, stubs: STUBS, ui: () => anyModule() }); - -const sampler = load("common_util", "common_util_surface_sampler.ts"); -const bytes = fs.readFileSync(process.argv[3]); -const grid = sampler.parseDtmGrid( - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), -); -const local = { grid, design: "las" }; - -/** 아무 화면 부품 — 무엇을 읽든 · 부르든 또 그것(DOM · 오버레이 · 도구줄 흉내). */ -function anyNode() { - const box = { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() {} } }; - return new Proxy(function node() {}, { - get: (_t, key) => { - if (key in box) return box[key]; - if (key === "then" || typeof key === "symbol") return undefined; - return (box[key] = anyNode()); - }, - set: (_t, key, value) => ((box[key] = value), true), - apply: () => anyNode(), - }); -} -const anyModule = (own = {}) => - new Proxy(own, { - get: (t, key) => - key in t - ? t[key] - : key === "__esModule" - ? true - : typeof key === "symbol" - ? undefined - : anyNode(), - }); -globalThis.document = anyNode(); -globalThis.HTMLElement = function HTMLElement() {}; - -STUBS["B05_Profile_Api_Replan"] = { - analysisTerrain: () => "las", - fetchRoutePlan: async () => ({ - planned: job.line, - curves: job.curves, - min_radius_m: job.minRadius, - station_snap: job.stationSnap, - }), -}; -STUBS["B05_Profile_Api_Structures"] = anyModule({ - fetchStructures: async () => ({ structures: job.structures }), - fetchStructureTypes: async () => job.types, - readPendingStructures: () => null, -}); -STUBS["B05_Profile_Api_TerrainGrid"] = { - loadDesignTerrainGrid: async () => local, - groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), -}; -STUBS["B04_PreProcess_Api_Fetch"] = { - fetchDetailPipePoints: async () => ({ - pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), - }), -}; -STUBS["B06_Section_UI_Standard_Panel"] = { - effectiveStandardCross: () => job.standard, - readRockBoundaryDefault: () => -0.5, -}; -// 구조물 초안 얹기(측점 계획 · 형상) — 이 시험 밖 · 아무 일 없음 -STUBS["B06_Section_Section_Drafts"] = { - supersedeDraftStations: () => undefined, - withDraftStations: async () => undefined, - withDraftShapes: async () => undefined, - forgetDraftStations: () => undefined, -}; -STUBS["B06_Section_Api_Fetch"] = { - fetchSectionDetail: async () => JSON.parse(JSON.stringify(job.detail)), - saveSections: async () => undefined, -}; -// 횡단 다시 셈(브라우저 · B05 crossPreview · B06 카드 같은 창구) — 부른 횟수 · 면적은 서버와 달라짐(두 배) -let refreshCalls = 0; -const doubled = (detail) => - detail.cross_sections.forEach((section) => { - if (!section.design) return; - const design = { ...section.design }; - for (const [key, value] of Object.entries(design)) - if (key.endsWith("_m2") && typeof value === "number") design[key] = value * 2; - section.design = design; - }); -STUBS["B06_Section_Cross_Refresh"] = { - refreshCrossDesigns: async ({ detail }) => { - refreshCalls += 1; - doubled(detail); - return detail.cross_sections.map((s) => s.chainage_m); - }, - // 화면 선 맡김 · 낡음 판정 = 진짜 `staleAgainstScreen` 그대로(화면 선 → 없으면 상세 자기 선) - holdScreenAlignment: (line) => { - screenLine = line; - return () => undefined; - }, - staleAgainstScreen: (detail, routeId) => { - const screen = screenLine?.(routeId); - if (!screen) return common.hasStaleDesigns(detail); - return common.hasStaleDesigns({ - longitudinal: { - design_profiles: [ - profileData.toDesignProfile(screen, detail.longitudinal.design_profiles?.[0]), - ], - }, - cross_sections: detail.cross_sections, - }); - }, - USER_TOUCHED_KEYS: [], -}; -let screenLine = null; -STUBS["B06_Section_UI_Cross_Detail"] = anyModule(); -STUBS["B06_Section_UI_Page_Common"] = anyModule({ L: (key) => key }); -// 그리기 — 진짜 `renderProfile` 의 낡음 판정 줄만(같은 함수 · 같은 인자) · SVG 는 안 그림 -const draws = []; -let opened = null; -STUBS["B05_Profile_UI_Profile_Render"] = { - renderProfile: (ctx) => { - const detail = ctx.detail(); - if (!detail) return; - const alignment = ctx.alignment(); - const longitudinal = detail.longitudinal; - const designProfiles = alignment - ? [profileData.toDesignProfile(alignment, longitudinal.design_profiles?.[0])] - : (longitudinal.design_profiles ?? []); - const key = common.staleKey({ - longitudinal: { design_profiles: designProfiles }, - cross_sections: detail.cross_sections, - }); - // 저장 상세를 그림 = 묶음 새 노선 상세(preview)가 아님 · 그린 객체(가게 것인지 견줌) - draws.push({ key, saved: !detail.cross_sections.some((s) => s.preview), ref: detail }); - ctx.onStaleDesigns?.(key); - }, -}; -STUBS["common_util_view_window"] = { frameOf: (_root, fn) => setTimeout(() => fn(0), 16) }; -for (const name of [ - "B05_Profile_UI_Profile_Balance", - "B05_Profile_UI_Profile_Heights", - "B05_Profile_UI_Profile_Panel_Tools", - "B05_Profile_UI_Profile_Zoom", - "B05_Profile_UI_Profile_Drag", - "B05_Profile_UI_Profile_MassHaul", - "design_profile_overlays", - "design_profile_viewport", - "design_save", - "B05_Profile_Api_Fetch", - "common_util_mass_haul_balance_view", -]) - STUBS[name] = anyModule(); - -const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); -const storeMod = load("A00_Common", "design", "design_store.ts"); -const sectionStore = load("B06_Section", "B06_Section_Section_Store.ts"); -const common = load("B06_Section", "B06_Section_UI_Section_Common.ts"); -const layouts = load("B06_Section", "B06_Section_Structure_Layouts.ts"); -const profileData = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Data.ts"); -const alignmentMod = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); -const baseMod = load("A00_Common", "design", "design_profile_base.ts"); -const pageState = load("A00_Common", "b_page_state.ts"); -const massHaul = load("common_util", "common_util_mass_haul.ts"); -const profileMod = load("A00_Common", "design", "design_profile.ts"); -const designSync = load("B06_Section", "B06_Section_UI_Page_Design_Sync.ts"); -const designSession = load("B06_Section", "B06_Section_Cross_Design_Session.ts"); -const stationKeys = load("B06_Section", "B06_Section_Station_Keys.ts"); -// 진짜 다시 셈 창구(묶음 새 노선 상세를 안 고치는지만 봄 · 다른 곳은 위 가짜를 부름) -const realRefresh = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Cross_Refresh.ts")); - -const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); -const runs = () => globalThis.__aisloPipeline?.runs ?? []; -const PID = "proj-349s"; -/** 묶음 · 상세 가게 · 다시 셈이 조용해질 때까지. */ -async function quiet() { - let seen = -1; - for (let i = 0; i < 400; i += 1) { - await tick(30); - const last = runs()[runs().length - 1]; - // 버림 그리기 멈춤(`pipelineHolding`)이 풀리고 그 다음 틀 그림까지(472-5 덧 — 묶음이 조용해진 뒤 풂) - const held = pageMod.pipelineHolding(PID); - const count = runs().length + refreshCalls * 1000 + draws.length * 1e6 + (held ? 0.5 : 0); - if (count === seen && !held && (!last || last.outcome !== "running")) return; - seen = count; - } - throw new Error("묶음이 조용해지지 않음"); -} -const n = job.nodes.length; -const draftOf = (nodes) => ({ - nodes, - curveOn: Array(n).fill(true), - curveRadius: Array(n).fill(null), - curveLock: Array(n).fill(null), - curveArc: Array(n).fill(null), - stationSnap: job.stationSnap, -}); -const shift = (index, dx, dy) => - job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); -const sliceRuns = (mark) => - runs() - .slice(mark) - .map((r) => ({ - sources: r.sources, - outcome: r.outcome, - restored: r.restored ?? null, - why: r.restoreWhy ?? null, - })); -const finalOf = (detail) => { - const [series] = massHaul.computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - job.conversion, - undefined, - ["cross"], - ); - const points = series?.result.points ?? []; - return points.length ? points[points.length - 1].cumulative_volume_m3 : null; -}; -const crossOf = (detail) => - JSON.stringify(detail.cross_sections.map((s) => [s.chainage_m, s.design, s.samples])); -/** 서버 정본 흉내 — 저장 횡단 계획고 = 열 때 종단도 계획선(낡음 0 · 서버가 낸 상세와 같음). */ -function settleDesigns(detail) { - const pa = detail.longitudinal.profile_alignment; - const base = baseMod.listedBase(alignmentMod.toAlignmentBase(pa), detail, []); - const samples = alignmentMod.buildAlignment(base, pa.edits).samples; - const at = (c) => { - const i = samples.findIndex((s) => s.chainage_m >= c); - if (i < 0) return samples[samples.length - 1].elevation_m; - if (i === 0) return samples[0].elevation_m; - const a = samples[i - 1]; - const b = samples[i]; - return ( - a.elevation_m + - ((b.elevation_m - a.elevation_m) * (c - a.chainage_m)) / (b.chainage_m - a.chainage_m) - ); - }; - detail.cross_sections.forEach((s) => (s.design.design_elevation_m = at(s.chainage_m))); -} - -async function run() { - const P = PID; - const R = 7; - window.localStorage.setItem("frd_current_project_id", P); - opened = await sectionStore.loadSectionDetail(P, R); - settleDesigns(opened); - const profile = profileMod.createDesignProfile({ - projectId: P, - view: "profile", - onSelectStation: () => undefined, - }); - const live = () => sectionStore.loadSectionDetail(P, R); - pageMod.routeDraftBridge(P, profile, { - stationIntervalM: job.interval, - structureAreas: ({ cross_sections: rows }) => - layouts.applyStructureAreaRows(rows, layouts.structureAreaRows(rows)), - holdDraft: (routeId, saved) => { - const hold = saved && sectionStore.holdSectionDetail(P, routeId, saved); - return async (stations) => { - if (!hold) { - await sectionStore.swapSectionStations(P, routeId, stations); - return "no-saved"; - } - const back = await sectionStore.restoreSectionDetail(P, routeId, hold, stations); - return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; - }; - }, - }); - // 종단 보기 `renderSections` 그대로 — 새 상세 → render · 정책 · 이어 공사 기준 · 비정규 측점(각각 다시 그림 부름) - const show = (detail) => { - profile.render(detail, job.interval, R); - profile.setGradeLimit(12); - profile.setStationDisplay({ station: 0, cumulative: 0 }); - profile.setIrregularStations([]); - }; - const stopListen = sectionStore.subscribeSectionDetail(P, R, (next) => show(next)); - const swapMs = []; - const stopStore = storeMod.useDesignStore(P, null).subscribe("stations", (stations) => { - if (!stations?.length) return; - const t0 = performance.now(); - void sectionStore - .swapSectionStations(P, R, stations) - .then(() => swapMs.push(Math.round((performance.now() - t0) * 100) / 100)); - }); - show(opened); - profile.setEarthworkContext({ conversion: job.conversion }); - await quiet(); - const detailBytes = JSON.stringify(await live()).length; - - // 꼭짓점 끌기 — 마우스 움직임마다 한 번(놓기 전 여러 걸음) - const bridge = pageMod.routeDraftBridge(P, profile); - const steps = []; - for (let k = 1; k <= job.steps; k += 1) { - const mark = runs().length; - const drawMark = draws.length; - const swapMark = swapMs.length; - bridge.edited(draftOf(shift(1, 1.5 * k, -k))); - await quiet(); - const ran = runs().slice(mark); - steps.push({ - runs: ran.map((r) => ({ - outcome: r.outcome, - totalMs: r.totalMs, - stagesMs: Object.entries(r.stages) - .filter(([name]) => !["haul", "drainage", "corridor"].includes(name)) - .reduce((a, [, ms]) => a + ms, 0), - stages: r.stages, - work: r.work ?? {}, - })), - draws: draws.length - drawMark, - swapMs: swapMs.slice(swapMark), - }); - } - // 되돌리기 - const mark = runs().length; - const drawMark = draws.length; - pageState.writeState("route-draft", null, P); - bridge.edited(null); - bridge.edited(null); - await quiet(); - const undo = { - runs: runs() - .slice(mark) - .map((r) => ({ - outcome: r.outcome, - totalMs: r.totalMs, - work: r.work ?? {}, - why: r.restoreWhy, - })), - draws: draws.length - drawMark, - }; - stopListen(); - stopStore(); - pageMod.disposePagePipeline(P); - return { steps, undo, detailBytes, refresh: refreshCalls }; -} - -run() - .then((out) => { - process.stdout.write(JSON.stringify(out)); - process.exit(0); - }) - .catch((error) => { - process.stderr.write(String(error && error.stack ? error.stack : error)); - process.exit(1); - }); diff --git a/resources/tester/helper_349_0u_follow_draft_xy.cjs b/resources/tester/helper_349_0u_follow_draft_xy.cjs deleted file mode 100644 index 0c1459cf4..000000000 --- a/resources/tester/helper_349_0u_follow_draft_xy.cjs +++ /dev/null @@ -1,238 +0,0 @@ -/* PLAN 349-0u sub7 헬퍼 — 세션 관 · 구조물 초안에 좌표가 없어도(배수유역도 · 구조물 목록이 정본 PUT 꼴로 씀) - * 앞쪽 꺾임점을 끌면 follow 가 새 측점을 가게에 바로 · 되돌리기 = 초안 값 · 다시하기 = 다시 밂 · 조작 하나 = - * 묶음 한 번. 나머지 틀 = `helper_349_0d_follow_store.cjs` 그대로. 짝 = `test_349_0u_follow_draft_xy.py`. */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const CATALOG = require("./helper_322_catalog_module.cjs"); - -const ROOT = path.join(__dirname, "..", ".."); -const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); - -function memoryStorage() { - const map = new Map(); - return { - getItem: (key) => (map.has(key) ? map.get(key) : null), - setItem: (key, value) => map.set(key, String(value)), - removeItem: (key) => map.delete(key), - key: (index) => [...map.keys()][index] ?? null, - get length() { - return map.size; - }, - }; -} -globalThis.window = { - sessionStorage: memoryStorage(), - localStorage: memoryStorage(), - setTimeout, - clearTimeout, -}; - -// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 -globalThis.fetch = (url, init) => - new Promise((resolve, reject) => { - const timer = setTimeout( - () => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }), - 20, - ); - init.signal.addEventListener("abort", () => { - clearTimeout(timer); - reject(new Error("aborted")); - }); - }); - -const STUBS = {}; -const cache = new Map(); -function loadTs(full) { - if (cache.has(full)) return cache.get(full); - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (CATALOG.isCatalogRequest(request)) return CATALOG; - const key = Object.keys(STUBS).find((name) => request.endsWith(name)); - if (key) return STUBS[key]; - if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; - if (request.startsWith("@util/")) - return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); - if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); - return require(request); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} -const load = (...parts) => loadTs(path.join(ROOT, ...parts)); - -const sampler = load("common_util", "common_util_surface_sampler.ts"); -const bytes = fs.readFileSync(process.argv[3]); -const grid = sampler.parseDtmGrid( - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), -); -const local = { grid, design: "las" }; - -STUBS["B05_Profile_Api_Replan"] = { - analysisTerrain: () => "las", - fetchRoutePlan: async () => ({ - planned: job.line, - curves: job.curves, - min_radius_m: job.minRadius, - station_snap: job.stationSnap, - }), -}; -STUBS["B05_Profile_Api_Structures"] = { - fetchStructures: async () => ({ structures: job.structures }), - fetchStructureTypes: async () => job.types, -}; -STUBS["B05_Profile_Api_TerrainGrid"] = { - loadDesignTerrainGrid: async () => local, - groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), -}; -STUBS["B04_PreProcess_Api_Fetch"] = { - fetchDetailPipePoints: async () => ({ - pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), - }), -}; -STUBS["B06_Section_UI_Standard_Panel"] = { - effectiveStandardCross: () => job.standard, - readRockBoundaryDefault: () => -0.5, -}; - -const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); -const storeMod = load("A00_Common", "design", "design_store.ts"); -const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); -const page = load("A00_Common", "b_page_state.ts"); - -const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); -const runs = () => globalThis.__aisloPipeline?.runs ?? []; -/** 묶음이 조용해질 때까지 — 마지막 기록이 끝나고 새 기록이 더 안 생김(고리면 계속 생김). */ -async function quiet() { - let seen = -1; - for (let i = 0; i < 400; i += 1) { - await tick(15); - const last = runs()[runs().length - 1]; - const count = runs().length; - if (count === seen && (!last || last.outcome !== "running")) return; - seen = count; - } - throw new Error("묶음이 조용해지지 않음"); -} -const n = job.nodes.length; -// 저장 곡선 그대로 연 편집기 칸(`flattenServerPlan` 꼴) — 꼭짓점 = 곡선 꼭지 · R = 저장 R(0.01 m) · 곡선 없는 자리 = 끔 -// (전부 켬 · R 비움이면 안 끈 자리 곡선도 기본 R 로 다시 깔려 노선이 통째로 바뀜) -const savedCurve = job.nodes.map((node) => - job.curves.find((c) => Math.hypot(c.apex[0] - node[0], c.apex[1] - node[1]) < 1e-3), -); -const draftOf = (nodes) => ({ - nodes, - curveOn: savedCurve.map((curve) => curve !== undefined), - curveRadius: savedCurve.map((curve) => (curve ? Math.round(curve.radius_m * 100) / 100 : null)), - curveLock: Array(n).fill(null), - curveArc: Array(n).fill(null), - stationSnap: job.stationSnap, -}); -const shift = (index, dx, dy) => - job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); -const chainagesOf = (stations) => (stations ?? []).map((s) => s.chainageM); - -const P = "proj-3490u"; -const ids = (list) => (list ?? []).map((item) => [item.id ?? item.structure_id, item.chainage_m]); -const lengthOf = (line) => - (line ?? []).reduce( - (sum, p, i) => (i ? sum + Math.hypot(p[0] - line[i - 1][0], p[1] - line[i - 1][1]) : 0), - 0, - ); - -async function run() { - window.localStorage.setItem("frd_current_project_id", P); - const savedAlignment = job.detail.longitudinal.profile_alignment; - const savedBase = alignment.toAlignmentBase(savedAlignment); - const followed = []; - const link = { - pipelineSource: () => ({ - base: savedBase, - edits: savedAlignment.edits, - detail: job.detail, - routeId: 7, - }), - adoptPipeline: (base, edits, draft) => - pageMod.pipelineGrade(P, draft ? edits : savedAlignment.edits), - redraw: () => undefined, - side: null, - }; - const store = storeMod.useDesignStore(P, null); - // 세션 초안 = 정본 PUT 꼴(좌표 뺌) — 배수유역도 · 구조물 목록이 열 때 · 고칠 때 씀 - const draftPipes = job.pipes.map(({ x: _x, y: _y, ...pipe }) => pipe); - const draftStructures = job.structures.map(({ x: _x, y: _y, ...item }) => item); - store.set("pipes", draftPipes); - store.set("structures", draftStructures); - const bridge = pageMod.routeDraftBridge(P, link, { - stationIntervalM: job.interval, - onFacilitiesFollowed: (far) => followed.push(far), - }); - pageMod.pipelineEarthwork(P, { conversion: job.conversion }); - await quiet(); - const pipe = pageMod.pagePipeline(P); - const savedLength = lengthOf(pipe.output("route")?.line); - const open = { - pipes: ids(store.get("pipes")), - structures: ids(store.get("structures")), - followed: [...followed], - }; - const snapshot = {}; - for (let i = 0; i < window.sessionStorage.length; i += 1) { - const key = window.sessionStorage.key(i); - snapshot[key] = window.sessionStorage.getItem(key); - } - const step = async (edit) => { - const mark = runs().length; - const before = followed.length; - bridge.edited(edit); - await quiet(); - return { - runs: runs() - .slice(mark) - .map((r) => r.sources), - pipes: ids(store.get("pipes")), - structures: ids(store.get("structures")), - length: lengthOf(pipe.output("route")?.line), - followed: followed.slice(before), - blocked: pipe.blockedWrites, - }; - }; - // 앞쪽(시작 쪽) 꺾임점을 크게 끌어 놓음 — 그 뒤 관 · 구조물은 길이가 는 만큼 뒤로 - const dragged = draftOf(shift(1, job.dx, job.dy)); - const drag = await step(dragged); - drag.line = pipe.output("route")?.line; // 서버 따라감 대조(파이썬) - // 되돌리기 — 사진첩이 세션을 되쓴 뒤 초안 없음 - for (const key of Object.keys(snapshot)) window.sessionStorage.setItem(key, snapshot[key]); - for (let i = window.sessionStorage.length - 1; i >= 0; i -= 1) { - const key = window.sessionStorage.key(i); - if (!(key in snapshot)) window.sessionStorage.removeItem(key); - } - const undo = await step(null); - const redo = await step(dragged); - const undo2 = await step(null); - pageMod.disposePagePipeline(P); - return { - savedLength, - open, - drag, - undo, - redo, - undo2, - draftPipes: ids(draftPipes), - draftStructures: ids(draftStructures), - }; -} - -run() - .then((out) => { - process.stdout.write(JSON.stringify(out)); - process.exit(0); - }) - .catch((error) => { - process.stderr.write(String(error && error.stack ? error.stack : error)); - process.exit(1); - }); diff --git a/resources/tester/helper_349_0u_save_rebase.cjs b/resources/tester/helper_349_0u_save_rebase.cjs deleted file mode 100644 index 4d62cbb58..000000000 --- a/resources/tester/helper_349_0u_save_rebase.cjs +++ /dev/null @@ -1,149 +0,0 @@ -/* PLAN 349-0u 덧 sub7 헬퍼 — 노선을 저장한 [저장]에서 세션 관 초안(옛 노선 누가거리)이 서버가 따라 옮긴 관을 - * 덮지 않음 · 사용자 관 고침만 얹음. 실제 `design_save.ts` · 관 초안 창구 · 가게를 Node 로 · 서버 창구 · - * 화면 알림만 가짜. 노선 저장 전 GET = `before` · 뒤 GET = `after`(서버 따라감 + 새 교차 관). - * 덤 — `placeOnLine` 이 안 옮긴 관에 정본 좌표를 씀. 입력 argv[2] JSON. 짝 = `test_349_0u_save_rebase.py`. */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const CATALOG = require("./helper_322_catalog_module.cjs"); - -const ROOT = path.join(__dirname, "..", ".."); -const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); - -function memoryStorage() { - const map = new Map(); - return { - getItem: (key) => (map.has(key) ? map.get(key) : null), - setItem: (key, value) => map.set(key, String(value)), - removeItem: (key) => map.delete(key), - key: (index) => [...map.keys()][index] ?? null, - get length() { - return map.size; - }, - }; -} -globalThis.window = { - sessionStorage: memoryStorage(), - localStorage: memoryStorage(), - setTimeout, - clearTimeout, -}; - -const STUBS = {}; -const cache = new Map(); -function loadTs(full) { - if (cache.has(full)) return cache.get(full); - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (CATALOG.isCatalogRequest(request)) return CATALOG; - const key = Object.keys(STUBS).find((name) => request.endsWith(name)); - if (key) return STUBS[key]; - if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; - if (request.startsWith("@util/")) - return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); - if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); - return require(request); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} -const load = (...parts) => loadTs(path.join(ROOT, ...parts)); - -const calls = []; -const sent = []; -let routeSavedOnServer = false; -let routeBody = null; -const withLonlat = (list) => list.map((pipe) => ({ ...pipe, lonlat: [0, 0] })); -STUBS["ui_template_elements"] = { - showLoadingOverlay: () => undefined, - hideLoadingOverlay: () => undefined, - showToast: () => undefined, -}; -STUBS["B04_PreProcess_Api_Fetch"] = { - fetchDetailPipePoints: async () => { - calls.push("GET"); - return { pipe_points: withLonlat(routeSavedOnServer ? job.after : job.before) }; - }, - saveDetailPipePoints: async (_projectId, pipes) => { - calls.push("PUT"); - sent.push(pipes); - return {}; - }, -}; -STUBS["B05_Profile_Api_Structures"] = { flushPendingStructures: async () => undefined }; -STUBS["B05_Profile_Api_Fetch"] = { - flushUphillOverrides: async () => undefined, - flushRouteParts: async () => undefined, // 상단측 차례(463) - saveProfileAlignment: async () => ({}), -}; -STUBS["B05_Profile_UI_Profile_Edit"] = { - markAlignmentSaved: () => undefined, - pendingAlignment: () => null, -}; -STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined }; -STUBS["B05_Profile_UI_RouteEdit_Apply"] = { - pendingRouteSave: async () => routeBody, - saveRouteDraft: async () => { - calls.push("ROUTE"); - routeSavedOnServer = true; - return { route_id: 8 }; - }, - carryAfterRouteSave: async () => 0, - routeSaveSummary: () => "", - rebuildAfterRouteSave: async () => undefined, -}; -const save = load("A00_Common", "design", "design_save.ts"); -const drafts = load("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts"); -const follow = load("common_util", "common_util_route_follow.ts"); - -const P = "proj-3490u-save"; -async function once(route, draft) { - calls.length = 0; - sent.length = 0; - routeSavedOnServer = false; - routeBody = route ? { draft: {} } : null; - drafts.writePendingPipes(P, draft); - let failure = null; - const ok = await save.saveDesign({ - projectId: P, - routeId: () => 7, - cross: async () => undefined, - failed: (error) => (failure = String(error && error.stack ? error.stack : error)), - }); - if (failure) throw new Error(failure); - return { ok, calls: [...calls], sent: [...sent], left: drafts.readPendingPipes(P) }; -} - -async function run() { - window.localStorage.setItem("frd_current_project_id", P); - const rebased = await once(true, job.draft); - const noDraft = await once(true, null); - const noRoute = await once(false, job.draft); - // 덤 — 좌표 없는 초안 관: 같은 id · 같은 누가거리 정본이면 그 좌표(노선 밖이어도) · 아니면 노선 위 - const placed = follow.placeOnLine( - [{ id: "a", chainage_m: 40 }, { id: "b", chainage_m: 60 }, { chainage_m: 70 }], - [ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - ], - [ - { id: "a", chainage_m: 40, x: 40.4, y: 3 }, - { id: "b", chainage_m: 55, x: 55, y: 0 }, - ], - ); - return { rebased, noDraft, noRoute, placed }; -} - -run() - .then((out) => { - process.stdout.write(JSON.stringify(out)); - process.exit(0); - }) - .catch((error) => { - process.stderr.write(String(error && error.stack ? error.stack : error)); - process.exit(1); - }); diff --git a/resources/tester/helper_app_settings.cjs b/resources/tester/helper_app_settings.cjs deleted file mode 100644 index 7c2fb6357..000000000 --- a/resources/tester/helper_app_settings.cjs +++ /dev/null @@ -1,36 +0,0 @@ -/* PLAN 332-2 단위검증 헬퍼 — ☰ [설정] CAD 별칭 검사(`A00_Common/app_settings_cad.ts`)를 Node 로 돌림. - * 결과 = 표준출력 JSON 한 벌. 짝 = `test_app_settings.py`. */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); - -const ROOT = path.join(__dirname, "..", ".."); -const file = path.join(ROOT, "A00_Common", "app_settings_cad.ts"); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2020, - esModuleInterop: true, - }, -}).outputText; -const box = { exports: {} }; -const req = (p) => - p.endsWith(".json") - ? require(path.join(ROOT, "A00_Common", p)) - : p === "@ui/ui_template_locale" - ? { t: (k) => k } - : {}; -new Function("exports", "module", "require", js)(box.exports, box, req); -const m = box.exports; - -const check = (aliases) => m.checkCadAliases(aliases); -process.stdout.write( - JSON.stringify({ - commands: m.CAD_COMMANDS.length, - parse: [m.parseAliases(" ll, Cc x1 "), m.parseAliases(""), m.parseAliases(",, ")], - ok: check({ LINE: ["LL"], CIRCLE: ["CC", "C2"], COPY: ["C"] }), - chars: check({ LINE: ["L!"], CIRCLE: ["원"] }), - dup: check({ LINE: ["LL"], CIRCLE: ["LL"], ARC: ["AA", "AA"] }), - name: check({ CIRCLE: ["LINE"] }), - }), -); diff --git a/resources/tester/old/helper_115_4_corridor_legacy.cjs b/resources/tester/old/helper_115_4_corridor_legacy.cjs deleted file mode 100644 index 9d95589c5..000000000 --- a/resources/tester/old/helper_115_4_corridor_legacy.cjs +++ /dev/null @@ -1,148 +0,0 @@ -/* PLAN 115-4 단위검증 헬퍼 — B05 3D 옛 방식 A(08-25 `6e87dd6cd` 실루엣 갈아 끼우기) 빌드. - * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 입력 = 픽스처(배수관 세트 · BOX · 세월교 측점). - * 결과는 표준출력에 JSON 한 벌. 짝 = `test_115_4_corridor_legacy.py`. - */ -const fs = require("fs"); -const path = require("path"); -const ROOT = path.join(__dirname, "..", ".."); -const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); -const THREE = require(path.join(ROOT, "config", "node_modules", "three")); - -const cache = new Map(); -function loadTs(file) { - const full = path.resolve(file); - if (cache.has(full)) return cache.get(full); - if (full.endsWith(".json")) { - const data = JSON.parse(fs.readFileSync(full, "utf8")); - cache.set(full, data); - return data; - } - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (request === "three") return THREE; - // 화면 모듈(@ui · @config)은 코리도 계산이 부르지 않는다 — 빈 껍데기로 넘긴다. - if (request.startsWith("@ui/") || request.startsWith("@config/")) { - return new Proxy({}, { get: () => () => undefined }); - } - const base = request.startsWith("@util/") - ? path.join(ROOT, "common_util", request.slice(6)) - : path.join(path.dirname(full), request); - const found = [base, `${base}.ts`, path.join(base, "index.ts")].find( - (p) => fs.existsSync(p) && fs.statSync(p).isFile(), - ); - return loadTs(found ?? `${base}.ts`); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} - -const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); -const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); -const { classifyStation } = loadTs(B05("B05_Profile_UI_Corridor_Station")); -const fixture = (name) => - JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", `${name}.json`), "utf8")) - .cross_sections; -const r3 = (v) => Math.round(v * 1000) / 1000; - -/** 양 끝을 잇는 직선에서 가장 먼 점의 거리(m) — 직선 비탈이면 0. */ -function bend(points) { - const a = points[0]; - const b = points[points.length - 1]; - const dx = b.offset_m - a.offset_m; - const dz = b.elevation_m - a.elevation_m; - const len = Math.hypot(dx, dz) || 1; - return Math.max( - ...points.map( - (p) => Math.abs((p.offset_m - a.offset_m) * dz - (p.elevation_m - a.elevation_m) * dx) / len, - ), - ); -} - -const results = {}; - -// ① 배수관 세트 측점(85.052 · 우절토 · 유출 좌측 성토) — 픽스처에 평면 자리가 없어 직선 노선에 얹는다. -{ - const sections = fixture("b07_cross_overlay_sections").map((s) => ({ - ...s, - station_id: `S${s.chainage_m}`, - center_x: s.chainage_m, - center_y: 0, - frame: { left_xy: [0, 1] }, - })); - const set = sections.find((s) => s.culvert); - const plain = classifyStation(set); - const legacy = classifyStation(set, { silhouette: true }); - const side = set.uphill_side === "right" ? "left" : "right"; // 유출측 - const key = `fill:${side}`; - const p0 = plain.pieces.get(key); - const p1 = legacy.pieces.get(key); - results.set = { - chainage: set.chainage_m, - key, - plainBend: r3(bend(p0)), - legacyBend: r3(bend(p1)), - plainRatio: r3( - Math.abs(p0[p0.length - 1].offset_m - p0[0].offset_m) / - Math.abs(p0[p0.length - 1].elevation_m - p0[0].elevation_m), - ), - wallSpans: Object.keys(legacy.wallSpans ?? {}).sort(), - plainWallSpans: plain.wallSpans ?? null, - indexArg: - JSON.stringify([...classifyStation(set, 3).pieces]) === JSON.stringify([...plain.pieces]), - }; - const route = sections.map((s) => ({ x: s.center_x, y: 0 })); - const base = buildCorridor(sections, route); - const old = buildCorridor(sections, route, undefined, undefined, { legacy: true }); - const ribbonAt = (build) => - build.ribbons.find((r) => r.kind === "fill" && r.side === side && !r.patch); - const rowAt = (ribbon, chainage) => { - let best = 0; - ribbon.chainages.forEach((c, i) => { - if (Math.abs(c - chainage) < Math.abs(ribbon.chainages[best] - chainage)) best = i; - }); - const pts = []; - for (let c = 0; c < ribbon.colCount; c += 1) { - const i = (best * ribbon.colCount + c) * 3; - pts.push({ offset_m: ribbon.positions[i + 1], elevation_m: ribbon.positions[i + 2] }); - } - return pts; - }; - results.build = { - basePlanCurves: Array.isArray(base.planCurves), - baseCutWalls: Array.isArray(base.cutWalls), - keys: Object.keys(old).sort(), - patches: old.ribbons.filter((r) => r.patch).length, - structures: old.structures.length, - rowBend: r3(bend(rowAt(ribbonAt(old), set.chainage_m))), - baseRowBend: r3(bend(rowAt(ribbonAt(base), set.chainage_m))), - rows: ribbonAt(old).chainages.length, - }; -} - -// ② BOX(352.14) · 세월교(173.087) 측점 — 옛 방식 A 에서도 비탈은 원본(실루엣 없음). -{ - const sections = fixture("b06_73_4_body_guard_sections"); - results.others = sections - .filter((s) => s.box || s.ford) - .map((s) => { - const plain = classifyStation(s); - const legacy = classifyStation(s, { silhouette: true }); - const slopes = (st) => - JSON.stringify( - [...st.pieces].filter(([k]) => k.startsWith("cut:") || k.startsWith("fill:")), - ); - return { - chainage: s.chainage_m, - kind: s.box ? "box" : "ford", - sameSlopes: slopes(plain) === slopes(legacy), - wallSpans: legacy.wallSpans ?? null, - shoulderSpans: legacy.shoulderSpans ?? null, - }; - }); -} - -process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/old/helper_115_5_corridor_improve.cjs b/resources/tester/old/helper_115_5_corridor_improve.cjs deleted file mode 100644 index e25c5201c..000000000 --- a/resources/tester/old/helper_115_5_corridor_improve.cjs +++ /dev/null @@ -1,420 +0,0 @@ -/* PLAN 115-5 단위검증 헬퍼 — B05 3D 제안 ① 새 방식 개선(폭주 · 행 자리 · 브레이크라인 · - * 구조물 둘레 · 방식 등록부). TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 - * 표준출력에 JSON 한 벌. - * - * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. - */ -const fs = require("fs"); -const path = require("path"); -const ROOT = path.join(__dirname, "..", ".."); -const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); -const THREE = require(path.join(ROOT, "config", "node_modules", "three")); -const NM = path.join(ROOT, "config", "node_modules"); -// 맨 이름 라이브러리(제안 ② TIN · 등록부가 끌어옴) — config/node_modules 에서 푼다(helper_115_6 과 같은 규칙). -const LIBS = { - // delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함). - delaunator: require(path.join(NM, "delaunator", "index.js")), - // three 부록(ESM 전용) — Node 22 require(esm). - "three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")), - "@kninnug/constrainautor": { - __esModule: true, - default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")), - }, -}; - -const cache = new Map(); -function loadTs(file) { - const full = path.resolve(file); - if (cache.has(full)) return cache.get(full); - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (request === "three") return THREE; - if (LIBS[request]) return LIBS[request]; - // 화면 모듈(@ui · @config)은 코리도 계산이 부르지 않는다 — 빈 껍데기로 넘긴다. - if (request.startsWith("@ui/") || request.startsWith("@config/")) { - return new Proxy({}, { get: () => () => undefined }); - } - const target = request.startsWith("@util/") - ? path.join(ROOT, "common_util", request.slice(6)) - : path.join(path.dirname(full), request); - if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); - return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} - -const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); -const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); -const deform = loadTs(B05("B05_Profile_UI_Corridor_Deform")); -const { buildPolylineSampler } = loadTs(B05("B05_Profile_UI_Corridor_Frames")); - -const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 10000); -const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5) -const ROAD_Z = 100; - -/** 직선 노선 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). 노선은 x 축에서 angle 만큼 돈다. */ -function station(chainage, mode, groundZ, ratio, angle = 0) { - const w = Math.abs(groundZ - ROAD_Z) * ratio; - const pts = [ - [-30, groundZ], - [-(HALF + w), groundZ], - [-HALF, ROAD_Z], - [-2.5, ROAD_Z], - [2.5, ROAD_Z], - [HALF, ROAD_Z], - [HALF + w, groundZ], - [30, groundZ], - ]; - const samples = []; - for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); - return { - station_id: `S${chainage}`, - chainage_m: chainage, - label: "", - kind: "regular", - center_x: chainage * Math.cos(angle), - center_y: chainage * Math.sin(angle), - center_z: groundZ, - frame: { left_xy: [-Math.sin(angle), Math.cos(angle)] }, - samples, - design: { - section_mode: mode, - road_edges: { - left: { offset_m: HALF, elevation_m: ROAD_Z }, - right: { offset_m: -HALF, elevation_m: ROAD_Z }, - }, - carriageway_edges: { - left: { offset_m: 2.5, elevation_m: ROAD_Z }, - right: { offset_m: -2.5, elevation_m: ROAD_Z }, - }, - design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), - ditch: { type: "none" }, - ditch_enabled: false, - ditch_side: "left", - }, - }; -} - -function routeOf(sections) { - const last = sections[sections.length - 1].chainage_m; - return [ - { x: 0, y: 0 }, - { x: last, y: 0 }, - ]; -} - -function fieldOf(sections, groundAt) { - const build = buildCorridor(sections, routeOf(sections)); - const field = deform.buildDeformField(build, deform.stationProfileSource(sections), groundAt); - return { build, field }; -} - -const results = {}; - -// ① 폭주 0(P0-1 ① ④) — 지반이 성토선보다 가파르게 떨어져 끝내 안 만난다. 옛 규칙은 60 m 까지 -// 마지막 구배로 메웠다. 새 규칙은 단면 끝(6 m)까지만 · 높이 변화는 단면 높이(4) + 1 m 까지. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); - const groundAt = (x, y) => 96 - 2 * Math.max(0, Math.abs(y) - HALF); - const { field } = fieldOf(sections, groundAt); - let maxDelta = 0; - for (let s = 0.25; s <= 40; s += 0.25) { - const y = HALF + s; - maxDelta = Math.max( - maxDelta, - Math.abs(field.deformZ(10, y, groundAt(10, y)) - groundAt(10, y)), - ); - } - const far = HALF + 20; - results.runaway = { - catch: r4(field.catchAt(50, 0, 0)), - far: r4(field.deformZ(10, far, groundAt(10, far)) - groundAt(10, far)), - maxDelta: r4(maxDelta), - }; -} - -// ② 조각 규칙(P0-1 ②) — 20 · 40 측점은 절토(성토 조각 없음). 그 사이 행은 0 · 60 측점 -// 성토를 끌어와 메우지 않는다. -{ - const sections = [ - station(0, "both_fill", 96, 1.5), - station(20, "both_cut", 104, 1.0), - station(40, "both_cut", 104, 1.0), - station(60, "both_fill", 96, 1.5), - ]; - const { field } = fieldOf(sections, () => 96); - results.pieceRule = { - between: r4(field.deformZ(30, HALF + 2, 96)), - nearFill: r4(field.deformZ(5, HALF + 2, 96)), - }; -} - -// ③ 행 자리(P0-2) — 구간 길이가 1.2 m 와 93 m 로 섞인 노선. 표본 점은 노선 위 · 노선 길이 = -// 누가거리(0 m 어긋남). -{ - const route = [ - { x: 0, y: 0 }, - { x: 1.2, y: 0 }, - { x: 2.3, y: 0.4 }, - { x: 95, y: 12 }, - { x: 96.1, y: 12.6 }, - { x: 97.1, y: 13.5 }, - { x: 98, y: 14.6 }, - ]; - const cum = [0]; - for (let i = 1; i < route.length; i += 1) { - cum.push(cum[i - 1] + Math.hypot(route[i].x - route[i - 1].x, route[i].y - route[i - 1].y)); - } - const sampler = buildPolylineSampler(route); - let alongMax = 0; - let lateralMax = 0; - let folds = 0; - let previous = -Infinity; - for (let c = 0; c <= cum[cum.length - 1]; c += 0.2) { - const p = sampler(c); - let best = Infinity; - let along = 0; - for (let i = 1; i < route.length; i += 1) { - const ax = route[i - 1].x; - const ay = route[i - 1].y; - const dx = route[i].x - ax; - const dy = route[i].y - ay; - const l2 = dx * dx + dy * dy; - const t = Math.max(0, Math.min(1, ((p.x - ax) * dx + (p.y - ay) * dy) / l2)); - const d = Math.hypot(p.x - ax - dx * t, p.y - ay - dy * t); - if (d < best) { - best = d; - along = cum[i - 1] + Math.sqrt(l2) * t; - } - } - alongMax = Math.max(alongMax, Math.abs(along - c)); - lateralMax = Math.max(lateralMax, best); - if (along < previous - 1e-9) folds += 1; - previous = along; - } - results.rowPlace = { alongMax, lateralMax, folds }; -} - -const { deformTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Terrain")); -const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); -const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); - -/** 격자 지형(모델 좌표 [x0,x1]×[y0,y1] · 칸 step) — 높이 함수 · 정점색 200. */ -function gridTerrain(x0, x1, y0, y1, step, heightAt, bounds) { - const cx = (bounds.x[0] + bounds.x[1]) / 2; - const cy = (bounds.y[0] + bounds.y[1]) / 2; - const cz = (bounds.z[0] + bounds.z[1]) / 2; - const nx = Math.round((x1 - x0) / step) + 1; - const ny = Math.round((y1 - y0) / step) + 1; - const positions = new Float32Array(nx * ny * 3); - for (let j = 0; j < ny; j += 1) { - for (let i = 0; i < nx; i += 1) { - const v = j * nx + i; - const x = x0 + i * step; - const y = y0 + j * step; - positions[v * 3] = x - cx; - positions[v * 3 + 1] = heightAt(x, y) - cz; - positions[v * 3 + 2] = -(y - cy); - } - } - const index = []; - for (let j = 0; j < ny - 1; j += 1) { - for (let i = 0; i < nx - 1; i += 1) { - const a = j * nx + i; - index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); - } - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - const colors = new Uint8Array(nx * ny * 4).fill(200); - geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true)); - geometry.setIndex(index); - return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })); -} - -function shape(build, bounds, terrain) { - const split = new TerrainBandSplit(terrain, { minX: -200, maxX: 200, minZ: -200, maxZ: 200 }); - return deformTerrain(split, build, bounds, new TerrainHeightIndex(terrain)); -} - -/** 변형 지형 삼각형들 — 모델 좌표 [x,y,z]×3 · 정점 칠 표식. */ -function trianglesOf(out, bounds) { - const cx = (bounds.x[0] + bounds.x[1]) / 2; - const cy = (bounds.y[0] + bounds.y[1]) / 2; - const cz = (bounds.z[0] + bounds.z[1]) / 2; - const tris = []; - out.root.traverse((child) => { - if (!(child instanceof THREE.Mesh) || !child.userData.owned) return; - const p = child.geometry.getAttribute("position"); - const idx = child.geometry.getIndex(); - const keep = child.userData.keepColor; - for (let t = 0; t < idx.count; t += 3) { - const vs = [0, 1, 2].map((k) => idx.getX(t + k)); - tris.push({ - pts: vs.map((v) => [p.getX(v) + cx, -p.getZ(v) + cy, p.getY(v) + cz]), - keep: vs.map((v) => keep[v]), - }); - } - }); - return tris; -} - -/** 점을 덮는 삼각형들의 높이 — 넓이 0 은 뺀다. */ -function heightsAt(tris, x, y) { - const out = []; - for (const { pts } of tris) { - const [a, b, c] = pts; - const den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); - if (Math.abs(den) < 1e-12) continue; - const w0 = ((b[1] - c[1]) * (x - c[0]) + (c[0] - b[0]) * (y - c[1])) / den; - const w1 = ((c[1] - a[1]) * (x - c[0]) + (a[0] - c[0]) * (y - c[1])) / den; - const w2 = 1 - w0 - w1; - if (w0 < -1e-7 || w1 < -1e-7 || w2 < -1e-7) continue; - out.push(w0 * a[2] + w1 * b[2] + w2 * c[2]); - } - return out; -} - -// ④ 브레이크라인(P1-3) — 격자에 비스듬한 노선(31°) · 성토 1:1.5 · 울퉁불퉁 지형. 모든 정점에서 -// 덮는 삼각형 높이가 같다(T자 균열 0) · 색은 면 단위 · 비탈 끝 선 위 높이 = 원지반. -{ - const angle = (31 * Math.PI) / 180; - const sections = [0, 15, 30, 45].map((c) => station(c, "both_fill", 96, 1.5, angle)); - const route = [ - { x: 0, y: 0 }, - { x: 45 * Math.cos(angle), y: 45 * Math.sin(angle) }, - ]; - const build = buildCorridor(sections, route); - build.sections = sections; - const bounds = { x: [-40, 80], y: [-40, 70], z: [80, 110] }; - const ground = (x, y) => 96 + 0.3 * Math.sin(x * 0.7) + 0.2 * Math.cos(y * 0.9); - const out = shape(build, bounds, gridTerrain(-20, 60, -20, 50, 0.97, ground, bounds)); - const tris = trianglesOf(out, bounds); - let worst = 0; - let checked = 0; - const seen = new Set(); - for (const { pts } of tris) { - for (const [x, y] of pts) { - const key = `${Math.round(x * 1e4)},${Math.round(y * 1e4)}`; - if (seen.has(key)) continue; - seen.add(key); - const hs = heightsAt(tris, x, y); - if (hs.length < 2) continue; - checked += 1; - worst = Math.max(worst, Math.max(...hs) - Math.min(...hs)); - } - } - const mixed = tris.filter(({ keep }) => keep.some((k) => k) && keep.some((k) => !k)).length; - // 비탈 끝 선(가운데 행 좌측) 위 높이 = 원지반. - const field = deform.buildDeformField(build, deform.stationProfileSource(sections), ground); - const row = Math.floor(field.rows.length / 2); - const [tx, ty] = field.pointAt(row, 0, field.catchAt(row, 0, 0)); - const toe = heightsAt(tris, tx, ty); - results.breakline = { - lines: out.stats.lines, - split: out.stats.split, - fallback: out.stats.fallback, - checked, - worst, - mixed, - toeGap: toe.length ? Math.max(...toe.map((h) => Math.abs(h - ground(tx, ty)))) : null, - }; -} - -// ⑤ 구조물 둘레(P1-4) — 20 측점 좌측 옹벽(기초 5~7 · 높이 95.5 / 벽체 5~5.6 · 상단 99.5). -// 지형(100)이 벽 상단보다 높다. 구멍은 벽체 윗면만 · 기초 위 · 둘레 띠는 상단(99.5)까지 내림 · -// 띠 밖은 그대로. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 100, 1.5)); - const build = buildCorridor(sections, routeOf(sections)); - build.sections = sections; - const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 }); - build.structures = [ - { - chainage_m: 20, - kind: "revet", - polygon: [ - [5, 95], - [7, 95], - [7, 95.5], - [5.6, 95.5], - [5.6, 99.5], - [5, 99.5], - ], - rings: [ring(18), ring(20), ring(22)], - }, - ]; - const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] }; - const out = shape( - build, - bounds, - gridTerrain(-10, 50, -20, 20, 0.5, () => 100, bounds), - ); - const tris = trianglesOf(out, bounds); - const one = (x, y) => { - const hs = heightsAt(tris, x, y); - return hs.length ? Math.round(Math.max(...hs) * 1e4) / 1e4 : null; - }; - results.drape = { - holes: out.stats.holes, - stemTop: one(20, 5.3), // 벽체 윗면 = 구멍 - footing: one(20, 6.5), // 기초 위 = 묻음 · 상단까지 - band: one(20, 7.4), // 둘레 띠(0.75 m) 안 - outside: one(20, 9), // 띠 밖 = 그대로 - endBand: one(22.5, 5.3), // 구조물 끝 너머 띠 - }; -} - -// ⑥ 3D 방식 등록부(고르기 한 줄) — 자리 넷 · 등록 셋 · 제안 ② 빈자리 · 옛 두 키 옮겨 읽기. -{ - const store = () => { - const proto = { - getItem(k) { - return Object.prototype.hasOwnProperty.call(this, k) ? this[k] : null; - }, - setItem(k, v) { - this[k] = String(v); - }, - removeItem(k) { - delete this[k]; - }, - key(i) { - return Object.keys(this)[i] ?? null; - }, - }; - Object.defineProperty(proto, "length", { - get() { - return Object.keys(this).length; - }, - }); - return Object.create(proto); - }; - globalThis.window = { localStorage: store(), sessionStorage: store() }; - const modes = loadTs(B05("B05_Profile_UI_Corridor_Modes")); - const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); - const picks = {}; - picks.fresh = modes.corridorModeId(); - state.writeStateRaw("corridor-deform", "0"); - picks.oldDeformOff = modes.corridorModeId(); - state.writeStateRaw("corridor-legacy", "1"); - picks.oldLegacyOn = modes.corridorModeId(); - modes.setCorridorMode("proposal2"); - picks.proposal = modes.corridorModeId(); - picks.proposalDraws = modes.activeCorridorMode().id; - modes.setCorridorMode("new1"); - picks.set = modes.corridorModeId(); - results.modes = { - slots: modes.MODE_SLOTS.map(([id, label]) => `${id}:${label}`), - registered: modes.CORRIDOR_MODES.map((mode) => mode.id), - defaultMode: modes.DEFAULT_MODE, - newHasTerrain: typeof modes.modeOf("new1").terrain === "function", - ...picks, - }; -} - -process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/old/helper_115_6_corridor_tin.cjs b/resources/tester/old/helper_115_6_corridor_tin.cjs deleted file mode 100644 index a3c19406a..000000000 --- a/resources/tester/old/helper_115_6_corridor_tin.cjs +++ /dev/null @@ -1,585 +0,0 @@ -/* PLAN 115-6 단위검증 헬퍼 — B05 3D 제안 ② 단일 삼각망(TIN) 합성 지형 검사. - * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌. - * (트랜스파일 틀은 helper_115_corridor_deform.cjs 와 같다.) - */ -const fs = require("fs"); -const path = require("path"); -const ROOT = path.join(__dirname, "..", ".."); -const NM = path.join(ROOT, "config", "node_modules"); -const ts = require(path.join(NM, "typescript")); -const THREE = require(path.join(NM, "three")); -const LIBS = { - three: THREE, - // 기본 내보내기(ESM default) 꼴로 감싼다. - // delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함). - delaunator: require(path.join(NM, "delaunator", "index.js")), - // three 부록(ESM 전용) — Node 22 require(esm). - "three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")), - "@kninnug/constrainautor": { - __esModule: true, - default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")), - }, -}; - -const cache = new Map(); -function loadTs(file) { - const full = path.resolve(file); - if (cache.has(full)) return cache.get(full); - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2020, - }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (LIBS[request]) return LIBS[request]; - if (request.startsWith("@ui/") || request.startsWith("@config/")) { - return new Proxy({}, { get: () => () => undefined }); - } - const target = request.startsWith("@util/") - ? path.join(ROOT, "common_util", request.slice(6)) - : path.join(path.dirname(full), request); - if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); - return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} - -const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); -const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); -const { tinTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Tin")); -const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); -const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); -const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg")); - -const HALF = 3; -const ROAD_Z = 100; - -/** 측점 하나 — 중심 (cx, cy) · 좌향 (lx, ly) · 지반 평탄 groundZ · 양측 같은 비탈 1:ratio. */ -function station(chainage, cx, cy, lx, ly, groundZ, ratio) { - const w = Math.abs(groundZ - ROAD_Z) * ratio; - const mode = groundZ < ROAD_Z ? "both_fill" : "both_cut"; - const pts = [ - [-40, groundZ], - [-(HALF + w), groundZ], - [-HALF, ROAD_Z], - [-2.5, ROAD_Z], - [2.5, ROAD_Z], - [HALF, ROAD_Z], - [HALF + w, groundZ], - [40, groundZ], - ]; - const samples = []; - for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); - return { - station_id: `S${chainage}`, - chainage_m: chainage, - label: "", - kind: "regular", - center_x: cx, - center_y: cy, - center_z: groundZ, - frame: { left_xy: [lx, ly] }, - samples, - design: { - section_mode: mode, - road_edges: { - left: { offset_m: HALF, elevation_m: ROAD_Z }, - right: { offset_m: -HALF, elevation_m: ROAD_Z }, - }, - carriageway_edges: { - left: { offset_m: 2.5, elevation_m: ROAD_Z }, - right: { offset_m: -2.5, elevation_m: ROAD_Z }, - }, - design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), - ditch: { type: "none" }, - ditch_enabled: false, - ditch_side: "left", - }, - }; -} - -/** 격자 지형(모델 좌표 x0..x1 · y0..y1 · 간격 step) — 씬 좌표로 구운 메시. */ -function gridTerrain(bounds, x0, x1, y0, y1, step, heightAt) { - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const nx = Math.round((x1 - x0) / step) + 1; - const ny = Math.round((y1 - y0) / step) + 1; - const positions = new Float32Array(nx * ny * 3); - const colors = new Uint8Array(nx * ny * 4).fill(200); - for (let j = 0; j < ny; j += 1) { - for (let i = 0; i < nx; i += 1) { - const v = j * nx + i; - const x = x0 + i * step; - const y = y0 + j * step; - positions[v * 3] = x - ox; - positions[v * 3 + 1] = heightAt(x, y) - oz; - positions[v * 3 + 2] = -(y - oy); - } - } - const index = []; - for (let j = 0; j < ny - 1; j += 1) { - for (let i = 0; i < nx - 1; i += 1) { - const a = j * nx + i; - // 대각을 번갈아 — 들로네가 마음대로 고르면 원래 삼각형과 달라지는 자리를 일부러 만든다. - if ((i + j) % 2) index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); - else index.push(a, a + nx + 1, a + nx, a, a + 1, a + nx + 1); - } - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true)); - geometry.setIndex(index); - const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })); - return { terrain, positions, index, ox, oy, oz }; -} - -const key = (x, y, z) => `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`; -const pkey = (x, z) => `${Math.round(x * 1e4)},${Math.round(z * 1e4)}`; -/** PSLG 점(배정밀도) → 망 정점(단정밀도)과 같은 키. */ -const fkey = (x, z) => pkey(Math.fround(x), Math.fround(z)); - -/** 결과 망 전체(남은 원래 + TIN) 삼각형을 [[x,y,z]×3] 로. */ -function trianglesOf(root) { - const out = []; - root.traverse((child) => { - if (!(child instanceof THREE.Mesh)) return; - const p = child.geometry.getAttribute("position"); - const idx = child.geometry.getIndex(); - const count = idx ? idx.count : p.count; - for (let t = 0; t < count; t += 3) { - const tri = []; - for (let k = 0; k < 3; k += 1) { - const v = idx ? idx.getX(t + k) : t + k; - tri.push([p.getX(v), p.getY(v), p.getZ(v)]); - } - out.push({ tri, tin: child.name === "terrain-tin-mesh" }); - } - }); - return out; -} - -/** 지형 바깥 테두리(모델 사각형 x0..x1 · y0..y1) 위 변인가 — 틈 셈에서 뺀다. */ -function frameOf(bounds, x0, x1, y0, y1) { - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const on = (p) => { - const x = p[0] + ox; - const y = -p[2] + oy; - return ( - Math.abs(x - x0) < 1e-3 || Math.abs(x - x1) < 1e-3 || Math.abs(y - y0) < 1e-3 || Math.abs(y - y1) < 1e-3 - ); - }; - return { boundary: (a, b) => on(a) && on(b) }; -} - -/** 제안 ② 망(TIN 조각)의 씬 높이 조회기. */ -function tinHeights(out) { - const p = out.root.getObjectByName("terrain-tin-mesh").geometry.getAttribute("position"); - const tris = []; - for (let t = 0; t < p.count; t += 3) { - tris.push([0, 1, 2].map((k) => [p.getX(t + k), p.getY(t + k), p.getZ(t + k)])); - } - return (sx, sz) => { - for (const [a, b, c] of tris) { - const d = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]); - if (Math.abs(d) < 1e-12) continue; - const w0 = ((b[2] - c[2]) * (sx - c[0]) + (c[0] - b[0]) * (sz - c[2])) / d; - const w1 = ((c[2] - a[2]) * (sx - c[0]) + (a[0] - c[0]) * (sz - c[2])) / d; - const w2 = 1 - w0 - w1; - if (w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9) continue; - return w0 * a[1] + w1 * b[1] + w2 * c[1]; - } - return null; - }; -} - -/** 한 번 돌리기 — 지형 · 노선 · 측점을 받아 결과와 검사 수치. */ -function run({ sections, route, terrainSpec, structures }) { - const build = buildCorridor(sections, route); - build.sections = sections; - build.structures = structures ?? []; - const { bounds, x0, x1, y0, y1, step, heightAt } = terrainSpec; - const { terrain, positions, index } = gridTerrain(bounds, x0, x1, y0, y1, step, heightAt); - const before = positions.slice(); - const split = new TerrainBandSplit(terrain, { - minX: -1e4, - maxX: 1e4, - minZ: -1e4, - maxZ: 1e4, - }); - const heightIndex = new TerrainHeightIndex(terrain); - const out = tinTerrain(split, build, bounds, heightIndex); - return { out, before, positions, index, build, heightIndex }; -} - -/** 틈 검사 — 한 번만 쓰인 변 중 지형 바깥 테두리 · 노면 끝(리본 이음) · 구멍 둘레가 아닌 것. */ -function gapEdges(tris, out, original) { - const pslg = out.design.pslg; - const tagAt = new Map(); - for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]); - const uses = new Map(); - tris.forEach(({ tri }) => { - for (let k = 0; k < 3; k += 1) { - const a = tri[k]; - const b = tri[(k + 1) % 3]; - const ka = key(...a); - const kb = key(...b); - const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; - const entry = uses.get(e) ?? { n: 0, a, b }; - entry.n += 1; - uses.set(e, entry); - } - }); - let gaps = 0; - let maxGapLen = 0; - for (const { n, a, b } of uses.values()) { - if (n !== 1) continue; - const ta = tagAt.get(pkey(a[0], a[2])); - const tb = tagAt.get(pkey(b[0], b[2])); - if (ta === PSLG.TAG_ANCHOR && tb === PSLG.TAG_ANCHOR) continue; - if (ta === PSLG.TAG_HOLE && tb === PSLG.TAG_HOLE) continue; - if (original.boundary(a, b)) continue; - gaps += 1; - maxGapLen = Math.max(maxGapLen, Math.hypot(a[0] - b[0], a[2] - b[2])); - } - return { gaps, maxGapLen }; -} - -/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 1 mm 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */ -function lostVertices(tris, before, out) { - const region = new PSLG.LoopIndex([out.design.outer, ...out.design.holes]); - const border = new PSLG.SegGrid(2); - let id = 0; - [out.design.outer, ...out.design.holes].forEach((loop) => { - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - border.add(id, loop[j][0], loop[j][1], loop[i][0], loop[i][1]); - id += 1; - } - }); - const present = new Set(); - tris.forEach(({ tri }) => tri.forEach((p) => present.add(key(...p)))); - let lost = 0; - for (let v = 0; v < before.length / 3; v += 1) { - const x = before[v * 3]; - const z = before[v * 3 + 2]; - if (region.contains(x, z) || border.passesNear(x, z, 1e-3)) continue; - if (!present.has(key(x, before[v * 3 + 1], z))) lost += 1; - } - return lost; -} - -const results = {}; -const r6 = (v) => Math.round(v * 1e6) / 1e6; - -// ① 직선 도로 · 굴곡 지형 — 틈 0 · 브레이크라인 = 삼각형 변 · 측점 단면 = B06 · 공사 밖 원래 그대로. -{ - const bounds = { x: [-10, 50], y: [-25, 25], z: [90, 110] }; - const bump = (x, y) => 96 + 0.3 * Math.sin(x / 3) * Math.cos(y / 4); - const sections = [0, 20, 40].map((c) => station(c, c, 0, 0, 1, 96, 1.5)); - const spec = { bounds, x0: -10, x1: 50, y0: -25, y1: 25, step: 0.5, heightAt: bump }; - const { out, before, positions, index, heightIndex } = run({ - sections, - route: [ - { x: 0, y: 0 }, - { x: 40, y: 0 }, - ], - terrainSpec: spec, - }); - const tris = trianglesOf(out.root); - // 원래 지형 바깥 테두리(씬) — x = -10/50 · y = -25/25 선 위. - const ox = 20; - const onFrame = (p) => - Math.abs(p[0] + ox + 10) < 1e-4 || - Math.abs(p[0] + ox - 50) < 1e-4 || - Math.abs(-p[2] + 25) < 1e-4 || - Math.abs(-p[2] - 25) < 1e-4; - const original = { boundary: (a, b) => onFrame(a) && onFrame(b) }; - const gap = gapEdges(tris, out, original); - // 원래 삼각형 집합(정점 셋 키) — TIN 의 지형 정점만으로 된 삼각형은 원래 것이어야 한다. - const origTris = new Set(); - const vkey = (v) => key(before[v * 3], before[v * 3 + 1], before[v * 3 + 2]); - for (let t = 0; t < index.length; t += 3) { - origTris.add([vkey(index[t]), vkey(index[t + 1]), vkey(index[t + 2])].sort().join("|")); - } - const origVerts = new Set(); - for (let v = 0; v < before.length / 3; v += 1) origVerts.add(vkey(v)); - const pslg = out.design.pslg; - const tagAt = new Map(); - for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]); - let terrainOnly = 0; - let notOriginal = 0; - let strayVerts = 0; - tris.forEach(({ tri, tin }) => { - if (!tin) return; - const tags = tri.map((p) => tagAt.get(pkey(p[0], p[2]))); - tri.forEach((p, k) => { - if (tags[k] === PSLG.TAG_TERRAIN && !origVerts.has(key(...p))) strayVerts += 1; - }); - if (tags.every((t) => t === PSLG.TAG_TERRAIN)) { - terrainOnly += 1; - if (!origTris.has(tri.map((p) => key(...p)).sort().join("|"))) notOriginal += 1; - } - }); - // 브레이크라인 = 망의 변 — 설계 · 노면 끝 제약이 모두 변으로 들어갔나. - const meshEdges = new Set(); - tris.forEach(({ tri }) => { - for (let k = 0; k < 3; k += 1) { - const a = pkey(tri[k][0], tri[k][2]); - const b = pkey(tri[(k + 1) % 3][0], tri[(k + 1) % 3][2]); - meshEdges.add(a < b ? `${a}|${b}` : `${b}|${a}`); - } - }); - let designEdges = 0; - let missingEdges = 0; - for (let e = 0; e < pslg.kinds.length; e += 1) { - if (pslg.kinds[e] === PSLG.EDGE_TERRAIN) continue; - designEdges += 1; - const a = fkey(pslg.x[pslg.edges[e * 2]], pslg.z[pslg.edges[e * 2]]); - const b = fkey(pslg.x[pslg.edges[e * 2 + 1]], pslg.z[pslg.edges[e * 2 + 1]]); - if (!meshEdges.has(a < b ? `${a}|${b}` : `${b}|${a}`)) missingEdges += 1; - } - // 측점(20) 단면 — 망을 그 선에서 재어 B06 설계선(성토 1:1.5 → 비탈 끝에서 지반)과 견준다. - const tinMesh = out.root.getObjectByName("terrain-tin-mesh"); - const tinTris = []; - const tp = tinMesh.geometry.getAttribute("position"); - for (let t = 0; t < tp.count; t += 3) { - tinTris.push([0, 1, 2].map((k) => [tp.getX(t + k), tp.getY(t + k), tp.getZ(t + k)])); - } - const heightOn = (sx, sz) => { - for (const [a, b, c] of tinTris) { - const d = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]); - if (Math.abs(d) < 1e-12) continue; - const w0 = ((b[2] - c[2]) * (sx - c[0]) + (c[0] - b[0]) * (sz - c[2])) / d; - const w1 = ((c[2] - a[2]) * (sx - c[0]) + (a[0] - c[0]) * (sz - c[2])) / d; - const w2 = 1 - w0 - w1; - if (w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9) continue; - return w0 * a[1] + w1 * b[1] + w2 * c[1]; - } - return null; - }; - let sectionErr = 0; - let toeErr = 0; - for (const side of [1, -1]) { - let toe = null; - for (let s = 0.05; s <= 8; s += 0.05) { - const y = side * (HALF + s); - const z = heightOn(20 - ox, -y); - if (z === null) continue; - const g = heightIndex.heightAt(20 - ox, -y) + 100; - const line = ROAD_Z - s / 1.5; - const expect = Math.max(line, g); - sectionErr = Math.max(sectionErr, Math.abs(z + 100 - expect)); - if (toe === null && line <= g) toe = s; - } - // 비탈 끝 점(설계) — 그 측 그 행의 PSLG 비탈 끝과 단면 교차 거리 차. - let best = Infinity; - for (let i = 0; i < pslg.count; i += 1) { - if (pslg.tag[i] !== PSLG.TAG_FILL || Math.abs(pslg.x[i] - (20 - ox)) > 1e-6) continue; - best = Math.min(best, Math.abs(Math.abs(pslg.z[i]) - HALF - toe)); - } - toeErr = Math.max(toeErr, best === Infinity ? 99 : best); - } - // 드레이프(115-8) — 측점 아닌 행의 비탈 끝(둘레 점)은 그 자리 망 지형 높이. - let offGround = 0; - let daylightRows = 0; - out.design.outer.forEach(([dx, dz]) => { - const tag = tagAt.get(fkey(dx, dz)); - if (tag !== PSLG.TAG_FILL && tag !== PSLG.TAG_CUT) return; - if ([-20, 0, 20].some((x) => Math.abs(dx - x) < 1e-6)) return; - const i = [...Array(pslg.count).keys()].find((k) => pslg.x[k] === dx && pslg.z[k] === dz); - daylightRows += 1; - if (Math.abs(pslg.y[i] - heightIndex.heightAt(dx, dz)) > 1e-4) offGround += 1; - }); - results.straight = { - daylightRows, - offGround, - stats: out.stats, - gaps: gap.gaps, - maxGapLen: r6(gap.maxGapLen), - originalUntouched: before.every((value, i) => value === positions[i]), - terrainOnly, - notOriginal, - strayVerts, - lostVerts: lostVertices(tris, before, out), - designEdges, - missingEdges, - sectionErr: r6(sectionErr), - toeErr: r6(toeErr), - }; -} - -// ② 구조물 구멍 — 20 측점 좌측 벽(편거리 5~6 · 19~21 m · 윗면 100) 안 삼각형 0 · 둘레 높이 ≤ 윗면. -{ - const bounds = { x: [-10, 50], y: [-25, 25], z: [90, 110] }; - const sections = [0, 20, 40].map((c) => station(c, c, 0, 0, 1, 96, 1.5)); - const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 }); - const wall = { - chainage_m: 20, - kind: "revet", - polygon: [ - [5, 94], - [6.3, 94], - [6, 99], - [5, 99], - ], - rings: [ring(19), ring(20), ring(21)], - }; - const { out } = run({ - sections, - route: [ - { x: 0, y: 0 }, - { x: 40, y: 0 }, - ], - terrainSpec: { bounds, x0: -10, x1: 50, y0: -25, y1: 25, step: 0.5, heightAt: () => 96 }, - structures: [wall], - }); - const tris = trianglesOf(out.root); - let inHole = 0; - let nearHole = 0; - tris.forEach(({ tri }) => { - const sx = tri.reduce((s, p) => s + p[0], 0) / 3 + 20; - const sy = -tri.reduce((s, p) => s + p[2], 0) / 3; - if (sx > 19.02 && sx < 20.98 && sy > 5.02 && sy < 5.98) inHole += 1; - if (sx > 19.02 && sx < 20.98 && sy > 6.02 && sy < 6.5) nearHole += 1; - }); - const pslg = out.design.pslg; - let perimeter = 0; - let aboveTop = 0; - for (let i = 0; i < pslg.count; i += 1) { - if (pslg.tag[i] !== PSLG.TAG_HOLE) continue; - perimeter += 1; - if (pslg.y[i] + 100 > 99 + 1e-6) aboveTop += 1; - } - const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -25, 25)); - results.hole = { - holes: out.stats.holes, - failed: out.stats.failed, - inHole, - nearHole, - perimeter, - aboveTop, - gaps: gap.gaps, - }; -} - -// ③ 급곡선(R 12 m) · 높은 성토(8 m · 비탈 12 m) — 안쪽 단면 겹침 청소 · 둘레 꼬임 없음 · 제약 실패 0. -{ - const R = 12; - const route = []; - for (let a = 0; a <= 180; a += 2) { - const t = (a * Math.PI) / 180; - route.push({ x: R * Math.sin(t), y: R - R * Math.cos(t) }); - } - const sections = [0, 30, 60, 90, 120, 150, 180].map((deg) => { - const t = (deg * Math.PI) / 180; - // 진행 방향 (cos, sin) · 좌향 = (-sin, cos)(원 중심 쪽). - return station(R * t, R * Math.sin(t), R - R * Math.cos(t), -Math.sin(t), Math.cos(t), 92, 1.5); - }); - const bounds = { x: [-40, 40], y: [-30, 50], z: [80, 110] }; - const { out } = run({ - sections, - route, - terrainSpec: { bounds, x0: -40, x1: 40, y0: -30, y1: 50, step: 1, heightAt: () => 92 }, - }); - const outer = out.design.outer; - let selfCross = 0; - for (let i = 0; i < outer.length; i += 1) { - const a = outer[i]; - const b = outer[(i + 1) % outer.length]; - for (let j = i + 2; j < outer.length; j += 1) { - if ((j + 1) % outer.length === i) continue; - const c = outer[j]; - const d = outer[(j + 1) % outer.length]; - if (PSLG.crossing(a[0], a[1], b[0], b[1], c[0], c[1], d[0], d[1])) selfCross += 1; - } - } - const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -40, 40, -30, 50)); - results.curve = { stats: out.stats, selfCross, gaps: gap.gaps }; -} - -// ④ 소단 — 성토 8 m(1:1.5 로 4 m · 소단 1.5 m · 다시 1:1.5) · 꺾임 종방향 선이 망의 변 · 측점 단면 그대로. -{ - const bounds = { x: [-10, 50], y: [-30, 30], z: [85, 110] }; - const berm = (c) => { - const st = station(c, c, 0, 0, 1, 92, 1.5); - const half = [ - [HALF, ROAD_Z], - [HALF + 6, ROAD_Z - 4], - [HALF + 7.5, ROAD_Z - 4], - [HALF + 13.5, 92], - [40, 92], - ]; - const pts = [...half.map(([o, z]) => [-o, z]).reverse(), [-2.5, ROAD_Z], [2.5, ROAD_Z], ...half]; - st.design.design_line = pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })); - return st; - }; - const sections = [0, 20, 40].map(berm); - const { out } = run({ - sections, - route: [ - { x: 0, y: 0 }, - { x: 40, y: 0 }, - ], - terrainSpec: { bounds, x0: -10, x1: 50, y0: -30, y1: 30, step: 0.5, heightAt: () => 92 }, - }); - const tris = trianglesOf(out.root); - const meshEdges = new Set(); - tris.forEach(({ tri }) => { - for (let k = 0; k < 3; k += 1) { - const a = pkey(tri[k][0], tri[k][2]); - const b = pkey(tri[(k + 1) % 3][0], tri[(k + 1) % 3][2]); - meshEdges.add(a < b ? `${a}|${b}` : `${b}|${a}`); - } - }); - const pslg = out.design.pslg; - let missing = 0; - for (let e = 0; e < pslg.kinds.length; e += 1) { - if (pslg.kinds[e] === PSLG.EDGE_TERRAIN) continue; - const a = fkey(pslg.x[pslg.edges[e * 2]], pslg.z[pslg.edges[e * 2]]); - const b = fkey(pslg.x[pslg.edges[e * 2 + 1]], pslg.z[pslg.edges[e * 2 + 1]]); - if (!meshEdges.has(a < b ? `${a}|${b}` : `${b}|${a}`)) missing += 1; - } - // 측점 20 단면 — 망 높이 대 B06 설계선(소단 포함 · 지반 92 아래로는 안 감). - const line = sections[1].design.design_line; - const b06 = (o) => { - for (let i = 0; i + 1 < line.length; i += 1) { - const a = line[i]; - const b = line[i + 1]; - if (o >= a.offset_m && o <= b.offset_m) { - const t = (o - a.offset_m) / (b.offset_m - a.offset_m || 1); - return Math.max(92, a.elevation_m + (b.elevation_m - a.elevation_m) * t); - } - } - return 92; - }; - const tin = tinHeights(out); - let sectionErr = 0; - for (let o = HALF + 0.05; o <= HALF + 16; o += 0.05) { - for (const side of [1, -1]) { - const z = tin(0, -side * o); // 씬 x = 20 − 20 · 씬 z = −y - if (z !== null) sectionErr = Math.max(sectionErr, Math.abs(z + 97.5 - b06(side * o))); - } - } - const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -30, 30)); - results.berm = { stats: out.stats, missing, sectionErr: r6(sectionErr), gaps: gap.gaps }; -} - -// ⑤ 3D 방식 등록부(115-5) — 「제안 ②」 자리에 TIN 지형이 등록됐나. -{ - globalThis.window = globalThis.window || {}; - const Modes = loadTs(B05("B05_Profile_UI_Corridor_Modes")); - const mode = Modes.modeOf("proposal2"); - results.registry = { - slot: Modes.MODE_SLOTS.some(([id]) => id === "proposal2"), - registered: Boolean(mode), - hasTerrain: typeof mode?.terrain === "function", - noIndex: mode?.terrain ? mode.terrain({ heightIndex: null }) : "none", - }; -} - -process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/old/helper_115_7_fill_continuity.cjs b/resources/tester/old/helper_115_7_fill_continuity.cjs deleted file mode 100644 index 52cad502d..000000000 --- a/resources/tester/old/helper_115_7_fill_continuity.cjs +++ /dev/null @@ -1,210 +0,0 @@ -/* PLAN 115-7 단위검증 헬퍼 — B05 3D 성토 비탈 끝 종단 이음(공통 변형 장 · 새 ① · 제안 ②). - * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌. - * - * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. - */ -const fs = require("fs"); -const path = require("path"); -const ROOT = path.join(__dirname, "..", ".."); -const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); -const THREE = require(path.join(ROOT, "config", "node_modules", "three")); - -const cache = new Map(); -function loadTs(file) { - const full = path.resolve(file); - if (cache.has(full)) return cache.get(full); - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (request === "three") return THREE; - if (request.startsWith("@ui/") || request.startsWith("@config/")) { - return new Proxy({}, { get: () => () => undefined }); - } - const target = request.startsWith("@util/") - ? path.join(ROOT, "common_util", request.slice(6)) - : path.join(path.dirname(full), request); - if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); - return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} - -const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); -const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); -const deform = loadTs(B05("B05_Profile_UI_Corridor_Deform")); - -const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 10000); -const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5) -const ROAD_Z = 100; - -/** x 축 직선 노선 측점 — 좌 성토(1:1.5 · 4 m 아래 · 비탈 끝 9) · 우 절토(1:1 · 4 m 위 · 비탈 끝 −7). */ -function station(chainage) { - const pts = [ - [-7, 104], - [-HALF, ROAD_Z], - [-2.5, ROAD_Z], - [2.5, ROAD_Z], - [HALF, ROAD_Z], - [9, 96], - ]; - const samples = []; - for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: o > 0 ? 96 : 104 }); - return { - station_id: `S${chainage}`, - chainage_m: chainage, - label: "", - kind: "regular", - center_x: chainage, - center_y: 0, - center_z: ROAD_Z, - frame: { left_xy: [0, 1] }, - samples, - design: { - section_mode: "right_cut", - road_edges: { - left: { offset_m: HALF, elevation_m: ROAD_Z }, - right: { offset_m: -HALF, elevation_m: ROAD_Z }, - }, - carriageway_edges: { - left: { offset_m: 2.5, elevation_m: ROAD_Z }, - right: { offset_m: -2.5, elevation_m: ROAD_Z }, - }, - design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), - ditch: { type: "none" }, - ditch_enabled: false, - ditch_side: "left", - }, - }; -} - -const SECTIONS = [0, 20, 40, 60, 80, 100].map(station); - -function fieldOf(groundAt) { - const build = buildCorridor(SECTIONS, [ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - ]); - build.sections = SECTIONS; - const field = deform.buildDeformField(build, deform.stationProfileSource(SECTIONS), groundAt); - return field; -} - -/** 행 · 측 성토 비탈 끝 열(노선 길이 순). */ -function catches(field, si, ki) { - return field.rows.map((row, r) => ({ ch: row.chainage_m, c: field.catchAt(r, si, ki) })); -} - -/** 지느러미 행 — 성토 행끼리 ±3 m 중앙값 + 3 m 넘음. */ -function fins(list) { - let count = 0; - for (const { ch, c } of list) { - if (c <= 0) continue; - const near = list - .filter((o) => o.c > 0 && Math.abs(o.ch - ch) <= 3) - .map((o) => o.c) - .sort((a, b) => a - b); - if (c > near[near.length >> 1] + 3) count += 1; - } - return count; -} - -const wobble = (list) => { - let sum = 0; - for (let i = 1; i < list.length; i += 1) { - if (list[i].c > 0 && list[i - 1].c > 0) sum += Math.abs(list[i].c - list[i - 1].c); - } - return (sum / (list[list.length - 1].ch - list[0].ch)) * 100; -}; - -const results = {}; - -// ① 도로에 직각인 골짜기 — 좁은 골(49~51 m) · 넓은 골(64~72 m)에서 지반이 3 m 더 깊다. 옛 규칙은 -// 골 행만 연장 구배로 10.5 m 까지 뻗어 지느러미 · 수직 벽. 새 규칙은 이웃 좋은 행(6 m)으로 보간 + -// 비탈 끝 밖 2 m 덧댐. 우측 절토는 같은 골을 그대로 따른다(절토는 안 바꿈). -{ - const gully = (x) => (x > 49 && x < 51) || (x > 64 && x < 72); - const groundAt = (x, y) => { - if (y >= 0) return gully(x) && y > 5 ? 93 : 96; - return gully(x) && y < -5 ? 101 : 104; - }; - const field = fieldOf(groundAt); - const fill = catches(field, 0, 0); - const inGully = fill.filter((o) => gully(o.ch)); - const cutGully = catches(field, 1, 1).filter((o) => gully(o.ch) && o.ch > 50 && o.ch < 50.5); - const zAt = (x, s) => field.deformZ(x, HALF + s, groundAt(x, HALF + s)); - results.valley = { - fins: fins(fill), - gullyMax: r4(Math.max(...inGully.map((o) => o.c))), - gullyMin: r4(Math.min(...inGully.map((o) => o.c))), - skirtMid: r4(zAt(68, 7)), // 비탈 끝(6) 밖 1 m — 96 에서 93 으로 반쯤 - skirtOut: r4(zAt(68, 8.5)), // 덧댐 밖 — 원지반 그대로 - outside: r4(zAt(30, 8)), // 골 밖 비탈 끝 밖 — 원지반 - cutGully: r4(Math.max(...cutGully.map((o) => o.c))), - cutFlat: r4( - field.catchAt( - field.rows.findIndex((row) => row.chainage_m >= 30), - 1, - 1, - ), - ), - }; -} - -// ② 계단 지형 — 1 m 띠마다 지반이 0.6 m 오르내린다(비탈 끝 6 ↔ 5.1). 원뿔 넓히기로 출렁임이 준다. -{ - const step = (x) => (Math.floor(x) % 2 ? 0.6 : 0); - const groundAt = (x, y) => (y >= 0 ? 96 + step(x) : 104); - const field = fieldOf(groundAt); - const fill = catches(field, 0, 0).filter((o) => o.ch > 1 && o.ch < 99); - // 날것 출렁임 — 띠 경계마다 0.9 m(6 − 5.1) 씩. - const raw = fill.map((o) => ({ ch: o.ch, c: step(o.ch) ? 5.1 : 6 })); - results.steps = { wobble: r4(wobble(fill)), rawWobble: r4(wobble(raw)), fins: fins(fill) }; -} - -// ③ ④ ⑤ 열 단위 — 못 닫힘 보간 · 측점 행 날것 · 0 행 끊음 / 짧은 틈 이음. -{ - const n = 101; - const ch = Array.from({ length: n }, (_, i) => i * 0.2); - const raw = new Array(n).fill(6); - const open = new Array(n).fill(0); - const station = new Array(n).fill(0); - const shaped = new Array(n).fill(1); - for (let i = 20; i <= 24; i += 1) { - raw[i] = 15; - open[i] = 1; - } - station[50] = 1; - raw[50] = 9; // 측점 행 — 튀어도 날것 - station[60] = 1; - raw[60] = 20; - open[60] = 1; // 못 닫힘 측점 — 보간 - for (let i = 70; i <= 72; i += 1) raw[i] = 0; // 짧은 틈(0.4 m) — 단면 있음 → 잇는다 - for (let i = 80; i <= 82; i += 1) { - raw[i] = 0; - shaped[i] = 0; // 단면 없음 → 끊음 - } - const out = deform.smoothFillCatches(ch, raw, open, station, shaped); - const gap = []; - for (let i = 0; i < n; i += 1) if (raw[i] === 0 && shaped[i] === 0) gap.push(out[i]); - results.column = { - openMax: r4(Math.max(...out.slice(20, 25))), - stationKept: r4(out[50]), - stationOpen: r4(out[60]), - bridged: r4(Math.min(...out.slice(70, 73))), - cutGap: r4(Math.max(...gap)), - neighbourStep: r4( - Math.max( - ...out - .slice(1) - .map((c, i) => (out[i] > 0 && c > 0 ? Math.abs(c - out[i]) / 0.2 : 0)) - .filter((_, i) => i < 49 || i > 51), - ), - ), - }; -} - -process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/old/helper_115_corridor_deform.cjs b/resources/tester/old/helper_115_corridor_deform.cjs deleted file mode 100644 index e8aa12a6b..000000000 --- a/resources/tester/old/helper_115_corridor_deform.cjs +++ /dev/null @@ -1,424 +0,0 @@ -/* PLAN 115 단위검증 헬퍼 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사. - * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌. - * - * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. - */ -const fs = require("fs"); -const path = require("path"); -const ROOT = path.join(__dirname, "..", ".."); -const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); -const THREE = require(path.join(ROOT, "config", "node_modules", "three")); - -const cache = new Map(); -function loadTs(file) { - const full = path.resolve(file); - if (cache.has(full)) return cache.get(full); - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - cache.set(full, box.exports); - const localRequire = (request) => { - if (request === "three") return THREE; - // 화면 모듈(@ui · @config)은 코리도 계산이 부르지 않는다 — 빈 껍데기로 넘긴다. - if (request.startsWith("@ui/") || request.startsWith("@config/")) { - return new Proxy({}, { get: () => () => undefined }); - } - const target = request.startsWith("@util/") - ? path.join(ROOT, "common_util", request.slice(6)) - : path.join(path.dirname(full), request); - if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); - return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); - }; - new Function("exports", "module", "require", js)(box.exports, box, localRequire); - return box.exports; -} - -const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); -const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); -const deform = loadTs(B05("B05_Profile_UI_Corridor_Deform")); -const { deformTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Terrain")); -const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); -const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); - -const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 10000); -const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5) -const ROAD_Z = 100; - -/** 직선 노선(x 축) 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). */ -function station(chainage, mode, groundZ, ratio) { - const w = Math.abs(groundZ - ROAD_Z) * ratio; - const pts = [ - [-30, groundZ], - [-(HALF + w), groundZ], - [-HALF, ROAD_Z], - [-2.5, ROAD_Z], - [2.5, ROAD_Z], - [HALF, ROAD_Z], - [HALF + w, groundZ], - [30, groundZ], - ]; - const samples = []; - for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); - return { - station_id: `S${chainage}`, - chainage_m: chainage, - label: "", - kind: "regular", - center_x: chainage, - center_y: 0, - center_z: groundZ, - frame: { left_xy: [0, 1] }, - samples, - design: { - section_mode: mode, - road_edges: { - left: { offset_m: HALF, elevation_m: ROAD_Z }, - right: { offset_m: -HALF, elevation_m: ROAD_Z }, - }, - carriageway_edges: { - left: { offset_m: 2.5, elevation_m: ROAD_Z }, - right: { offset_m: -2.5, elevation_m: ROAD_Z }, - }, - design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), - ditch: { type: "none" }, - ditch_enabled: false, - ditch_side: "left", - }, - }; -} - -const ROUTE = [ - { x: 0, y: 0 }, - { x: 40, y: 0 }, -]; - -function fieldOf(sections, groundAt) { - const build = buildCorridor(sections, ROUTE); - const field = deform.buildDeformField(build, deform.stationProfileSource(sections), groundAt); - return { build, field }; -} - -const results = {}; - -// ① 성토 — 지반 96 · 1:1.5. 비탈 안 정점은 성토선으로 올라가고, 끝(6 m) 밖은 그대로. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); - const { build, field } = fieldOf(sections, () => 96); - results.fill = { - rows: field.rows.length, - anchorL: field.rows[10].left.map(r4), - left2: r4(field.deformZ(10, HALF + 2, 96)), - right2: r4(field.deformZ(10, -HALF - 2, 96)), - boundary: r4(field.deformZ(10, HALF, 96)), - beyond: r4(field.deformZ(10, HALF + 9, 96)), - // 지반이 성토선보다 높은 정점은 그대로(clamp 아래쪽만 올린다). - above: r4(field.deformZ(10, HALF + 2, 99.5)), - oldRibbons: build.ribbons.length, - }; -} - -// ② 첫 지반 교차점 밖은 원지반 — 비탈 끝(6 m) 너머 골짜기(80)는 연장선(92)으로 메우지 않는다. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); - const groundAt = (x, y) => (Math.abs(y) - HALF > 10 ? 80 : 96); - const { field } = fieldOf(sections, groundAt); - results.valley = { - inside: r4(field.deformZ(10, HALF + 2, 96)), - farValley: r4(field.deformZ(10, HALF + 15, 80)), - }; -} - -// ③ 절토 — 지반 104 · 1:1. 비탈 안 정점은 절토선으로 내려가고 끝(4 m) 밖은 그대로. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_cut", 104, 1.0)); - const { field } = fieldOf(sections, () => 104); - results.cut = { - left2: r4(field.deformZ(10, HALF + 2, 104)), - beyond: r4(field.deformZ(10, HALF + 6, 104)), - below: r4(field.deformZ(10, HALF + 2, 101)), - }; -} - -// ④ 측점 사이 보간 — 0 측점 1:1.5, 40 측점 1:2. 가운데(20)는 앵커 기준 상대 단면 보간. -{ - const sections = [station(0, "both_fill", 96, 1.5), station(40, "both_fill", 96, 2.0)]; - const { field } = fieldOf(sections, () => 96); - results.taper = { - at0: r4(field.deformZ(0.1, HALF + 2, 96)), - at20: r4(field.deformZ(20, HALF + 2, 96)), - at40: r4(field.deformZ(39.9, HALF + 2, 96)), - }; -} - -// ⑤ 단면 밖 마지막 구배 연장 · 반대 측 빌리기 — 단면 함수 단독. -{ - const profile = Float64Array.from([0, 0, 2, -1, 4, -2]); - results.profile = { - mid: r4(deform.profileZ(profile, 1)), - extend: r4(deform.profileZ(profile, 10)), - noExtend: deform.profileZ(profile, 10, true), - }; -} - -// ⑥ Three 지형 — 사본에만 · XY 고정 · 노면부 구멍 · 색 · 흑백 보존 표식. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); - const build = buildCorridor(sections, ROUTE); - build.sections = sections; - const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] }; - const cx = 20; - const cy = 0; - const cz = 100; - const step = 0.5; - const nx = Math.round(60 / step) + 1; - const ny = Math.round(40 / step) + 1; - const positions = new Float32Array(nx * ny * 3); - const colors = new Uint8Array(nx * ny * 4).fill(200); - for (let j = 0; j < ny; j += 1) { - for (let i = 0; i < nx; i += 1) { - const v = j * nx + i; - positions[v * 3] = -10 + i * step - cx; - positions[v * 3 + 1] = 96 - cz; - positions[v * 3 + 2] = -(-20 + j * step - cy); - } - } - const index = []; - for (let j = 0; j < ny - 1; j += 1) { - for (let i = 0; i < nx - 1; i += 1) { - const a = j * nx + i; - index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); - } - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true)); - geometry.setIndex(index); - const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })); - const before = positions.slice(); - const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 }); - const heightIndex = new TerrainHeightIndex(terrain); - const out = deformTerrain(split, build, bounds, heightIndex); - let xyMoved = 0; - let inRoad = 0; - let probe = null; - let keepCount = 0; - let tinted = 0; - out.root.traverse((child) => { - if (!(child instanceof THREE.Mesh) || !child.userData.owned) return; - const p = child.geometry.getAttribute("position"); - const c = child.geometry.getAttribute("color"); - for (let v = 0; v < nx * ny; v += 1) { - if (p.getX(v) !== before[v * 3] || p.getZ(v) !== before[v * 3 + 2]) xyMoved += 1; - const mx = p.getX(v) + cx; - const my = -p.getZ(v) + cy; - if (Math.abs(mx - 10) < 1e-6 && Math.abs(my - (HALF + 2)) < 1e-6) probe = p.getY(v) + cz; - if (child.userData.keepColor[v]) { - keepCount += 1; - if (c.getX(v) !== 200 / 255) tinted += 1; - } - } - const idx = child.geometry.getIndex(); - for (let t = 0; t < idx.count; t += 3) { - let sx = 0; - let sy = 0; - for (let k = 0; k < 3; k += 1) { - sx += p.getX(idx.getX(t + k)) + cx; - sy += -p.getZ(idx.getX(t + k)) + cy; - } - sx /= 3; - sy /= 3; - if (sx > 0.5 && sx < 39.5 && Math.abs(sy) < HALF - 0.05) inRoad += 1; - } - }); - results.three = { - originalUntouched: before.every((value, i) => value === positions[i]), - xyMoved, - inRoad, - probe: r4(probe), - moved: out.stats.moved, - raised: out.stats.raised, - keepCount, - tinted, - }; -} - -// ⑦ 115-2 상부 포락선 — 벽 앞면(수직)에서 안쪽으로 되돌아오는 계단은 위쪽만 남는다. -{ - const { upperEnvelope } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure")); - const env = upperEnvelope( - [ - [0, 0], - [2, 0], - [2.1, -3], - [1.8, -3.2], - [5, -4], - ].map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), - ); - const at = (x) => env.find((p) => Math.abs(p.offset_m - x) < 1e-9)?.elevation_m; - results.envelope = { - monotone: env.every((p, i) => i === 0 || p.offset_m > env[i - 1].offset_m), - at18: r4(at(1.8)), - at21: r4(at(2.1)), - at5: r4(at(5)), - }; -} - -/** 구조물 구간 단면(앵커 기준 상대 · 좌측) — 48 열로 다시 뽑는다. */ -function slopeOf(points, opts = {}) { - const { resample } = loadTs(B05("B05_Profile_UI_Corridor_Station")); - return { - chainage_m: 20, - side: "left", - kind: "fill", - span: { beforeM: 2, afterM: 2 }, - points: resample( - points.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), - 48, - ), - endsAtStructure: false, - ...opts, - }; -} - -// ⑧ 115-2 구조물 구간 고정 + 이웃 측점 테이퍼 — 20 측점 좌측에 선반 단면(4 m 수평 뒤 1:0.5). -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); - const build = buildCorridor(sections, ROUTE); - const shelf = slopeOf([ - [0, 0], - [4, 0], - [6, -4], - ]); - const source = deform.stationProfileSource(sections, [shelf]); - const field = deform.buildDeformField(build, source, () => 96); - results.span = { - in20: r4(field.deformZ(20, HALF + 2, 96)), - in19: r4(field.deformZ(19, HALF + 2, 96)), - taper10: r4(field.deformZ(10, HALF + 2, 96)), - taper30: r4(field.deformZ(30, HALF + 2, 96)), - right20: r4(field.deformZ(20, -HALF - 2, 96)), - }; -} - -// ⑨ 115-2 BOX 「본체 밖 원지반」 — 구조물에서 끝나는 단면은 구간 안에서 끝 너머를 늘리지 않는다. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); - const build = buildCorridor(sections, ROUTE); - const box = slopeOf( - [ - [0, 0], - [3, -1], - ], - { endsAtStructure: true }, - ); - const field = deform.buildDeformField( - build, - deform.stationProfileSource(sections, [box]), - () => 96, - ); - results.box = { - inside: r4(field.deformZ(20, HALF + 2, 96)), - outside: r4(field.deformZ(20, HALF + 5, 96)), - normal: r4(field.deformZ(5, HALF + 5, 96)), - }; -} - -// ⑩ 115-2 날개 안쪽 성토 금지 — 고리 안은 원지반(천장 있으면 천장까지)만. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); - const build = buildCorridor(sections, ROUTE); - const source = deform.stationProfileSource(sections); - const loop = [ - [8, 4], - [12, 4], - [12, 8], - [8, 8], - ]; - const none = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: null }]); - const ceiling = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: 97 }]); - const { noFillLimits } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure")); - const ring = (cx, cy) => ({ cx, cy, leftX: 0, leftY: 1, dz: 0 }); - const wings = [ - { chainage_m: 20, kind: "revet", wing: "route", rings: [ring(18, 4), ring(16, 7)] }, - { chainage_m: 20, kind: "revet", wing: "route", rings: [ring(22, 4), ring(24, 7)] }, - ]; - const limits = noFillLimits(sections, wings); - results.noFill = { - blocked: r4(none.deformZ(10, HALF + 2, 96)), - ceiling: r4(ceiling.deformZ(10, HALF + 2, 96)), - outsideLoop: r4(none.deformZ(20, HALF + 2, 96)), - limits: limits.length, - hullArea: limits.length ? limits[0].loop.length : 0, - }; -} - -// ⑪ 115-2 구조물 풋프린트 구멍 — 20 측점 좌측 벽(편거리 5~6 · 19~21 m) 자리 삼각형 없음. -{ - const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); - const build = buildCorridor(sections, ROUTE); - build.sections = sections; - const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 }); - build.structures = [ - { - chainage_m: 20, - kind: "revet", - polygon: [ - [5, 95], - [6, 95], - [6, 100], - [5, 100], - ], - rings: [ring(19), ring(20), ring(21)], - }, - ]; - const step = 0.5; - const cx = 20; - const cz = 100; - const positions = []; - const nx = 121; - const ny = 81; - for (let j = 0; j < ny; j += 1) { - for (let i = 0; i < nx; i += 1) positions.push(-10 + i * step - cx, 96 - cz, -(-20 + j * step)); - } - const index = []; - for (let j = 0; j < ny - 1; j += 1) { - for (let i = 0; i < nx - 1; i += 1) { - const a = j * nx + i; - index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); - } - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); - geometry.setIndex(index); - const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial()); - const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 }); - const out = deformTerrain( - split, - build, - { x: [-10, 50], y: [-20, 20], z: [90, 110] }, - new TerrainHeightIndex(terrain), - ); - let inHole = 0; - let nearHole = 0; - out.root.traverse((child) => { - if (!(child instanceof THREE.Mesh) || !child.userData.owned) return; - const p = child.geometry.getAttribute("position"); - const idx = child.geometry.getIndex(); - for (let t = 0; t < idx.count; t += 3) { - let sx = 0; - let sy = 0; - for (let k = 0; k < 3; k += 1) { - sx += p.getX(idx.getX(t + k)) + cx; - sy += -p.getZ(idx.getX(t + k)); - } - sx /= 3; - sy /= 3; - if (sx > 19.05 && sx < 20.95 && sy > 5.05 && sy < 5.95) inHole += 1; - if (sx > 19.05 && sx < 20.95 && sy > 6.05 && sy < 6.6) nearHole += 1; - } - }); - results.hole = { holes: out.stats.holes, inHole, nearHole }; -} - -process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/old/helper_b05_cut_merged_loop.cjs b/resources/tester/old/helper_b05_cut_merged_loop.cjs deleted file mode 100644 index 0411ba15e..000000000 --- a/resources/tester/old/helper_b05_cut_merged_loop.cjs +++ /dev/null @@ -1,111 +0,0 @@ -/* B05 잘린 자리 윤곽(`cut-merged`) 단위검증 헬퍼 — `_Corridor_Cut.ts`를 그 자리에서 - * 트랜스파일해 Node로 돌린다(타입 import뿐이라 의존 없음). 2026-09-02 */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); - -const source = fs.readFileSync( - path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Corridor_Cut.ts"), - "utf8", -); -const js = ts.transpileModule(source, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const moduleBox = { exports: {} }; -new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require); -const { maskFillByPlanCurves } = moduleBox.exports; - -/** 직선 노선 성토 리본 — x = 종방향(1m/행), y = 횡방향(1m/열), z = 0. */ -function ribbonOf(rows, cols) { - const positions = new Float64Array(rows * cols * 3); - for (let row = 0; row < rows; row += 1) { - for (let col = 0; col < cols; col += 1) { - const i = (row * cols + col) * 3; - positions[i] = row; - positions[i + 1] = col; - positions[i + 2] = 0; - } - } - return { - kind: "fill", - side: "left", - colCount: cols, - chainages: Array.from({ length: rows }, (_v, row) => row), - positions, - groundZ: new Float64Array(rows * cols), - }; -} - -/** 절취 영역 — 직사각형 x∈[x0,x1], y∈[y0,y1] (닫힘점 포함). */ -function rectCurve(x0, x1, y0, y1) { - return { - setChainageM: (x0 + x1) / 2, - source: "slope-projected", - role: "inlet", - side: "left", - planZ: 10, - loops: [ - [ - [x0, y0, 10], - [x1, y0, 10], - [x1, y1, 10], - [x0, y1, 10], - [x0, y0, 10], - ], - ], - }; -} - -function insideLoop(loop, x, y) { - let hit = false; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - const [xi, yi] = loop[i]; - const [xj, yj] = loop[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -function run(rows, cols, curve) { - const ribbon = ribbonOf(rows, cols); - const { ribbons, curves } = maskFillByPlanCurves([ribbon], [curve]); - const cut = ribbons[0]; - const merged = curves.filter((c) => c.source === "cut-merged"); - const loops = merged.flatMap((c) => c.loops); - let masked = 0; - let covered = 0; - const uncovered = []; - if (cut.cellMask) { - for (let row = 0; row < rows - 1; row += 1) { - for (let col = 0; col < cols - 1; col += 1) { - if (!cut.cellMask[row * (cols - 1) + col]) continue; - masked += 1; - const cx = row + 0.5; - const cy = col + 0.5; - if (loops.some((loop) => insideLoop(loop, cx, cy))) covered += 1; - else uncovered.push([row, col]); - } - } - } - return { - masked, - covered, - uncovered, - loops: loops.map((loop) => ({ - n: loop.length, - closed: - loop.length > 1 && - loop[0][0] === loop[loop.length - 1][0] && - loop[0][1] === loop[loop.length - 1][1], - points: loop.map(([x, y]) => [x, y]), - })), - }; -} - -const out = { - // 직사각형 — 시작 모서리(첫 행 첫 열)가 살아야 4모서리 + 닫힘점 = 5. - rect: run(12, 6, rectCurve(1.9, 7.1, -0.5, 3.4)), - // 한 셀짜리 — 가장 작은 고리도 4모서리를 다 지킨다. - single: run(6, 4, rectCurve(1.9, 3.1, 0.9, 2.1)), -}; -process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/old/helper_b05_patch_skirt.cjs b/resources/tester/old/helper_b05_patch_skirt.cjs deleted file mode 100644 index b21ef34bc..000000000 --- a/resources/tester/old/helper_b05_patch_skirt.cjs +++ /dev/null @@ -1,85 +0,0 @@ -/* B05 패치 스커트 단위검증 헬퍼 — TS를 그 자리에서 트랜스파일해 Node로 돌린다. - * (프론트에 JS 테스트 러너가 없어 pytest가 이 스크립트를 호출한다 — 2026-08-27) */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); - -const source = fs.readFileSync( - path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Corridor_Skirt.ts"), - "utf8", -); -const js = ts.transpileModule(source, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const moduleBox = { exports: {} }; -new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require); -const { buildPatchSkirts } = moduleBox.exports; - -/** 직선 노선 패치 리본 하나 — 바깥 열 z를 행마다 지정해 만든다. */ -function ribbonOf(outerZ, options = {}) { - const cols = 3; - const rows = outerZ.length; - const positions = new Float64Array(rows * cols * 3); - for (let row = 0; row < rows; row += 1) { - for (let col = 0; col < cols; col += 1) { - const i = (row * cols + col) * 3; - positions[i] = row; // x = 종방향 1m 간격 - positions[i + 1] = col; // y = 횡방향(마지막 열이 바깥) - positions[i + 2] = col === cols - 1 ? outerZ[row] : 100; - } - } - return { - kind: "fill", - side: "left", - colCount: cols, - chainages: outerZ.map((_z, row) => row), - positions, - patch: true, - ...options, - }; -} - -const results = {}; - -// ① 뜬 모서리 — 바깥 끝이 지반(90)보다 0.5m 위: 마디마다 판 2장씩. -{ - const ribbon = ribbonOf([90.5, 90.5, 90.5]); - const { walls, stats } = buildPatchSkirts([ribbon], () => 90); - results.floating = { - walls: walls.length, - triangles: stats.triangles, - hatched: walls.every((w) => w.uvs && w.uvs.length === (w.positions.length / 3) * 2), - marked: walls.every((w) => w.patchSkirt === true), - }; -} - -// ② 잠긴 모서리 — 바깥 끝이 지반 아래: 판 없음(지형이 덮는다). -{ - const ribbon = ribbonOf([89.5, 89.5, 89.5]); - const { walls, stats } = buildPatchSkirts([ribbon], () => 90); - results.submerged = { walls: walls.length, skipped: stats.skippedSubmerged }; -} - -// ③ 구조물에서 끝나는 줄 — 소유 측점(가운데 행) 낙차 2m > 허용 0.5m: 리본 전체 제외. -{ - const ribbon = ribbonOf([92, 92, 92]); - const { walls, stats } = buildPatchSkirts([ribbon], () => 90); - results.toeGate = { walls: walls.length, reaches: stats.detail[0].reachesGround }; -} - -// ④ 낙차 상한 — 게이트는 통과(가운데 0.2m)하되 한 마디가 3m 넘게 뜨면 그 마디만 건너뜀. -{ - const ribbon = ribbonOf([90.2, 94, 90.2, 90.2]); - const { walls, stats } = buildPatchSkirts([ribbon], () => 90); - results.tall = { walls: walls.length, skippedTall: stats.skippedTall, tris: stats.triangles }; -} - -// ⑤ 비패치·절토 리본은 무시. -{ - const plain = ribbonOf([90.5, 90.5], { patch: undefined }); - const cut = ribbonOf([90.5, 90.5], { kind: "cut" }); - const { walls } = buildPatchSkirts([plain, cut], () => 90); - results.ignored = { walls: walls.length }; -} - -process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/old/test_115_4_corridor_legacy.py b/resources/tester/old/test_115_4_corridor_legacy.py deleted file mode 100644 index 39246643c..000000000 --- a/resources/tester/old/test_115_4_corridor_legacy.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -"""PLAN 115-4 — B05 3D 옛 방식 A(08-25 `6e87dd6cd` 실루엣 갈아 끼우기) 별개 단추. - -Node 도우미(helper_115_4_corridor_legacy.cjs)가 TS 를 트랜스파일해 픽스처 측점으로 돌리고, -여기서는 결과(JSON)를 단언한다. 배수관 세트 측 성토 리본이 직선이 아니라 실루엣을 따름 · -스케치 · 자르기 · 패치 없음 · BOX · 세월교는 원본 비탈 · 기본 빌드 그대로 · 설정 키 등록. -""" - -import json -import os -import subprocess -from pathlib import Path - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) -ROOT = Path(HERE).parents[1] - - -@pytest.fixture(scope="module") -def results(): - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_115_4_corridor_legacy.cjs")], - capture_output=True, - text=True, - timeout=120, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_drainage_set_fill_follows_silhouette(results): - r = results["set"] - assert r["plainBend"] < 0.05 # 기본 = 직선 성토 비탈(1:1.2 꼴) - assert 1.1 < r["plainRatio"] < 1.4 - assert r["legacyBend"] > 0.5 # 옛 방식 A = 벽 상단 · 전면 · 성토부선 실루엣 - assert r["wallSpans"] == ["left", "right"] - assert r["plainWallSpans"] is None - assert r["indexArg"] is True # `.map(classifyStation)` 인덱스는 끔 - - -def test_legacy_build_has_no_sketch_cut_patch(results): - r = results["build"] - assert r["keys"] == ["caps", "outline", "ribbons", "structures"] # planCurves · cutWalls 없음 - assert r["patches"] == 0 - assert r["structures"] > 0 # 솔리드는 지금 것 그대로 얹음 - assert r["rowBend"] > 0.5 # 측점 행 성토 리본이 실루엣 따름 - assert r["baseRowBend"] < 0.05 - assert r["basePlanCurves"] is True and r["baseCutWalls"] is True # 기본 빌드 그대로 - - -def test_box_and_ford_keep_original_slopes(results): - kinds = {item["kind"]: item for item in results["others"]} - assert set(kinds) == {"box", "ford"} - for item in kinds.values(): - assert item["sameSlopes"] is True - assert item["wallSpans"] is None - assert item["shoulderSpans"] is None # 노견 확장 상시 적용(08-25 ①)도 그날 전 - - -def test_legacy_state_key_registered(): - registry = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") - assert '"corridor-legacy": { bucket: "pref", scope: "global" }' in registry - assert '"corridor-deform": { bucket: "pref", scope: "global" }' in registry - # 115-5 — 단추 둘은 고르기 한 줄 「3D 방식」로 옮겼다(옛 키 값은 읽어서 옮김). - modes = (ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Modes.ts").read_text(encoding="utf-8") - assert '["legacy-a", "옛 A"]' in modes - assert 'readStateRaw("corridor-legacy") === "1"' in modes diff --git a/resources/tester/old/test_115_5_corridor_improve.py b/resources/tester/old/test_115_5_corridor_improve.py deleted file mode 100644 index b719075ed..000000000 --- a/resources/tester/old/test_115_5_corridor_improve.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -"""PLAN 115-5 — B05 3D 제안 ① 새 방식 개선 합성 검사. - -Node 도우미(helper_115_5_corridor_improve.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 -결과(JSON)를 단언한다. 비탈 폭주 0 · 조각 규칙 · 행 자리 0 m · 브레이크라인 T자 균열 0 · -구조물 둘레(윗면 구멍 · 드레이프) · 3D 방식 등록부(고르기 한 줄). -""" - -import json -import os -import subprocess -from pathlib import Path - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) - - -@pytest.fixture(scope="module") -def results(): - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_115_5_corridor_improve.cjs")], - capture_output=True, - text=True, - timeout=120, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_runaway_slope_stops_at_section_end(results): - r = results["runaway"] - assert r["catch"] == 6 # 못 찾으면 단면 끝(6 m)까지만 - assert r["far"] == 0 # 단면 끝 너머 지형은 그대로 - assert r["maxDelta"] <= 4 + 1 # 단면 높이 + 1 m 상한 - - -def test_piece_only_where_neighbour_station_has_it(results): - r = results["pieceRule"] - assert r["between"] == 96 # 앞뒤 측점 둘 다 성토 없음 — 메우지 않음 - assert r["nearFill"] > 96 # 성토 측점 곁은 그대로 메움 - - -def test_row_place_follows_route_length(results): - r = results["rowPlace"] - assert r["alongMax"] < 1e-6 # 행 자리 = 누가거리(0 m) - assert r["lateralMax"] < 1e-6 # 노선 위 - assert r["folds"] == 0 - - -def test_breakline_no_t_cracks_and_face_colours(results): - r = results["breakline"] - assert r["split"] > 0 and r["fallback"] == 0 - assert r["checked"] > 1000 - assert r["worst"] < 1e-4 # 같은 자리 덮는 삼각형 높이 같음(T자 균열 0) - assert r["mixed"] == 0 # 색은 면 단위 — 한 삼각형에 칠 · 원지반 섞임 없음 - assert r["toeGap"] < 0.02 # 비탈 끝 선 위 = 원지반 - - -def test_structure_top_hole_and_drape(results): - r = results["drape"] - assert r["holes"] == 1 - assert r["stemTop"] is None # 벽체 윗면만 구멍 - assert r["footing"] == 99.5 # 기초 위는 묻고 벽 상단까지 내림 - assert r["band"] == 99.5 # 둘레 띠 안 - assert r["endBand"] == 99.5 # 끝 너머 띠 - assert r["outside"] == 100 # 띠 밖은 그대로 - - -def test_mode_registry_and_migration(results): - r = results["modes"] - assert r["slots"] == [ - "current:지금", - "legacy-a:옛 A", - "new1:새 ①", - "proposal2:제안 ②", - "proposal3:제안 ③", - ] - assert r["registered"] == [ - "current", - "legacy-a", - "new1", - "proposal2", - "proposal3", - ] # 제안 ② · ③ 등록(115-6 · 115-8) - assert r["defaultMode"] == "new1" and r["fresh"] == "new1" - assert r["newHasTerrain"] is True - assert r["oldDeformOff"] == "current" # 옛 「새 방식」 끔 → 지금 - assert r["oldLegacyOn"] == "legacy-a" # 옛 「옛 방식 A」 켬 → 옛 A - assert ( - r["proposal"] == "proposal2" and r["proposalDraws"] == "proposal2" - ) # 등록됨 → 제안 ② 로 그림 - assert r["set"] == "new1" - - -def test_mode_row_replaces_old_toggles(): - root = Path(HERE).parents[1] - registry = (root / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") - assert '"corridor-mode": { bucket: "pref", scope: "global" }' in registry - panel = (root / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8") - assert "MODE_SLOTS.map" in panel and '"3D 방식"' in panel - assert 'toggleButton("새 방식"' not in panel and "옛 방식 A(실루엣)" not in panel diff --git a/resources/tester/old/test_115_6_corridor_tin.py b/resources/tester/old/test_115_6_corridor_tin.py deleted file mode 100644 index e5d3479a8..000000000 --- a/resources/tester/old/test_115_6_corridor_tin.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- coding: utf-8 -*- -"""PLAN 115-6 — B05 3D 제안 ② 단일 삼각망(TIN) 합성 지형 검사. - -Node 도우미(helper_115_6_corridor_tin.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 -결과(JSON)를 단언한다. 틈 0 · 브레이크라인 = 삼각형 변 · 측점 단면 = B06 · 공사 밖 정점 그대로 · -구조물 구멍 안 삼각형 0 · 곡선 안쪽 겹침 청소 · 등록부. -""" - -import json -import os -import subprocess - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) -ROOT = os.path.dirname(os.path.dirname(HERE)) - - -@pytest.fixture(scope="module") -def results(): - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_115_6_corridor_tin.cjs")], - capture_output=True, - text=True, - timeout=180, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_straight_no_gap_and_constraints_are_edges(results): - r = results["straight"] - assert r["stats"]["failed"] == 0 # 제약 못 넣은 것 없음 - assert r["gaps"] == 0 # 리본 이음 · 지형 테두리 말고 한 번만 쓰인 변 없음 - assert r["designEdges"] > 0 and r["missingEdges"] == 0 # 브레이크라인 = 삼각형 변 - - -def test_straight_station_section_matches_b06(results): - r = results["straight"] - assert r["sectionErr"] < 1e-4 # 측점 20 단면 = 성토 1:1.5 → 지반 - assert r["toeErr"] < 0.06 # 비탈 끝 자리(시험 걸음 0.05 m 안) - - -def test_straight_daylight_draped(results): - r = results["straight"] - assert r["daylightRows"] > 300 - assert r["offGround"] == 0 # 측점 아닌 행 비탈 끝 = 망 지형 높이(드레이프 · 115-8) - - -def test_straight_terrain_outside_untouched(results): - r = results["straight"] - assert r["originalUntouched"] is True # 원본 정점 버퍼 그대로 - assert r["strayVerts"] == 0 # 망의 지형 정점은 모두 원래 정점 - assert r["lostVerts"] == 0 # 공사 밖 원래 정점 하나도 안 빠짐 - assert r["notOriginal"] <= 2 # 지형만으로 된 새 삼각형 = 둘레 위 정점을 뺀 자리뿐 - - -def test_structure_hole_empty_and_draped(results): - r = results["hole"] - assert r["holes"] == 1 and r["failed"] == 0 - assert r["inHole"] == 0 # 윗면 외곽 안 삼각형 없음 - assert r["perimeter"] >= 4 and r["aboveTop"] == 0 # 둘레 높이 ≤ 윗면(드레이프) - assert r["gaps"] == 0 - - -def test_tight_curve_bowtie_cleared(results): - r = results["curve"] - assert r["stats"]["bowtie"] > 0 # 안쪽 단면이 앞 단면에 닿아 잘림 - assert r["selfCross"] == 0 # 설계 구역 둘레 꼬임 없음 - assert r["stats"]["failed"] == 0 - assert r["gaps"] == 0 - - -def test_berm_bend_lines(results): - r = results["berm"] - assert r["stats"]["bendLines"] > 0 # 소단 꺾임 종방향 선 - assert r["missing"] == 0 - assert r["sectionErr"] < 0.1 # 측점 단면 ±0.1 m(조각 48열 재표본 오차 포함) - assert r["gaps"] == 0 - - -def test_registered_as_proposal2(results): - r = results["registry"] - assert r["slot"] is True - assert r["registered"] is True and r["hasTerrain"] is True - assert r["noIndex"] is None # 높이 색인 없으면 다른 방식(띠 자르기)으로 - - -def test_dependencies_two_only(): - with open(os.path.join(ROOT, "config", "package.json"), encoding="utf-8") as f: - deps = json.load(f)["dependencies"] - assert deps.get("delaunator", "").startswith("^5") - assert deps.get("@kninnug/constrainautor", "").startswith("^4") diff --git a/resources/tester/old/test_115_7_fill_continuity.py b/resources/tester/old/test_115_7_fill_continuity.py deleted file mode 100644 index e1c4a19bd..000000000 --- a/resources/tester/old/test_115_7_fill_continuity.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -"""PLAN 115-7 — B05 3D 성토 비탈 끝 종단 이음(공통 변형 장) 합성 검사. - -Node 도우미(helper_115_7_fill_continuity.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 -결과(JSON)를 단언한다. 직각 골짜기 지느러미 0 · 덧댐 · 계단 지형 출렁임 · 못 닫힘 보간 · -측점 행 날것 · 0 행 끊음 · 절토 그대로. -""" - -import json -import os -import subprocess - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) - - -@pytest.fixture(scope="module") -def results(): - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_115_7_fill_continuity.cjs")], - capture_output=True, - text=True, - timeout=120, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_valley_has_no_fins(results): - r = results["valley"] - assert r["fins"] == 0 - assert ( - r["gullyMax"] == 6 and r["gullyMin"] == 6 - ) # 골 행도 이웃 비탈 끝(6 m) — 10.5 m 로 안 뻗음 - - -def test_valley_skirt_replaces_wall(results): - r = results["valley"] - assert r["skirtMid"] == 94.5 # 비탈 끝 밖 1 m — 96 → 93 반쯤 - assert r["skirtOut"] == 93 # 덧댐 밖 — 원지반 - assert r["outside"] == 96 # 골 밖 — 원지반 그대로 - - -def test_cut_unchanged(results): - r = results["valley"] - assert r["cutFlat"] == 4 - assert 2 < r["cutGully"] < 2.3 # 절토는 골을 날것 그대로 따른다 - - -def test_step_terrain_wobble_small(results): - r = results["steps"] - assert r["fins"] == 0 - assert r["wobble"] < 0.7 * r["rawWobble"] - - -def test_open_rows_interpolated(results): - r = results["column"] - assert r["openMax"] == 6 # 못 닫힘(15) → 이웃 6 - assert r["stationOpen"] < 9 # 못 닫힘 측점도 보간(날것 20 아님) - - -def test_station_row_kept_and_zero_breaks(results): - r = results["column"] - assert r["stationKept"] == 9 # 측점 행 날것 - assert r["bridged"] == 6 # 단면 있는 짧은 틈 — 잇는다 - assert r["cutGap"] == 0 # 단면 없는 행 — 0 그대로(구간 끊음) - assert r["neighbourStep"] <= 1 + 1e-6 # 이웃 행 차 1 m/m 안 diff --git a/resources/tester/old/test_115_corridor_deform.py b/resources/tester/old/test_115_corridor_deform.py deleted file mode 100644 index f163d9286..000000000 --- a/resources/tester/old/test_115_corridor_deform.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- coding: utf-8 -*- -"""PLAN 115 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사. - -Node 도우미(helper_115_corridor_deform.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 -결과(JSON)를 단언한다. 정점 규칙 `z' = clamp(지형, 성토선, 절토선)` · 비탈 끝 밖 원지반 · -측점 사이 보간 · 단면 밖 마지막 구배 연장 · 사본에만 · XY 고정 · 노면부 구멍 · 색. -""" - -import json -import os -import subprocess - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) -FILL_2M = round(100 - 2 / 1.5, 4) - - -@pytest.fixture(scope="module") -def results(): - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_115_corridor_deform.cjs")], - capture_output=True, - text=True, - timeout=120, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_fill_clamp_raises_to_fill_line(results): - r = results["fill"] - assert r["rows"] >= 2 - assert r["anchorL"][1:] == [3, 100] - assert r["left2"] == FILL_2M - assert r["right2"] == FILL_2M - assert r["boundary"] == 100 # 노면부 끝 경계 정점 = 앵커 z - assert r["beyond"] == 96 # 비탈 끝(6 m) 밖은 원지반 - assert r["above"] == 99.5 # 지반이 성토선 위면 그대로 - - -def test_fill_stops_at_first_catch(results): - r = results["valley"] - assert r["inside"] == FILL_2M - assert r["farValley"] == 80 # 연장선(92)으로 골짜기를 메우지 않는다 - - -def test_cut_clamp_lowers_to_cut_line(results): - r = results["cut"] - assert r["left2"] == 102 - assert r["beyond"] == 104 - assert r["below"] == 101 # 지반이 절토선 아래면 그대로 - - -def test_station_interpolation(results): - r = results["taper"] - assert abs(r["at0"] - FILL_2M) < 0.02 - assert abs(r["at40"] - 99.0) < 0.02 - assert abs(r["at20"] - round(100 - 2 * 4 / 7, 4)) < 1e-3 # 상대 단면 보간 - - -def test_profile_extends_last_grade(results): - r = results["profile"] - assert r["mid"] == -0.5 - assert r["extend"] == -5 - assert r["noExtend"] is None - - -def test_three_copy_only_xy_fixed_hole_and_tint(results): - r = results["three"] - assert r["originalUntouched"] is True # 원본 정점 버퍼 그대로([예상형상] OFF = 원본) - assert r["xyMoved"] == 0 - assert r["inRoad"] == 0 # 노면부 안 삼각형 없음 - assert r["probe"] == FILL_2M - assert r["moved"] > 0 and r["raised"] == r["moved"] - assert r["keepCount"] > 0 and r["tinted"] == r["keepCount"] - - -def test_upper_envelope_keeps_top_of_wall_steps(results): - r = results["envelope"] - assert r["monotone"] is True - assert r["at18"] == 0 # 안쪽으로 되돌아온 계단(-3.2)보다 위쪽 마디(0) - assert r["at21"] == -3 - assert r["at5"] == -4 - - -def test_structure_span_fixed_and_tapers_to_neighbours(results): - r = results["span"] - assert r["in20"] == 100 and r["in19"] == 100 # 구간(18~22) 안 = 선반 단면 고정 - assert FILL_2M < r["taper10"] < 100 # 구간 끝 → 이웃 측점 테이퍼 - assert r["taper30"] == r["taper10"] - assert r["right20"] == FILL_2M # 반대 측은 측점 단면 그대로 - - -def test_box_outside_body_stays_original_ground(results): - r = results["box"] - assert r["inside"] == round(100 - 2 / 3, 4) - assert r["outside"] == 96 # 단면 끝 너머 = 원지반(연장 안 함) - assert 96 < r["normal"] < 100 # 구간 밖 테이퍼 행은 그대로 변형 - - -def test_no_fill_ring_inside_wings(results): - r = results["noFill"] - assert r["blocked"] == 96 - assert r["ceiling"] == 97 - assert r["outsideLoop"] == FILL_2M - assert r["limits"] == 1 and r["hullArea"] == 4 - - -def test_structure_footprint_hole(results): - r = results["hole"] - assert r["holes"] == 1 - assert r["inHole"] == 0 # 풋프린트 안 지형 삼각형 없음 - assert r["nearHole"] > 0 # 바로 바깥은 남는다 diff --git a/resources/tester/old/test_b05_corridor_envelope_flags.py b/resources/tester/old/test_b05_corridor_envelope_flags.py deleted file mode 100644 index d61e02b00..000000000 --- a/resources/tester/old/test_b05_corridor_envelope_flags.py +++ /dev/null @@ -1,115 +0,0 @@ -"""코리도 저장본이 리본 표식을 잃지 않는지 (2026-09-02). - -`patch`·`patchClip` 은 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도** 본다 -(`B05_Profile_UI_Viewer.ts` 의 지형 스냅 제외 가드). 그런데 직렬화 대상에서 빠져 있어 -저장본으로 다시 연 코리도는 패치인 줄 몰랐고, 바깥 끝이 원지반까지 끌려 내려갔다. - -같은 종류의 실수를 다시 잡으려고 **대칭**을 잠근다 — `serialize()` 가 리본에 쓰는 -선택 필드는 전부 `deserialize()` 가 되읽어야 한다. 필드를 새로 담으면서 되읽기를 -빠뜨리면 여기서 걸린다. -""" - -import re -import sys -from pathlib import Path - -import pytest - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -# 저장 형식(serialize/deserialize)은 브라우저·서버 공용 모듈로 옮겼다(2026-09-04 분리). -CORRIDOR = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Envelope.ts" -# 지형 스냅 가드는 뷰어에서 코리도 조각으로 옮겼다(계획서 24-3a · 700줄 나눔). -# 115-5 — 심 보정(snapCorridorEdges)은 방식 등록부로 옮겼다. -VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Modes.ts" -CARVE_WING = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve_Wing.ts" -CARVE = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve.ts" - -# 700줄 제한으로 함수가 다른 파일로 갈릴 수 있다 — 후보를 모두 이어 붙여 훑는다. -# ⚠ 못 찾을 때 최상위에서 터지면 **이 파일 하나 때문에 회귀 전체가 멈춘다**(2026-09-08). -_CANDIDATES = (CORRIDOR, PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor.ts") -SOURCE = "\n".join(path.read_text(encoding="utf-8") for path in _CANDIDATES if path.is_file()) - - -def _block(name: str) -> str: - """`function (` 부터 다음 최상위 `function ` 앞까지.""" - start = SOURCE.find(f"function {name}(") - if start < 0: - pytest.skip( - f"`function {name}(` 를 못 찾음 — 다른 파일로 갈라져 나갔는지 확인할 것", - allow_module_level=True, - ) - rest = SOURCE[start + 1 :] - # 공용 모듈에서는 최상위 함수가 `export function` 으로도 나온다(2026-09-04 분리). - ends = [pos for pos in (rest.find("\nfunction "), rest.find("\nexport function ")) if pos >= 0] - return rest if not ends else rest[: min(ends)] - - -SERIALIZE = _block("serialize") -DESERIALIZE = _block("deserialize") - - -# ── 표식이 실제로 오간다 ───────────────────────────────────────────────────── - - -def test_serialize_carries_patch_flags(): - """저장 쪽이 두 표식을 담는다.""" - assert "patch: true" in SERIALIZE - assert "patchClip: true" in SERIALIZE - - -def test_deserialize_restores_patch_flags(): - """복원 쪽이 두 표식을 되살린다 — 이게 빠져서 결함이 났다.""" - assert "patch: true" in DESERIALIZE - assert "patchClip: true" in DESERIALIZE - - -def test_envelope_version_bumped(): - """담는 필드가 바뀌었으므로 형식 버전이 올라가야 옛 저장본이 만료된다.""" - version = int(re.search(r"const ENVELOPE_VERSION = (\d+);", SOURCE).group(1)) - assert version >= 6 - - -def test_viewer_still_guards_on_patch(): - """뷰어가 표식을 보는 것이 이 수정의 이유다 — 가드가 사라지면 전제가 무너진다.""" - assert "if (ribbon.patch) return;" in VIEWER.read_text(encoding="utf-8") - - -# ── 대칭 — 쓴 것은 되읽는다 ────────────────────────────────────────────────── - - -def _optional_ribbon_fields(block: str) -> set[str]: - """`...(조건 ? { 키: … } : {})` 로 붙는 선택 필드 이름.""" - return set(re.findall(r"\?\s*\{\s*(\w+):", block)) | set( - re.findall(r"\?\s*\{\s*(\w+)\s*:", block) - ) - - -def test_every_saved_optional_field_is_restored(): - """저장이 담는 선택 필드는 전부 복원이 되읽어야 한다.""" - saved = _optional_ribbon_fields(SERIALIZE) - restored = _optional_ribbon_fields(DESERIALIZE) - # 이름이 다른 짝(직렬화 base64 ↔ 복원 원본형)은 접미사를 떼고 맞춘다. - normalize = lambda names: {n.removesuffix("Base64") for n in names} # noqa: E731 - missing = normalize(saved) - normalize(restored) - assert not missing, f"복원이 되읽지 않는 필드: {sorted(missing)}" - - -# ── 죽은 코드 제거 ─────────────────────────────────────────────────────────── - - -def test_reverted_wing_trim_tools_are_gone(): - """버전 76~78 트림 도구는 원복 뒤 참조가 0이었다 — 되살아나면 여기서 걸린다.""" - source = CARVE_WING.read_text(encoding="utf-8") - for symbol in ("wingOuterLines", "wingTrimAt", "clipRunToRange", "WingTrimLine"): - assert f"export function {symbol}" not in source - assert f"export interface {symbol}" not in source - - -def test_carve_still_imports_live_wing_tools(): - """살아 있는 도구까지 지우지 않았는지 — 호출부 import 가 그대로여야 한다.""" - source = CARVE.read_text(encoding="utf-8") - for symbol in ("wingSpanOf", "wingSpanOnRoute", "wingRangeAt", "clipRunBelow"): - assert symbol in source diff --git a/resources/tester/old/test_b05_cut_merged_loop.py b/resources/tester/old/test_b05_cut_merged_loop.py deleted file mode 100644 index 79d626678..000000000 --- a/resources/tester/old/test_b05_cut_merged_loop.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -"""B05 잘린 자리 윤곽(`cut-merged`) 회귀검증 — 2026-09-02. - -`dropCollinear`가 추적 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 시작 모서리를 -지우던 결함(실측 63셀 구멍)의 재발 방지. Node 헬퍼(helper_b05_cut_merged_loop.cjs)가 -⚠ 도우미 확장자가 **`.cjs`** 인 이유(2026-09-07) — 루트 `package.json` 에 -`"type": "module"` 이 있어 `.js` 는 Node 판·환경에 따라 ESM 으로 읽힌다. 그러면 도우미의 -`require` 가 「require is not defined in ES module scope」로 죽는다(보조 창 폴더에서 실제로 -9건이 그렇게 실패했고, 같은 파일이 이 폴더에서는 통과해 **환경 차이**임이 드러났다). -`.cjs` 는 `type` 과 무관하게 언제나 CommonJS 라 어느 폴더·어느 Node 에서도 같게 돈다. - -`_Corridor_Cut.ts`를 트랜스파일해 합성 리본에 돌리고, pytest가 결과를 판정한다. -""" - -import json -import os -import subprocess - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) - - -@pytest.fixture(scope="module") -def results(): - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_b05_cut_merged_loop.cjs")], - capture_output=True, - text=True, - timeout=60, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_rect_every_masked_cell_inside_loop(results): - """도려낸 셀은 전부 병합 고리 안 — 시작 모서리 쐐기가 빠지면 여기서 걸린다.""" - r = results["rect"] - assert r["masked"] > 0 - assert r["uncovered"] == [], r["uncovered"] - assert r["covered"] == r["masked"] - - -def test_rect_loop_keeps_four_corners_and_closure(results): - """직사각형 마스크 → 모서리 4 + 닫힘점 1. 종전에는 머리 모서리가 빠져 3+1이었다.""" - loops = results["rect"]["loops"] - assert len(loops) == 1 - assert loops[0]["closed"] is True - assert loops[0]["n"] == 5, loops[0]["points"] - - -def test_single_cell_loop_intact(results): - """한 셀짜리 고리도 4모서리 유지.""" - r = results["single"] - assert r["masked"] >= 1 - assert r["uncovered"] == [] - assert all(loop["n"] == 5 and loop["closed"] for loop in r["loops"]), r["loops"] diff --git a/resources/tester/old/test_b05_patch_skirt.py b/resources/tester/old/test_b05_patch_skirt.py deleted file mode 100644 index 5e4dd9fc6..000000000 --- a/resources/tester/old/test_b05_patch_skirt.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -"""B05 패치 스커트(`B05_Profile_UI_Corridor_Skirt.ts`) 단위검증 — 2026-08-27. - -프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_b05_patch_skirt.cjs)가 TS를 -⚠ 도우미 확장자가 **`.cjs`** 인 이유(2026-09-07) — 루트 `package.json` 에 -`"type": "module"` 이 있어 `.js` 는 Node 판·환경에 따라 ESM 으로 읽힌다. 그러면 도우미의 -`require` 가 「require is not defined in ES module scope」로 죽는다(보조 창 폴더에서 실제로 -9건이 그렇게 실패했고, 같은 파일이 이 폴더에서는 통과해 **환경 차이**임이 드러났다). -`.cjs` 는 `type` 과 무관하게 언제나 CommonJS 라 어느 폴더·어느 Node 에서도 같게 돈다. - -트랜스파일해 실행하고, pytest는 그 결과(JSON)를 검증한다. -""" - -import json -import os -import subprocess - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) - - -@pytest.fixture(scope="module") -def skirt_results(): - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_b05_patch_skirt.cjs")], - capture_output=True, - text=True, - timeout=60, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_floating_edge_builds_hatched_skirt(skirt_results): - """뜬 모서리(지반 위 0.5m)는 마디마다 판 2장 + 빗금 UV + patchSkirt 표식.""" - r = skirt_results["floating"] - assert r["walls"] == 1 - assert r["triangles"] == 4 # 마디 2개 × 2장 - assert r["hatched"] is True - assert r["marked"] is True - - -def test_submerged_edge_builds_nothing(skirt_results): - """잠긴 모서리(지반 아래)는 판 없음 — 지형이 덮는다.""" - r = skirt_results["submerged"] - assert r["walls"] == 0 - assert r["skipped"] == 2 - - -def test_structure_ending_run_excluded(skirt_results): - """소유 측점 행 낙차 2m > 허용 0.5m — 벽 뒷면·구체 상단에서 끝나는 줄은 리본째 제외.""" - r = skirt_results["toeGate"] - assert r["walls"] == 0 - assert r["reaches"] is False - - -def test_tall_segment_skipped(skirt_results): - """게이트 통과 후에도 낙차 3m 초과 마디는 건너뛴다(지느러미 방지).""" - r = skirt_results["tall"] - assert r["skippedTall"] == 2 # 94를 낀 두 마디 - assert r["tris"] == 2 # 남은 정상 마디 1개 × 2장 - assert r["walls"] == 1 - - -def test_non_patch_and_cut_ignored(skirt_results): - """patch 표식 없는 리본·절토 리본은 스커트 대상이 아니다.""" - assert skirt_results["ignored"]["walls"] == 0 diff --git a/resources/tester/probe_berm_infinite.py b/resources/tester/probe_berm_infinite.py deleted file mode 100644 index 393e898b5..000000000 --- a/resources/tester/probe_berm_infinite.py +++ /dev/null @@ -1,52 +0,0 @@ -"""소단 + 다중 무릎에서 **제자리 무릎이 되풀이되어 안 끝나는지** 재현한다. - -의심 — 암 경계선의 기울기가 **암 경사와 토사 경사 사이**에 있으면, 경계에 정확히 닿은 자리에서 -① 토사로 바꾸면 다음 걸음이 경계 아래로 내려가 「다시 암」 ② 암으로 바꾸면 다음 걸음이 경계 -위로 올라가 「다시 토사」 가 되어 **같은 자리에서 앞뒤로 뒤집힌다**. `share`(보간 비율)가 0 이라 -거리가 한 걸음도 안 나가므로 `while dist < limit` 이 끝나지 않는다. -""" - -import signal -import sys -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from common_util.common_util_cross_berm import BermSpec, cut_profile_points # noqa: E402 - -# 경계선 기울기를 **두 설계 경사 사이**에 둔다 — 암 1:0.4 는 수평 1m 에 2.5m 오르고, -# 토사 1:1.0 은 1.0m 오른다. 그 사이(1.5m/1m)면 「토사로 바꾸면 경계 아래, 암으로 바꾸면 -# 경계 위」가 되어 같은 자리에서 뒤집힌다. -BOUNDARY_RISE = 1.5 - - -def boundary(dist: float) -> float: - """시작점에서 정확히 만나고, 그 뒤로 두 경사 사이 기울기로 오르는 경계선.""" - return 100.0 + dist * BOUNDARY_RISE - - -def run(multi_knee: bool, seconds: int = 8): - def on_alarm(signum, frame): # noqa: ARG001 - raise TimeoutError("끝나지 않음") - - try: - points = cut_profile_points( - 0.0, 100.0, 0.4, 1.0, boundary, BermSpec(0.5, 3.0, 0.0), 50.0, multi_knee - ) - return f"끝남 · 꼭짓점 {len(points)}개" - except RecursionError as exc: - return f"RecursionError {exc}" - - -if __name__ == "__main__": - import threading - - for multi in (False, True): - result = {"v": None} - thread = threading.Thread(target=lambda: result.__setitem__("v", run(multi)), daemon=True) - thread.start() - thread.join(8) - state = result["v"] if not thread.is_alive() else "⚠ 8초 안에 안 끝남 (무한 반복)" - print(f"multi_knee={multi}: {state}") diff --git a/resources/tester/probe_pipe_branch_today.py b/resources/tester/probe_pipe_branch_today.py deleted file mode 100644 index 0a9f3691c..000000000 --- a/resources/tester/probe_pipe_branch_today.py +++ /dev/null @@ -1,62 +0,0 @@ -"""저장된 프로젝트마다 관 지점 읽기가 **어느 가지**로 가는지 잰다 (계획서 0-7). - -투영 이월 가지를 지워도 되는지 판단하는 근거다 — 실제 자료에서 그 가지가 안 돌면 -「지문 일치」·「허용오차 안」 둘 중 하나로 끝난다는 뜻이다. 값은 안 고친다(읽기만). -""" - -import json -import sys -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from common_util.common_util_drainage_pipes import ( # noqa: E402 - ROUTE_MATCH_TOLERANCE_M, - max_projection_shift, - parse_pipe_points, -) -from common_util.common_util_route_geometry import RouteVertex # noqa: E402 -from config.config_system import STORAGE_BASE_DIR # noqa: E402 - -ROOT = Path(STORAGE_BASE_DIR) - - -def route_vertices(project_dir: Path): - """B05 가 읽는 노선 정점 — `route_main.geojson`.""" - for name in ("route_main.geojson", "planned_route.geojson"): - for path in project_dir.rglob(name): - data = json.loads(path.read_text(encoding="utf-8")) - # 파일 꼴이 둘이다 — Feature 하나짜리와 FeatureCollection. - geoms = [data.get("geometry") or {}] - geoms += [(f.get("geometry") or {}) for f in (data.get("features") or [])] - for geom in geoms: - if geom.get("type") == "LineString" and geom.get("coordinates"): - # 투영은 x·y 만 본다 — z·누가거리는 0 으로 채운다(읽기 전용 계산). - return ( - [ - RouteVertex(x=float(pair[0]), y=float(pair[1]), z=0.0, chainage_m=0.0) - for pair in geom["coordinates"] - ], - path.name, - ) - return None, None - - -for pipe_file in sorted(ROOT.rglob("pipe_points.json")): - if "initial_snapshot" in str(pipe_file): - continue - project_dir = pipe_file.parents[3] - document = json.loads(pipe_file.read_text(encoding="utf-8")) - points = parse_pipe_points(document.get("points")) - stored_sig = str(document.get("route_signature") or "") - vertices, src = route_vertices(project_dir) - shift = max_projection_shift(points, vertices) if vertices else None - if shift is None: - verdict = "노선 파일 없음 — 판정 불가" - elif shift <= ROUTE_MATCH_TOLERANCE_M: - verdict = f"허용오차 안 (같은 노선) — {shift:.4f}m ≤ {ROUTE_MATCH_TOLERANCE_M}m" - else: - verdict = f"⚠ 투영 이월 가지 — {shift:.4f}m > {ROUTE_MATCH_TOLERANCE_M}m" - print(f"{project_dir.name[:8]} 관 {len(points):3d}건 · 지문 {stored_sig[:18]:18s} · {src} · {verdict}") diff --git a/resources/tester/probe_unclosed_reach.py b/resources/tester/probe_unclosed_reach.py deleted file mode 100644 index 8f6a552bb..000000000 --- a/resources/tester/probe_unclosed_reach.py +++ /dev/null @@ -1,71 +0,0 @@ -"""사면이 안 닫히는 측점 — **얼마나 더 넓히면 닫히나**를 잰다(읽기 전용). - -왜 — 계산 반폭(±20m) 안에서 사면이 원지반을 못 만나면 절·성토 면적이 그 자리에서 잘린다. -「경고로 대체」가 2026-09-03 사용자 확정이지만, 데스크탑 창이 **수량이 조용히 적게 나온다**는 -점을 짚었다. 반폭을 넓힐지 정하려면 **얼마나 넓혀야 닫히는지**를 알아야 한다. - -재는 법 — 저장된 지반 샘플의 **바깥 5m 평균 기울기**로 지형이 계속 이어진다고 보고, -설계 사면선과 만나는 거리를 푼다. 지형이 그대로 이어진다는 가정이라 **하한 추정**이다. -""" - -import json -import sys -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from config.config_system import STORAGE_BASE_DIR # noqa: E402 - -ROOT = Path(STORAGE_BASE_DIR) - - -def outer_trend(samples, side): - """바깥 5m 구간의 지반 기울기(수직/수평) — 양수면 바깥으로 갈수록 높아진다.""" - pts = sorted( - ((float(s["offset_m"]), float(s["elevation_m"])) for s in samples if s.get("valid") is not False), - key=lambda p: p[0], - ) - if side == "left": - pts = [p for p in pts if p[0] <= pts[0][0] + 5.0] - pts = pts[::-1] - else: - pts = [p for p in pts if p[0] >= pts[-1][0] - 5.0] - if len(pts) < 2: - return None, None - run = abs(pts[-1][0] - pts[0][0]) - if run <= 0: - return None, None - return (pts[-1][1] - pts[0][1]) / run, pts[-1] - - -def main(project: str, route_hint: str = "") -> None: - base = ROOT / "1" / "3" / project / "B06_Section" / "cross_sections" - files = sorted(base.glob("cross_*.json")) - print(f"측점 파일 {len(files)}개") - rows = [] - for path in files: - doc = json.loads(path.read_text(encoding="utf-8")) - samples = doc.get("samples") or [] - if len(samples) < 4: - continue - for side in ("left", "right"): - slope, edge = outer_trend(samples, side) - if slope is None or edge is None: - continue - # 절토 사면은 1:0.4(암)~1:1.0(토사) — 바깥으로 갈수록 오르는 기울기 1/n. - # 지형이 사면보다 가파르면 영원히 안 만난다. - for ratio, label in ((0.4, "암 1:0.4"), (1.0, "토사 1:1.0")): - design_rise = 1.0 / ratio - if slope >= design_rise: - rows.append((path.stem, side, label, None)) - break - never = [r for r in rows if r[3] is None] - print(f"지형이 설계 사면보다 가팔라 **영원히 못 만나는** 측점·측 조합: {len(never)}건") - for row in never[:12]: - print(" ", row[0], row[1], row[2]) - - -if __name__ == "__main__": - main(sys.argv[1] if len(sys.argv) > 1 else "5601e828-feea-487a-9b25-415e5199f2f5") diff --git a/resources/tester/probe_unclosed_width.py b/resources/tester/probe_unclosed_width.py deleted file mode 100644 index b4d72ae55..000000000 --- a/resources/tester/probe_unclosed_width.py +++ /dev/null @@ -1,99 +0,0 @@ -"""미교차 측점이 **얼마나 더 넓히면 닫히는지** — 저장분만으로 잰다(읽기 전용). - -재는 법 — 저장된 `design_line` 의 바깥 끝(사면이 잘린 자리)에서 시작해, 지반이 **바깥 5m 평균 -기울기**로 이어진다고 보고 설계 사면선과 만나는 거리를 푼다. 지형이 그대로 이어진다는 가정이라 -**하한 추정**이며, 지형이 설계 사면보다 가파르면 「아무리 넓혀도 안 닫힘」으로 잡힌다. -""" - -import asyncio -import json -import sys -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -import config.config_db as db # noqa: E402 -from config.config_system import STORAGE_BASE_DIR # noqa: E402 - -ROUTE_ID = 169 -PROJECT = "5601e828-feea-487a-9b25-415e5199f2f5" -SAMPLES = Path(STORAGE_BASE_DIR) / "1" / "3" / PROJECT / "B06_Section" / "cross_sections" - - -def ground_of(chainage: float): - path = SAMPLES / f"cross_{int(round(chainage)):05d}m.json" - if not path.exists(): - return None - doc = json.loads(path.read_text(encoding="utf-8")) - pts = [ - (float(s["offset_m"]), float(s["elevation_m"])) - for s in doc.get("samples") or [] - if s.get("valid") is not False and s.get("elevation_m") is not None - ] - return sorted(pts) or None - - -def need_width(design_line, ground, ratio_fill, ratio_cut): - """양 끝에서 필요한 추가 폭(m). 못 닫히면 None.""" - out = {} - for side, pick in (("left", min), ("right", max)): - edge = pick(design_line, key=lambda p: p["offset_m"]) - tip_x, tip_z = float(edge["offset_m"]), float(edge["elevation_m"]) - near = [p for p in ground if abs(p[0] - tip_x) <= 5.0] - if len(near) < 2: - continue - run = near[-1][0] - near[0][0] - if abs(run) < 1e-6: - continue - terrain = (near[-1][1] - near[0][1]) / run # 바깥으로 갈수록 (+)면 오름 - gap = tip_z - (near[-1][1] if side == "right" else near[0][1]) - # 절토(사면이 올라감)면 1/cut, 성토(내려감)면 -1/fill. 바깥 방향 부호를 맞춘다. - rising = gap < 0 # 설계선이 지반보다 낮다 = 절토측 - design = (1.0 / ratio_cut) if rising else (-1.0 / ratio_fill) - if side == "left": - design, terrain = -design, -terrain - closing = design - terrain - if abs(closing) < 1e-9 or (gap < 0) != (closing > 0): - out[side] = None # 벌어지기만 함 — 아무리 넓혀도 안 닫힘 - continue - out[side] = abs(gap / closing) - return out - - -async def main() -> None: - await db.init_db_pool() - pool = db.get_db_pool() - rows = [] - async with pool.acquire() as conn, conn.cursor() as cur: - await cur.execute("SELECT chainage_m, data FROM cross_sections WHERE route_id=%s", (ROUTE_ID,)) - for chainage, data in await cur.fetchall(): - doc = (json.loads(data) if isinstance(data, str) else data) or {} - design = doc.get("design") or {} - if not design.get("slope_unclosed"): - continue - ground = ground_of(float(chainage)) - line = design.get("design_line") or [] - if not ground or len(line) < 2: - continue - need = need_width( - line, ground, float(design.get("fill_slope_ratio") or 1.2), - float(design.get("cut_slope_ratio") or 1.0), - ) - rows.append((float(chainage), need)) - await db.close_db_pool() - - never = [(c, s) for c, n in rows for s, v in n.items() if v is None] - finite = [(c, s, v) for c, n in rows for s, v in n.items() if v is not None] - print(f"미교차 측점 {len(rows)}곳") - print(f"· 아무리 넓혀도 안 닫히는 측·조합: {len(never)}건") - if finite: - finite.sort(key=lambda r: -r[2]) - print(f"· 넓히면 닫히는 조합: {len(finite)}건 — 필요한 추가 폭 최대 {finite[0][2]:.1f}m, " - f"중앙값 {sorted(v for _, _, v in finite)[len(finite)//2]:.1f}m") - for row in finite[:6]: - print(f" {row[0]:8.1f}m {row[1]:5s} +{row[2]:.1f}m") - - -asyncio.run(main()) diff --git a/resources/tester/run_346_3_route_save_timing.py b/resources/tester/run_346_3_route_save_timing.py deleted file mode 100644 index 67e7081cf..000000000 --- a/resources/tester/run_346_3_route_save_timing.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -"""346-3 노선 저장 실경로 시간 — 검증 프로젝트의 지금 노드 · 곡선 그대로 저장 한 번(수동 실행). - -같은 노선이라 값은 그대로(새 노선 행 번호만 바뀜) · 초기값은 안 건드림(`as_initial` 끔). -쓰는 법: `./venv/Scripts/python.exe resources/tester/run_346_3_route_save_timing.py ` -""" - -import asyncio -import json -import logging -import sys -import time -from pathlib import Path -from uuid import UUID - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from config.config_db import close_db_pool, init_db_pool # noqa: E402 - - -async def main(project_id: UUID) -> None: - from B05_Profile import B05_Profile_Router_Replan as replan - from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest - - logging.basicConfig(level=logging.INFO, format="%(message)s") - await init_db_pool() - try: - plan = await replan.read_route_plan(project_id) - nodes = plan["nodes"] - radii: dict[int, float] = {} - for curve in plan["curves"]: - if curve.get("node_first") == curve.get("node_last"): - radii[int(curve["node_first"])] = float(curve["radius_m"]) - body = RouteSaveRequest( - vertices=[ - { - "x": node["x"], - "y": node["y"], - "curve": index in radii, - "radius_m": radii.get(index), - } - for index, node in enumerate(nodes) - ], - station_snap=bool(plan.get("station_snap")), - ) - started = time.perf_counter() - result = await replan.replan_route(project_id, body) - seconds = time.perf_counter() - started - body_text = getattr(result, "body", None) - print(json.dumps(json.loads(body_text) if body_text else result, ensure_ascii=False)) - print(f"[시간] 노선 저장 전체 {seconds:.1f}s") - finally: - await close_db_pool() - - -asyncio.run(main(UUID(sys.argv[1]))) diff --git a/resources/tester/run_511_b5_route_save_timing.py b/resources/tester/run_511_b5_route_save_timing.py deleted file mode 100644 index e47c6e40e..000000000 --- a/resources/tester/run_511_b5_route_save_timing.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- coding: utf-8 -*- -"""511-B B-5 노선 저장 체인 시간 · 값 — 검증 프로젝트의 지금 노드 · 편집표 그대로 저장(수동 실행). - -같은 노선이라 값은 그대로(새 노선 행 번호만 바뀜) · 초기값 안 건드림(`as_initial` 끔). -갈래 = 파이썬 짝(Node 폴리라인 · 좌표 맞춤 끔) / Node 를 번갈아 N 번 · 저장 뒤 정본 파일 해시 -(노선 csv · 노드 · 곡선표 · 구조물 · 관 · 추가 측점 · 종단 · 횡단)가 처음과 같은지. -쓰는 법: `./venv/Scripts/python.exe resources/tester/run_511_b5_route_save_timing.py [N]` -""" - -import asyncio -import hashlib -import json -import logging -import statistics -import sys -import time -from pathlib import Path -from uuid import UUID - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from config.config_db import close_db_pool, init_db_pool # noqa: E402 - -CANON = ( - "B05_Profile/route/planned_route.csv", - "B05_Profile/route/planned_route_nodes.csv", - "B05_Profile/route/planned_route_curves.json", - "B05_Profile/route/route_main.geojson", - "B05_Profile/route/structures.json", - "B05_Profile/route/plus_stations.json", - "B04_PreProcess/drainage/edits/pipe_points.json", -) - - -def _hashes(root: Path) -> dict[str, str]: - files = [root / name for name in CANON] - files += sorted((root / "B06_Section").rglob("*.json")) - return { - str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()[:16] - for path in files - if path.is_file() - } - - -async def _save(project_id: UUID, python: bool) -> float: - from B05_Profile import B05_Profile_Route_Polyline_Write as writer - from B05_Profile import B05_Profile_Router_Replan as replan - from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest - from common_util import common_util_route_follow as follow - - plan = await replan.read_route_plan(project_id) - edits = plan["node_edits"] - body = RouteSaveRequest( - vertices=[ - {"x": n["x"], "y": n["y"], "curve": e["curve"], "radius_m": e["radius_m"]} - for n, e in zip(plan["nodes"], edits, strict=True) - ], - station_snap=bool(plan.get("station_snap")), - station_snap_step=plan.get("station_snap_step"), - ) - real = (writer.node_polyline, follow.fill_structure_coordinates) - if python: - writer.node_polyline = lambda *a: None - follow.fill_structure_coordinates = follow._fill_structure_coordinates_py - try: - started = time.perf_counter() - result = await replan.replan_route(project_id, body) - seconds = time.perf_counter() - started - finally: - writer.node_polyline, follow.fill_structure_coordinates = real - status = getattr(result, "status_code", 200) - assert status == 200, getattr(result, "body", result) - return seconds - - -async def main(project_id: UUID, rounds: int) -> None: - from B05_Profile import B05_Profile_Router_Replan as replan - - logging.basicConfig(level=logging.WARNING, format="%(message)s") - await init_db_pool() - try: - root, _ = await replan._project_paths(project_id) - first = _hashes(root) - times: dict[str, list[float]] = {"python": [], "node": []} - for _ in range(rounds): - for kind in ("python", "node"): - times[kind].append(await _save(project_id, kind == "python")) - after = _hashes(root) - changed = sorted( - k for k in first.keys() | after.keys() if first.get(k) != after.get(k) - ) - print(f"{kind}: {times[kind][-1]:.2f} s · 바뀐 정본 {len(changed)} {changed[:5]}") - print( - json.dumps( - { - k: { - "median_s": round(statistics.median(v), 2), - "runs": [round(x, 2) for x in v], - } - for k, v in times.items() - }, - ensure_ascii=False, - ) - ) - finally: - from common_util.common_util_calc_runner import _SERVED - - _SERVED.stop() - await close_db_pool() - - -if __name__ == "__main__": - asyncio.run(main(UUID(sys.argv[1]), int(sys.argv[2]) if len(sys.argv) > 2 else 3)) diff --git a/resources/tester/run_prebuild_smoke.py b/resources/tester/run_prebuild_smoke.py deleted file mode 100644 index a1eaa8ac4..000000000 --- a/resources/tester/run_prebuild_smoke.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -"""코리도 사전 생성 실경로 점검 — 실제 DB·실제 프로젝트로 한 번 돌려 본다(수동 실행).""" -import asyncio -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from config.config_db import close_db_pool, init_db_pool # noqa: E402 -from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor # noqa: E402 - -PROJECT_ID = "5cff3920-a181-4a3d-bec0-e0ac4082b75d" -ROUTE_ID = 139 -PROJECT_ROOT = ROOT / "storage" / "1" / "3" / PROJECT_ID - - -async def main() -> None: - await init_db_pool() - try: - ok = await prebuild_corridor(PROJECT_ID, ROUTE_ID, PROJECT_ROOT) - print("사전 생성:", ok) - finally: - await close_db_pool() - - -asyncio.run(main()) diff --git a/resources/tester/test_225_drawer_shortcuts.py b/resources/tester/test_225_drawer_shortcuts.py deleted file mode 100644 index 7265294b1..000000000 --- a/resources/tester/test_225_drawer_shortcuts.py +++ /dev/null @@ -1,276 +0,0 @@ -"""PLAN 334 — ☰ 바로가기(요구 225-1 · 225-2). - -① Node — 진행 단계 「시스템 관리자 전용」 = 전처리 길 기준(칸 번호 아님) · 대시보드 둘째 칸은 안 꺼짐 - settingLinks 역할 셋 · 설정 ☰ 지금 칸(진입 인자) -② 실제 브라우저(sub2 화면 서버 5177 · 꺼지면 건너뜀) — 대시보드 ☰ 이름 = 구역 제목 차례(역할 셋) · - 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) · M01 ☰ 다섯 · 강조 · 「도면 양식」 → open=도면 · - M02 → M02 이동은 떠나기 물음 먼저(가드 자리에 물음 창을 심어 확인 · 취소 = 머묾) -""" - -from __future__ import annotations - -import json -import os -import re -import subprocess -import urllib.request -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -from helper_verify_project import own_ports # noqa: E402 - -BASE = os.environ.get("AISLO_UI_BASE", own_ports()[1]) # 화면 서버 = OWNERS.md 이 폴더 줄 -LAYOUT = ROOT / "ui_template" / "ui_template_workflow_layout.ts" -NAV = ROOT / "A00_Common" / "m_settings_nav.ts" -STEPS = ["파일입력", "전처리", "종단설계"] -WF_ROUTES = ["b03-file-input", "b04-preprocess", "b05-profile"] - - -def _fn(src: str, name: str) -> str: - found = re.search(rf"^(?:export )?function {name}\([\s\S]*?^}}", src, re.M) - assert found, f"{name} 를 못 찾음" - return found.group(0).removeprefix("export ") - - -def _node(script: str, *args: str): - result = subprocess.run( - ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-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) - - -FAKE_DOM = ( - "const mk = (): any => { const cls = new Set(); return {" - " children: [] as any[], textContent: '', title: '', disabled: false, on: null as any," - " set className(v: string) { cls.clear(); v.split(' ').filter(Boolean).forEach((c) => cls.add(c)); }," - " get className() { return [...cls].join(' '); }," - " classList: { add: (...c: string[]) => c.forEach((x) => cls.add(x))," - " remove: (c: string) => cls.delete(c), contains: (c: string) => cls.has(c)," - " toggle: (c: string, on: boolean) => (on ? cls.add(c) : cls.delete(c)) }," - " setAttribute() {}, append(...c: any[]) { this.children.push(...c); }," - " addEventListener(_: string, f: any) { this.on = f; } }; };\n" - "const document = { createElement: () => mk() };\n" -) - - -def _step_bar(role: str, routes: list[str]) -> list[bool]: - """역할 · 길 목록으로 그린 단계 단추의 꺼짐 — 역할 물음이 끝난 뒤""" - src = _fn(LAYOUT.read_text(encoding="utf-8"), "createStepBar") - script = ( - FAKE_DOM - + "const ROUTES = { B01_ACCOUNT: 'b01-account', B04_PREPROCESS: 'b04-preprocess' };\n" - "const t = (k: string) => k;\nconst navigateTo = () => {};\n" - "const getDashboardRole = async () => process.argv[1];\n" - + src.replace("import.meta.env.DEV", "false") - + "\nconst bar = createStepBar(JSON.parse(process.argv[2]), -1," - " { routes: JSON.parse(process.argv[3]) });" - "\nawait new Promise((r) => setTimeout(r, 0));" - "\nconsole.log(JSON.stringify(bar.children.map((b: any) => b.disabled)));" - ) - return _node(script, role, json.dumps(STEPS), json.dumps(routes)) - - -def test_전처리_잠금은_길_기준(): - # 프로젝트 진행 단계 — 그대로(전처리만 시스템 관리자 전용) - assert _step_bar("ADMIN", WF_ROUTES) == [False, True, False] - assert _step_bar("USER", WF_ROUTES) == [False, True, False] - assert _step_bar("SYSTEM_ADMIN", WF_ROUTES) == [False, False, False] - # 대시보드 구역 바로가기 — 둘째 칸이 회사 관리자 · 일반에서 안 꺼짐 - titles = ["시스템 설정", "프로젝트", "임시 보관함"] - assert _step_bar("ADMIN", titles) == [False, False, False] - assert _step_bar("USER", titles) == [False, False, False] - - -def _settings(user: dict, active: str, company) -> dict: - """settingLinks 목록 + ☰ 에 맡긴 단계 막대(이름 · 지금 칸 · 아이콘 · 🏠)""" - src = NAV.read_text(encoding="utf-8") - icons = re.search(r"^const ICONS.*$", src, re.M).group(0) - script = ( - "const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," - " M02_MASTER_TEMPLATE: 'm02-master-template', B01_ACCOUNT: 'b01-account' };\n" - "const L = (k: string) => k;\nlet bar: any = null;\n" - "const createStepBar = (titles: string[], active: number, o: any) =>" - " ({ titles, active, icons: o.icons, home: o.homeButton, routes: o.routes });\n" - "const setDrawerSteps = (b: any) => { bar = b; };\nconst go = () => {};\n" - + icons - + "\n" - + _fn(src, "settingLinks") - + "\n" - + _fn(src, "mountSettingsDrawer") - + "\nconst [user, active, company] = JSON.parse(process.argv[1]);" - "\nmountSettingsDrawer(user, active, company);" - "\nconsole.log(JSON.stringify({ links: settingLinks(user, company).map((l) =>" - " [l.key, l.route, l.query ?? null]), bar }));" - ) - return _node(script, json.dumps([user, active, company])) - - -M02 = "m02-master-template" - - -def test_설정_바로가기_역할_셋_지금_칸(): - admin = _settings({"role": "SYSTEM_ADMIN", "company_id": 1}, "옵션", "3") - assert admin["links"] == [ - ["m01", "m01-master-data", None], - ["구조물조합", M02, {"open": "구조물조합", "root": "personal", "company": "3"}], - ["옵션", M02, {"open": "옵션", "root": "master", "company": "3"}], - ["도면", M02, {"open": "도면", "root": "master", "company": "3"}], - ["calcSites", "b01-account", None], # 계산 자리(504-2) = 대시보드 카드만 · ☰ 엔 없음 - ] - bar = admin["bar"] - assert len(bar["titles"]) == 4 - assert bar["active"] == 2 and bar["home"] is True - assert bar["icons"] == ["🗄️", "🧱", "🎛️", "📑"] - assert _settings({"role": "SYSTEM_ADMIN"}, "m01", None)["bar"]["active"] == 0 - # 일위대가조합 = 강조 없음 - assert _settings({"role": "SYSTEM_ADMIN"}, "일위대가조합", None)["bar"]["active"] == -1 - # 그 밖 역할 = 🏠 + 구조물 조합 하나(회사 인자 안 실음) - company = _settings({"role": "ADMIN", "company_id": 1}, "구조물조합", "3") - assert company["links"] == [["구조물조합", M02, {"open": "구조물조합", "root": "personal"}]] - assert company["bar"]["active"] == 0 - assert _settings({"role": "USER", "is_master": True}, "구조물조합", None)["links"][0][2] == { - "open": "구조물조합", - "root": "company", - } - assert _settings({"role": "USER"}, "구조물조합", None)["links"][0][2]["root"] == "personal" - - -# ── 실제 브라우저 ───────────────────────────────────────────── - - -def _up() -> bool: - try: - urllib.request.urlopen(BASE, timeout=3) - return True - except Exception: - return False - - -def _cred(who: str) -> dict: - if who == "시스템 관리자": - return json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - accounts = json.loads((ROOT / "tmp" / "verify_accounts.json").read_text(encoding="utf-8")) - return {k: accounts[who][k] for k in ("email", "password")} - - -def _page(browser, who: str, route: str, size=(1280, 700)): - ctx = browser.new_context(viewport={"width": size[0], "height": size[1]}) - r = ctx.request.post(f"{BASE}/api/auth/login/request", data=_cred(who), timeout=60000) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - page = ctx.new_page() - page.goto(f"{BASE}/#/{route}", wait_until="domcontentloaded") - return page - - -DRAWER_LABELS = ( - "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step-label')]" - ".map((e) => e.textContent.trim())" -) -DRAWER_DISABLED = ( - "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step')]" - ".map((e) => e.disabled)" -) -SECTION_TITLES = ( - "[...document.querySelector('.b01-dashboard__grid').children]" - ".map((s) => s.querySelector('.ui-card__title').textContent.trim())" -) -DRAWER_OPEN = "!!document.querySelector('.is-drawer-open')" - - -def _open_drawer(page): - page.click(".app-burger") - page.wait_for_selector(".app-drawer__steps .ui-workflow-layout__step") - - -@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") -@pytest.mark.parametrize("who, count", [("시스템 관리자", 10), ("회사관리자", 8), ("일반", 6)]) -def test_대시보드_구역_바로가기(who, count): - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - page = _page(browser, who, "b01-account") - page.wait_for_selector(".b01-dashboard__grid", timeout=60000) - page.wait_for_load_state("networkidle") - titles = page.evaluate(SECTION_TITLES) - assert len(titles) == count, titles - _open_drawer(page) - assert page.evaluate(DRAWER_LABELS) == titles - assert page.evaluate(DRAWER_DISABLED) == [False] * count # 둘째 칸 안 꺼짐 - if who == "시스템 관리자": - page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('보안')") - assert not page.evaluate(DRAWER_OPEN) - top = ( - "[...document.querySelector('.b01-dashboard__grid').children]" - ".find((s) => s.querySelector('.ui-card__title').textContent.trim() === '보안')" - ".getBoundingClientRect().top" - ) - try: # 부드러운 굴림이 멎을 때까지 - page.wait_for_function(f"() => Math.abs({top} - 52) <= 2", timeout=5000) - except Exception: - pass - assert abs(page.evaluate(top) - 52) <= 2, page.evaluate(top) - browser.close() - - -ACTIVE = "document.querySelector('.app-drawer__steps .is-active .ui-workflow-layout__step-label')?.textContent" -HASH = "decodeURIComponent(location.hash)" - - -def _drawer_click(page, label: str): - _open_drawer(page) - page.click(f".app-drawer__steps .ui-workflow-layout__step:has-text('{label}')") - - -@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") -def test_설정_4_페이지_바로가기(): - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - page = _page(browser, "시스템 관리자", "m01-master-data") - page.wait_for_selector(".m01-master", timeout=60000) - _open_drawer(page) - assert page.evaluate(DRAWER_LABELS) == [ - "대시보드", - "마스터 데이터", - "구조물 조합", - "구조물 옵션", - "도면 양식", - ] - assert page.evaluate(ACTIVE) == "마스터 데이터" - page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('도면 양식')") - page.wait_for_function("decodeURIComponent(location.hash).includes('open=도면')") - assert M02 in page.evaluate(HASH) - page.wait_for_selector(".m02-master", timeout=60000) - page.wait_for_load_state("networkidle") - _open_drawer(page) - assert page.evaluate(ACTIVE) == "도면 양식" - page.click(".app-burger") # 닫음 - - # M02 → M02 — 저장 안 한 고침 대신 가드 자리에 앱의 물음 창을 심음 - page.evaluate( - """async () => { - const url = (p) => performance.getEntriesByType('resource') - .map((e) => e.name).find((n) => n.includes(p)); - const router = await import(url('/router.ts')); - const ui = await import(url('/ui_template_elements.ts')); - router.setLeaveGuard(() => ui.showConfirmDialog('저장 안 한 고침')); - }""" - ) - before = page.evaluate(HASH) - _drawer_click(page, "구조물 옵션") - page.wait_for_selector(".ui-confirm") - page.click(".ui-confirm__actions button >> nth=0") # 취소 = 머묾 - page.wait_for_timeout(300) - assert page.evaluate(HASH) == before - _drawer_click(page, "구조물 옵션") - page.wait_for_selector(".ui-confirm") - page.click(".ui-confirm__actions button >> nth=1") # 버림 = 이동 - page.wait_for_function("decodeURIComponent(location.hash).includes('open=옵션')") - browser.close() diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py deleted file mode 100644 index fbed19636..000000000 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ /dev/null @@ -1,281 +0,0 @@ -"""244-11 B08 거리이정표 경로 그림 = 서버 자동 캡처(사용자 139번). - -가짜 브라우저(playwright.sync_api 자리에 가짜 모듈)로: -① [길찾기] 길 선 · 표지 → 서버 캡처 → auto/<경로 id>.png · 페이지 주소 = KAKAO_CAPTURE_ORIGIN + - /__route_capture(page.route 로 채움) · 좌표 저장 0 · 키 값 응답 0 -② 사용자 그림 우선 · 「자동 그림으로 되돌리기」(user_only) · 그림 지우기 = 둘 다 · - 경로 지우고 [저장] = 자동 그림도 -③ 실패 까닭 한 줄 — 키 없음 · Playwright 없음 · 브라우저 못 띄움 · 20초 넘김 · 도메인 불일치 -④ 동시 요청 = 하나씩(잠금) · 화면 = 브라우저 캡처 길 없음 · [길찾기] 뒤 자동 · 되돌리기 단추 -""" - -import json -import sys -import threading -import time -import types -from pathlib import Path -from uuid import uuid4 - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402 -from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 - -PNG = b"\x89PNG\r\n\x1a\nfake-map" -KEY = "fake-js-key-123" -LINES = [[[35.6866, 127.9095], [35.68, 127.89]], [[35.672, 127.87], [35.66, 127.85]]] -MARKS = [ - {"name": "거창군청", "lon": 127.9095, "lat": 35.6866}, - {"name": "시점", "lon": 127.85, "lat": 35.66}, -] - - -class Box: - """가짜 브라우저 기록 · 동작 모드.""" - - def __init__(self, mode="ok", delay=0.0): - self.mode, self.delay = mode, delay - self.channels, self.routed, self.goto, self.html = [], [], [], [] - self.active = self.peak = 0 - self.lock = threading.Lock() - - -def fake_playwright(box): - class Error(Exception): - pass - - class TimeoutError(Error): # noqa: A001 — playwright 이름 그대로 - pass - - class Page: - def route(self, url, handler): - box.routed.append(url) - self.handler = handler - - def goto(self, url, timeout): - box.goto.append(url) - fulfil = types.SimpleNamespace(fulfill=lambda **kw: box.html.append(kw["body"])) - self.handler(fulfil) - - def wait_for_function(self, expr, timeout): - assert timeout <= cap.TIMEOUT_S * 1000 - if box.mode == "timeout": - raise TimeoutError("Timeout 20000ms exceeded") - - def evaluate(self, expr): - return 1 if box.mode == "sdk_fail" else None - - def wait_for_timeout(self, ms): - pass - - def locator(self, selector): - assert selector == "#m" - return self - - def screenshot(self, timeout): - with box.lock: - box.active += 1 - box.peak = max(box.peak, box.active) - time.sleep(box.delay) - with box.lock: - box.active -= 1 - return PNG - - class Browser: - def new_page(self, viewport): - assert viewport == {"width": 1200, "height": 800} - return Page() - - def close(self): - pass - - class Chromium: - def launch(self, channel, headless, timeout): - box.channels.append(channel) - assert headless - if box.mode == "no_browser" or (box.mode == "no_edge" and channel == "msedge"): - raise Error(f"Chromium distribution '{channel}' is not found") - return Browser() - - class Ctx: - def __enter__(self): - return types.SimpleNamespace(chromium=Chromium()) - - def __exit__(self, *a): - return False - - return types.SimpleNamespace(Error=Error, TimeoutError=TimeoutError, sync_playwright=Ctx) - - -def _setup(tmp_path, monkeypatch, box, origin=None): - monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_playwright(box)) - monkeypatch.setattr( - cap, "spawn", cap.capture - ) # 가짜 브라우저 = 같은 프로세스(249-1 자식은 별도 시험) - monkeypatch.setenv(cap.KAKAO_JS, KEY) - if origin is None: - monkeypatch.delenv(cap.ORIGIN_KEY, raising=False) - else: - monkeypatch.setenv(cap.ORIGIN_KEY, origin) - - async def root(project_id): - return tmp_path - - monkeypatch.setattr(ms, "_project_root", root) - app = FastAPI() - app.include_router(ms.router) - return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone" - - -def _auto(client, base, route="r1"): - return client.post( - base + "/image/auto", params={"route": route}, json={"lines": LINES, "marks": MARKS} - ) - - -def test_자동_그림_저장_좌표_0(tmp_path, monkeypatch): - box = Box() - client, base = _setup(tmp_path, monkeypatch, box) - res = _auto(client, base) - assert res.json() == {"name": "auto/r1.png", "reason": None} - assert KEY not in res.text - auto = tmp_path / "B08_Quantity/milestone/auto/r1.png" - assert auto.read_bytes() == PNG - # 페이지 = 기본 주소 + /__route_capture 를 page.route 로 채움 · Edge 먼저 - assert box.routed == box.goto == ["http://localhost:5173/__route_capture"] - assert box.channels == ["msedge"] - html = box.html[0] - assert f"appkey={KEY}&autoload=false" in html and 'onerror="window.__fail=1"' in html - assert json.dumps(LINES) in html and "거창군청" in html and "tilesloaded" in html - assert "#E60046" in html and "1200px" in html and "800px" in html - # 좌표 저장 0 — 폴더엔 그림만 - files = sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file()) - assert files == ["B08_Quantity/milestone/auto/r1.png"] - got = client.get(base).json() - assert got["images"] == {"r1": "auto/r1.png"} and got["auto_images"] == {"r1": "auto/r1.png"} - assert client.get(base + "/image", params={"route": "r1"}).content == PNG - # 나쁜 좌표 · id = 거절 - bad = {"lines": [[[95, 127.9]]], "marks": []} - assert client.post(base + "/image/auto", params={"route": "r1"}, json=bad).status_code == 422 - assert _auto(client, base, "../x").status_code == 400 - - -def test_주소_바꿈_Edge_없으면_Chrome(tmp_path, monkeypatch): - box = Box("no_edge") - client, base = _setup(tmp_path, monkeypatch, box, origin=" http://100.72.32.27:5173/ ") - assert _auto(client, base).json()["name"] == "auto/r1.png" - assert box.channels == ["msedge", "chrome"] - assert box.goto == ["http://100.72.32.27:5173/__route_capture"] - - -def test_사용자_그림_우선_되돌리기(tmp_path, monkeypatch): - client, base = _setup(tmp_path, monkeypatch, Box()) - img = base + "/image" - client.post(img, params={"route": "r1"}, files={"file": ("u.jpg", b"user", "image/jpeg")}) - assert _auto(client, base).json()["name"] == "auto/r1.png" # 자동 그림은 늘 만듦 - got = client.get(base).json() - assert got["images"] == {"r1": "r1.jpg"} and got["auto_images"] == {"r1": "auto/r1.png"} - assert client.get(img, params={"route": "r1"}).content == b"user" # 사용자 그림 우선 - # 자동 그림으로 되돌리기 = 사용자 그림만 지움 - back = client.delete(img, params={"route": "r1", "user_only": "true"}).json() - assert back == {"name": "auto/r1.png"} - assert client.get(img, params={"route": "r1"}).content == PNG - # 그림 지우기 = 둘 다 - assert client.delete(img, params={"route": "r1"}).json() == {"name": None} - assert client.get(img, params={"route": "r1"}).status_code == 404 - assert client.get(base).json()["auto_images"] == {} - # 경로 지우고 [저장] = 자동 그림도 지움 · 남은 경로 것은 그대로 - _auto(client, base, "r1") - _auto(client, base, "r2") - client.post(img, params={"route": "r2"}, files={"file": ("u.png", b"u2", "image/png")}) - keep = {"routes": [{"id": "r2", "name": "김천역"}]} - saved = client.put(base, json=keep).json() - assert saved["images"] == {"r2": "r2.png"} and saved["auto_images"] == {"r2": "auto/r2.png"} - assert not (tmp_path / "B08_Quantity/milestone/auto/r1.png").exists() - - -def test_실패_까닭_한_줄(tmp_path, monkeypatch): - tail = " — [그림 변경]으로 넣으세요." - cases = { - "no_browser": "경로 그림 자동 캡처 실패 — 서버에서 브라우저(Edge · Chrome)를 못 띄움", - "timeout": "경로 그림 자동 캡처 실패 — 지도 그림이 20초 안에 안 끝남", - "sdk_fail": "경로 그림 자동 캡처 실패 — 카카오 지도 JS 가 캡처 주소(http://localhost:5173)" - "를 받지 않음(키 도메인 등록 확인)", - } - for mode, reason in cases.items(): - box = Box(mode) - client, base = _setup(tmp_path, monkeypatch, box) - res = _auto(client, base) - assert res.json() == {"name": None, "reason": reason + tail}, mode - assert KEY not in res.text - assert not (tmp_path / "B08_Quantity/milestone/auto").exists() # 실패 = 그림 없음 - # Playwright 없음 - monkeypatch.setitem(sys.modules, "playwright.sync_api", None) - assert _auto(client, base).json()["reason"] == ( - "경로 그림 자동 캡처 실패 — 서버에 Playwright 가 없습니다(pip install playwright)" + tail - ) - # 키 없음 = 브라우저 안 띄움 - box = Box() - client, base = _setup(tmp_path, monkeypatch, box) - monkeypatch.setenv(cap.KAKAO_JS, " ") - assert _auto(client, base).json() == { - "name": None, - "reason": "카카오 지도 JavaScript 키가 없습니다(.env KAKAO_JS_KEY)" + tail, - } - assert box.channels == [] - # 길 선 · 표지 없음 - monkeypatch.setenv(cap.KAKAO_JS, KEY) - empty = client.post(base + "/image/auto", params={"route": "r1"}, json={}).json() - assert empty["reason"] == "길 선 · 표지가 없어 지도 그림을 못 만듦" + tail - - -def test_동시_요청_하나씩(tmp_path, monkeypatch): - box = Box(delay=0.15) - _setup(tmp_path, monkeypatch, box) - out = tmp_path / "auto" - got = [] - - def one(i): - got.append(cap.make_route_image(out, f"r{i}", LINES, MARKS)) - - jobs = [threading.Thread(target=one, args=(i,)) for i in range(3)] - for job in jobs: - job.start() - for job in jobs: - job.join() - assert [g["saved"] for g in got] == [True] * 3 and box.peak == 1 - assert sorted(p.name for p in out.iterdir()) == ["r0.png", "r1.png", "r2.png"] - - -def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): - def read(name): - return (ROOT / "B08_Quantity" / name).read_text(encoding="utf-8") - - ui = ("UI_Milestone_KakaoMap", "UI_Milestone_Route", "UI_Milestone_Modal", "Milestone_Layout") - for name in (f"B08_Quantity_{n}.ts" for n in ui): - text = read(name) - assert "getDisplayMedia" not in text and "지도 캡처" not in text, name - assert "캡처는 localhost" not in text, name - state = read("B08_Quantity_UI_Milestone_State.ts") - # 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림 - assert ( - 'if (image && seg.key === "route" && path) void autoRouteImage(projectId, from, [path]);' - in state - ) - assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state - assert "user_only=true" in state and "showToast(reason," in state - route = read("B08_Quantity_UI_Milestone_Route.ts") - # 157 [초기화] = 사용자 그림만 지우고 길찾기 지도로 · 쓸데없는 안내 줄 없음 - assert 'label: "초기화"' in route and "deleteImage(projectId, routeId, true)" in route - assert "reset.hidden = !user;" in route and "HINT" not in route - kmap = read("B08_Quantity_UI_Milestone_KakaoMap.ts") - assert "등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김 - big = ("Milestone_Capture.py", "Router_Milestone.py", "UI_Milestone_State.ts") - for name in (f"B08_Quantity_{n}" for n in big): - assert len(read(name).splitlines()) <= 700 diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py deleted file mode 100644 index 3f303ed2f..000000000 --- a/resources/tester/test_249_1_b08_milestone_capture_child.py +++ /dev/null @@ -1,97 +0,0 @@ -"""249-1 B08 거리이정표 자동 그림 = 자식 프로세스 캡처(사용자 148번). - -서버(uvicorn · Windows) = WindowsSelectorEventLoopPolicy → 스레드 안 sync_playwright 가 -드라이버 하위 프로세스를 못 만듦. 같은 정책 · 스레드에서: -① 옛 길(같은 프로세스 shoot) = 「브라우저 못 띄움」 재현 -② make_route_image(자식 파이썬 프로세스) = 실제 Edge 로 PNG 생김 · 키 값 응답 0 -③ 자식 시간 넘김 · 자식 죽음 = 까닭 한 줄 -카카오 SDK 대신 단색 페이지(이 파일을 자식 스크립트로 · page_html 만 바꿈) — 네트워크 없음. -""" - -import asyncio -import json -import sys -import threading -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402 - -HTML = ( - '
' -) -KEY = "fake-js-key-249" -LINES = [[[35.6866, 127.9095], [35.66, 127.85]]] -MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}] - -if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로 - cap.page_html = lambda key, lines, marks, view=None: HTML - print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read())))) - sys.exit(0) - -import pytest # noqa: E402 - -pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows Selector 루프 재현") - - -def in_server_thread(fn): - """uvicorn 처럼 Selector 정책을 건 채 스레드(asyncio.to_thread 자리)에서 fn().""" - got = {} - - def run(): - try: - got["value"] = fn() - except BaseException as error: # noqa: BLE001 — 시험에서 그대로 봄 - got["error"] = error - - before = asyncio.get_event_loop_policy() - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - try: - job = threading.Thread(target=run) - job.start() - job.join(60) - finally: - asyncio.set_event_loop_policy(before) - return got - - -def test_옛_길_같은_프로세스는_못_띄움(): - pytest.importorskip("playwright.sync_api") - got = in_server_thread(lambda: cap.shoot(HTML, cap.DEFAULT_ORIGIN)) - assert isinstance(got.get("error"), cap.CaptureError) - assert str(got["error"]) == cap.NO_BROWSER - - -def test_자식_프로세스로_PNG_생김(tmp_path, monkeypatch): - pytest.importorskip("playwright.sync_api") - monkeypatch.setattr(cap, "CHILD", __file__) - monkeypatch.setenv(cap.KAKAO_JS, KEY) - monkeypatch.delenv(cap.ORIGIN_KEY, raising=False) - out = tmp_path / "auto" - got = in_server_thread(lambda: cap.make_route_image(out, "r1", LINES, MARKS)) - assert got.get("value") == {"saved": True, "reason": None}, got - png = (out / "r1.png").read_bytes() - assert png[:8] == b"\x89PNG\r\n\x1a\n" and len(png) > 1000 - assert sorted(p.name for p in out.iterdir()) == ["r1.png"] # .new 남지 않음 - - -def test_자식_시간_넘김_죽음_까닭(tmp_path, monkeypatch): - monkeypatch.setenv(cap.KAKAO_JS, KEY) - tail = " — [그림 변경]으로 넣으세요." - head = "경로 그림 자동 캡처 실패 — " - hang = tmp_path / "hang.py" - hang.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") - monkeypatch.setattr(cap, "CHILD", str(hang)) - monkeypatch.setattr(cap, "CHILD_TIMEOUT_S", 1) - got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS) - assert got == {"saved": False, "reason": head + "지도 그림이 20초 안에 안 끝남" + tail} - dead = tmp_path / "dead.py" - dead.write_text(f"raise SystemExit('boom {KEY}')\n", encoding="utf-8") - monkeypatch.setattr(cap, "CHILD", str(dead)) - got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS) - assert got == {"saved": False, "reason": head + cap.NO_BROWSER + tail} - assert not (tmp_path / "auto").exists() diff --git a/resources/tester/test_309_2_calc_sites_browser.py b/resources/tester/test_309_2_calc_sites_browser.py deleted file mode 100644 index cf4e1cc5d..000000000 --- a/resources/tester/test_309_2_calc_sites_browser.py +++ /dev/null @@ -1,198 +0,0 @@ -"""PLAN 504-2 화면 — 자기 포트(5177) Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만. - -- 시스템 관리자 = 「계산 자리」 카드 · 창(밝음 · 어두움 한 장씩 · tmp/shots_504_2) -- 회사 관리자 · 일반 = 카드 없음 · PUT 403 -- G1 체크 끔 → [저장] → B05 열기(검증_sub2) → 지반 표본이 서버 길(`/route/elevations`) · 판 기본값으로 되돌림 -- 시험 브라우저 = 표식 문자열(`MARK`) · 끝나면 그 표식 시스템 로그 행만 지움(508-1a) -- 포트 = OWNERS.md 이 폴더 줄 — 다른 폴더 사본이 남의 서버 판을 안 고침 - - ./venv/Scripts/python.exe -m pytest resources/tester/test_309_2_calc_sites_browser.py -q -""" - -from __future__ import annotations - -import asyncio -import json -import urllib.request -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] - -from helper_verify_project import own_ports # noqa: E402 - -API, BASE = own_ports() # 이 폴더 백엔드 · 그 백엔드를 프록시하는 vite(OWNERS.md 이 폴더 줄) -BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" -PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) -SHOTS = ROOT / "tmp" / "shots_504_2" -CARD = "계산 자리" -MARK = "AisloTest-309-2" # 시험 브라우저 문자열 — 시스템 로그 user_agent 칸으로 시험 행을 가림 - - -def _started(base: str) -> str | None: - try: - with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: - return json.loads(res.read()).get("started_at") - except Exception: - return None - - -_UP = _started(BASE) if BASE else None -# vite 가 이 폴더 백엔드가 아닌 서버(예 8000 = 메인 폴더)로 가면 PUT 이 남의 판을 고침 → 돌리지 않음 -pytestmark = pytest.mark.skipif( - _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" -) - - -def _cred(who: str) -> dict: - if who == "시스템 관리자": - return json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - accounts = json.loads((ROOT / "tmp" / "verify_accounts.json").read_text(encoding="utf-8")) - return {k: accounts[who][k] for k in ("email", "password")} - - -def _drop_test_logs() -> None: - """시험이 남긴 시스템 로그(로그인 · CALC_SITE_CHANGE) = 표식 브라우저 문자열 행만 지움.""" - from config.config_db import close_db_pool, init_db_pool - - async def run(): - pool = await init_db_pool() - try: - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) - await connection.commit() - finally: - await close_db_pool() - - asyncio.run(run()) - - -@pytest.fixture(scope="module") -def browser(): - sync = pytest.importorskip("playwright.sync_api") - committed = BOARD.read_bytes() # 시작 때 파일 바이트 그대로(줄끝 포함) - try: - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - try: - yield chrome - finally: - chrome.close() - finally: # 실패해도 판 = 커밋한 기본값 그대로 · 로그 지움은 Playwright 고리 밖에서 - BOARD.write_bytes(committed) - _drop_test_logs() - - -def _context(browser, who: str): - ctx = browser.new_context(viewport={"width": 1400, "height": 900}, user_agent=MARK) - r = ctx.request.post(f"{BASE}/api/auth/login/request", data=_cred(who), timeout=60000) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - return ctx - - -def _dashboard(ctx): - page = ctx.new_page() - page.goto(f"{BASE}/#/b01-account", wait_until="domcontentloaded") - page.wait_for_selector(".b01-dashboard__setting-card", timeout=60000) - return page - - -def _cards(page) -> list[str]: - return page.eval_on_selector_all( - ".b01-dashboard__setting-title", "(els) => els.map((e) => e.textContent.trim())" - ) - - -@pytest.mark.parametrize("who", ["회사관리자", "일반"]) -def test_다른_역할_카드_없음_403(browser, who): - ctx = _context(browser, who) - try: - assert CARD not in _cards(_dashboard(ctx)) - put = ctx.request.put( - f"{BASE}/api/dashboard/admin/calc-sites", - data={"sites": {"ground_sample": "server"}}, - ) - assert put.status == 403 - assert ctx.request.get(f"{BASE}/api/dashboard/calc-sites").ok # 읽기 = 로그인 - finally: - ctx.close() - - -def _elevation_calls(ctx) -> int: - page = ctx.new_page() - # 대시보드가 지금 프로젝트 칸을 비움 — 페이지 스크립트보다 먼저 넣음 - page.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')") - calls: list[str] = [] - page.on("request", lambda r: "/route/elevations" in r.url and calls.append(r.url)) - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(8000) - page.close() - return len(calls) - - -def test_시스템_관리자_카드_창_G1_서버_길(browser): - ctx = _context(browser, "시스템 관리자") - try: - before = _elevation_calls(ctx) # 기본값 = 격자(브라우저) - page = _dashboard(ctx) - assert CARD in _cards(page) - page.get_by_text(CARD, exact=True).click() - dialog = page.wait_for_selector(".b01-dashboard__calc-sites", timeout=30000) - rows = page.eval_on_selector_all( - ".b01-dashboard__calc-sites tbody tr", - "(trs) => trs.map((tr) => ({ head: tr.classList.contains('b01-dashboard__calc-group')," - " name: tr.cells[0].textContent.trim(), why: tr.cells[3].textContent.trim()," - " boxes: [...tr.querySelectorAll('input')].map((i) => [i.checked, i.disabled]) }))", - ) - heads = [row for row in rows if row["head"]] - calcs = [row for row in rows if not row["head"]] - assert len(heads) == 13 and len(calcs) == 45 # 511-B 단계 다섯 줄 · F9 · 511-C haul - whys = {row["name"].split(" · ")[0]: row["why"] for row in calcs} - assert not any(why.startswith("짝 모으는 중") for why in whys.values()) # 단계 줄 모두 엶 - assert whys["C1"].startswith("끄면 횡단을 서버 Node") # 511-B B-4 엶 - assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음 - editable = [row["name"] for row in calcs if not row["boxes"][0][1]] - opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더 - opened += ["R1", "R3", "P5", "P6", "F1", "F2"] # 511-B B-5 노선 · 따라감 - opened += ["R5", "R6", "R7", "F3"] # 511-B B-2 측점 - opened += ["P1", "P2", "P3", "P4", "P8"] # 511-B B-3 종단 엶 - opened += ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] # 511-B B-4 - opened += ["E2"] # 511-C 유토 배분 엶(기본 server = 체크 꺼짐) - opened += ["R2", "R4", "R8", "P7", "C7", "D4"] # ⑧ 시험용 - assert [name.split(" · ")[0] for name in editable] == opened - assert all(row["name"].split(" · ")[1] for row in calcs) # 이름 = 등록부 label - assert all(row["boxes"][1][1] for row in calcs) # 서버 정본 칸은 늘 잠금 - assert "지금 서버 1 줄" in dialog.inner_text() # haul 기본 server(511-C) - SHOTS.mkdir(parents=True, exist_ok=True) - dialog.screenshot(path=str(SHOTS / "light.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") - dialog.screenshot(path=str(SHOTS / "dark.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") - - page.click("input[data-calc-site='ground_sample']") - assert "지금 서버 2 줄" in dialog.inner_text() - assert not page.is_checked("input[data-calc-no='G1']") # 머리 따라 줄도 꺼짐 - page.locator(".b01-dashboard__calc-sites .ui-modal__actions button").click() - page.wait_for_selector(".b01-dashboard__calc-sites", state="detached", timeout=30000) - got = ctx.request.get(f"{BASE}/api/dashboard/calc-sites").json()["sites"] - assert got["ground_sample"] == "server", (got, BOARD.read_text("utf-8")) - assert json.loads(BOARD.read_text("utf-8"))["sites"]["ground_sample"] == "server" - logs = ctx.request.get(f"{BASE}/api/dashboard/admin/audit-logs").json()["items"] - assert any( - log.get("action") == "CALC_SITE_CHANGE" - and log.get("resource_ref") == "ground_sample=server" - for log in logs - ) - - after = _elevation_calls(ctx) - print(f"elevations 요청: 기본값 {before} · 서버 {after}") - assert after > before - - back = ctx.request.put( - f"{BASE}/api/dashboard/admin/calc-sites", data={"sites": {"ground_sample": "both"}} - ) - assert back.ok and back.json()["sites"]["ground_sample"] == "both" - finally: - ctx.close() diff --git a/resources/tester/test_309_mass_haul_badge.py b/resources/tester/test_309_mass_haul_badge.py deleted file mode 100644 index f57c37ef2..000000000 --- a/resources/tester/test_309_mass_haul_badge.py +++ /dev/null @@ -1,185 +0,0 @@ -"""B05 · B06 종단 누가토량 알약 — 테마 색 · 그래프 안 끌기(PLAN 309 · 요구 202) — 실제 브라우저(5174). - -① 색: 밝음 · 어두움 × 보통 · 음수 — 바탕 불투명 · 글자(제목 · 값) 대비 4.5 넘음 · 붉은 계통(빨강이 가장 큼) -② 끌기(마우스): 움직임 · 끈 뒤 click 은 삼킴 · 안 끈 누름은 지나감 · 영역 끝 막힘(오른쪽 아래 · 왼쪽 위 = 처음 자리) -③ 자리 기억: 화면 설정 한 칸(계정 올리기 PUT 에 실림 — 시험은 가로채 서버에 안 씀) · 다시 열어도 · 창 크기 바꿔도 같은 비율 -④ B06 도 같은 자리 · 손가락 끌기(터치 pointer)도 같은 길 -""" - -import json -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import test_device_audit as audit # noqa: E402 - -KEY = "aislo:pref:mass-haul-badge-pos" -# 그래프 고름 · 종단 편집 흔적 — 고른 것 수 · 되돌리기 단추 켜짐 -PICKED = ( - "[document.querySelectorAll('.b05-route-profile__body :is(.is-selected, .is-picked," - " .is-active)').length, location.hash]" -) -BOX = ( - "(() => { const b = document.querySelector('.mass-haul-badge'), a = b.parentElement" - ".querySelector('.b05-route-profile__body'); const r = b.getBoundingClientRect()," - " q = a.getBoundingClientRect(); return {l: r.left, t: r.top, r: r.right, b: r.bottom," - " w: r.width, h: r.height, ar: q.left + a.clientWidth, ab: q.top + a.clientHeight," - " al: q.left, at: q.top}; })()" -) -CONTRAST = r""" -(theme) => { - document.documentElement.dataset.theme = theme; - const parse = (s) => { const m = s.match(/[\d.]+/g).map(Number); - const k = s.startsWith('color(') ? 255 : 1; - return {r: m[0] * k, g: m[1] * k, b: m[2] * k, a: m.length > 3 ? m[3] : 1}; }; - const lum = (c) => { const ch = [c.r, c.g, c.b].map((v) => { v /= 255; - return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); - return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2]; }; - const ratio = (x, y) => { const [h, l] = [lum(x), lum(y)].sort((p, q) => q - p); - return (h + 0.05) / (l + 0.05); }; - const badge = document.querySelector('.mass-haul-badge'); - const was = badge.classList.contains('is-over'); - const out = []; - for (const over of [false, true]) { - badge.classList.toggle('is-over', over); - const bg = parse(getComputedStyle(badge).backgroundColor); - for (const t of ['em', 'strong']) { - const fg = parse(getComputedStyle(badge.querySelector(t)).color); - out.push({over, t, alpha: bg.a, ratio: ratio(fg, bg), red: fg.r > fg.g && fg.r > fg.b}); - } - } - badge.classList.toggle('is-over', was); - return out; -} -""" - - -def _ctx(browser, cred, touch=False, saved=None): - ctx = browser.new_context( - viewport={"width": 1440, "height": 900}, has_touch=touch, is_mobile=False - ) - audit._login(ctx, cred) - pid = audit._project_id(ctx) - ctx.add_init_script(f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})") - pushed = [] - - # 계정 취향 — 받기는 빈 값(처음 자리부터) · 올리기는 가로채 서버에 안 씀 - def prefs(route): - if route.request.method == "PUT": - pushed.append(json.loads(route.request.post_data or "{}").get("prefs", {})) - body = {"prefs": saved or {}} if route.request.method == "GET" else {"ok": True} - route.fulfill(status=200, content_type="application/json", body=json.dumps(body)) - - ctx.route("**/api/dashboard/me/ui-prefs", prefs) - return ctx, pushed - - -def _drag(page, dx, dy): - b = page.evaluate(BOX) - x, y = b["l"] + b["w"] / 2, b["t"] + b["h"] / 2 - page.mouse.move(x, y) - page.mouse.down() - page.mouse.move(x + dx / 2, y + dy / 2, steps=4) - page.mouse.move(x + dx, y + dy, steps=4) - page.mouse.up() - - -def _saved(page): - return json.loads(page.evaluate(f"localStorage.getItem({json.dumps(KEY)})") or "null") - - -@pytest.mark.skipif(not audit._server_up(), reason="sub1 화면 서버(5174) 꺼짐") -def test_mass_haul_badge_color_and_drag(): - from playwright.sync_api import sync_playwright - - cred = json.loads((audit.ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - ctx, pushed = _ctx(browser, cred) - page = ctx.new_page() - audit._open(page, "b05-profile") - page.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000) - - # ① - for theme in ("light", "dark"): - for row in page.evaluate(CONTRAST, theme): - assert row["alpha"] == 1 and row["ratio"] >= 4.5 and row["red"], (theme, row) - page.evaluate("document.documentElement.removeAttribute('data-theme')") - - # ② 안 끈 누름 = 지나감 · 끈 뒤 click = 삼킴 - page.evaluate( - "window.__clicks = 0; document.addEventListener('click', (e) =>" - " e.target.closest?.('.mass-haul-badge') && window.__clicks++)" - ) - start = page.evaluate(BOX) - picked = page.evaluate(PICKED) - page.click(".mass-haul-badge") - assert page.evaluate("window.__clicks") == 1 - assert page.evaluate(BOX)["l"] == start["l"] - assert _saved(page) is None - _drag(page, 200, 60) - moved = page.evaluate(BOX) - assert abs(moved["l"] - start["l"] - 200) <= 2 and abs(moved["t"] - start["t"] - 60) <= 2 - assert page.evaluate("window.__clicks") == 1 - _drag(page, 5000, 5000) # 오른쪽 아래 끝 막힘 - end = page.evaluate(BOX) - assert ( - end["ar"] - 1 <= end["r"] <= end["ar"] + 1 - and end["ab"] - 1 <= end["b"] <= end["ab"] + 1 - ) - assert _saved(page) == {"x": 1, "y": 1} - _drag(page, -5000, -5000) # 왼쪽 위 끝 = 처음 자리 - assert page.evaluate(BOX)["l"] == start["l"] and page.evaluate(BOX)["t"] == start["t"] - assert _saved(page) == {"x": 0, "y": 0} - assert page.evaluate(PICKED) == picked # 끌기가 그래프 고름 · 편집을 안 일으킴 - - # ③ 비율 기억 · 계정 올리기에 실림 · 다시 열기 · 창 크기 - _drag(page, 300, 100) - ratio = _saved(page) - assert 0 < ratio["x"] < 1 and 0 < ratio["y"] < 1 - page.wait_for_timeout(2000) # 올리기 모음 1.5초 - assert any(KEY in push for push in pushed) - audit._open(page, "b05-profile") - page.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000) - b = page.evaluate(BOX) - x0, y0 = start["l"] - start["al"], start["t"] - start["at"] - span_x = b["ar"] - b["al"] - x0 - b["w"] - span_y = b["ab"] - b["at"] - y0 - b["h"] - assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio) - assert abs((b["t"] - b["at"] - y0) - ratio["y"] * span_y) <= 2, (b, ratio) - page.set_viewport_size({"width": 1200, "height": 800}) - page.wait_for_timeout(800) - b = page.evaluate(BOX) - span_x = b["ar"] - b["al"] - x0 - b["w"] - assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio) - assert b["r"] <= b["ar"] + 1 and b["b"] <= b["ab"] + 1 - ctx.close() - - # ④ B06 같은 자리(계정 값으로 받음) · 손가락 끌기 - tctx, _ = _ctx(browser, cred, touch=True, saved={KEY: json.dumps(ratio)}) - tp = tctx.new_page() - audit._open(tp, "b06-section") - tp.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000) - b = tp.evaluate(BOX) - span_x = b["ar"] - b["al"] - x0 - b["w"] - assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio) - cdp = tctx.new_cdp_session(tp) - x, y = b["l"] + b["w"] / 2, b["t"] + b["h"] / 2 - cdp.send( - "Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x, "y": y}]} - ) - for i in range(1, 6): - cdp.send( - "Input.dispatchTouchEvent", - {"type": "touchMove", "touchPoints": [{"x": x - 20 * i, "y": y - 8 * i}]}, - ) - cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) - after = tp.evaluate(BOX) - assert abs(after["l"] - b["l"] + 100) <= 2 and abs(after["t"] - b["t"] + 40) <= 2, ( - b, - after, - ) - assert _saved(tp) != ratio - browser.close() diff --git a/resources/tester/test_313_clear_all.py b/resources/tester/test_313_clear_all.py deleted file mode 100644 index dcc7f15cd..000000000 --- a/resources/tester/test_313_clear_all.py +++ /dev/null @@ -1,142 +0,0 @@ -"""313 B05 · B06 좌측 구조물 목록 맨 아래 [구조물 전체 삭제] — 목록 있음 · 없음 · 누름 뒤 0 · 취소 · B05 · B06. - -목록 그리기 · 입력 배선은 진짜 TS 를 Node 로 돌려 보고(helper_313), B05 · B06 이 같은 목록 한 벌에 -각자 관 빼기 길을 거는지는 배선 글로 본다. -""" - -import json -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -HELPER = Path(__file__).with_name("helper_313_clear_all.cjs") - - -def _read(*parts: str) -> str: - return ROOT.joinpath(*parts).read_text(encoding="utf-8") - - -@pytest.fixture(scope="module") -def out() -> dict: - run = subprocess.run( - ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", cwd=ROOT - ) - assert run.returncode == 0, run.stderr - return json.loads(run.stdout) - - -def test_목록_있음_맨아래_단추(out): - filled = out["listCases"]["filled"] - assert filled["rows"] == 3 # 구조물 1 + 관 1 + 단추 줄 - assert filled["lastClass"] == "b05-route__irregular-clear" - assert filled["button"] == { - "text": "구조물 전체 삭제", - "className": "b05-route__irregular-btn is-danger", # 좌측 패널 위험 단추 꼴 - "type": "button", - } - assert filled["cleared"] == 1 - - -def test_목록_없음_단추_숨김(out): - empty = out["listCases"]["empty"] - assert empty == { - "rows": 1, - "lastClass": "b05-route__irregular-empty", - "button": None, - "cleared": 0, - } - # 단추를 안 건 목록(다른 쓰임)은 그대로 - assert out["listCases"]["noHandler"]["button"] is None - - -def test_누름_확인_뒤_0(out): - done = out["confirmed"] - assert done["confirms"] == [["구조물 4개를 모두 지울까요?", "모두 삭제"]] - assert done["left"] == 0 - # 하나씩 지우기와 같은 길 — 관은 관 빼기(자동 · 손 배치 모두) · 구조물은 비우고 알림 한 번 - assert done["calls"] == {"reset": 1, "release": 1, "removed": [80, 160.37], "emit": 1} - pipes_only = out["pipesOnly"] - assert pipes_only["left"] == 0 and pipes_only["calls"]["emit"] == 0 - - -def test_취소면_그대로(out): - cancelled = out["cancelled"] - assert cancelled["confirms"] == [["구조물 3개를 모두 지울까요?", "모두 삭제"]] - assert cancelled["left"] == 3 - assert cancelled["calls"] == {"reset": 0, "release": 0, "removed": [], "emit": 0} - assert out["nothing"]["confirms"] == [] # 빈 목록 = 확인창도 안 뜸 - - -def test_B05_B06_같은_목록_각자_관빼기(): - panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") - assert "onClearAll: () => void clearAll()," in panel - assert "const { startNew, clearAll } = bindStructuresEvents(panelContext);" in panel - assert len(panel.splitlines()) <= 700 - # 좌측 「구조물 배치」 폼 · 목록 = 두 보기 한 벌 · 관 빼기 알림은 지금 보기로 - shared = _read("A00_Common", "design", "design_shared.ts") - assert "onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM)," in shared - b06 = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") - assert "onPipeRemove: (chainageM) => removePipeAt(chainageM)," in b06 # B06 = 관 초안 캐시 - b05 = _read("A00_Common", "design", "design_view_profile.ts") - assert "onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage)," in b05 - # 공용 확인창 · 저장 길(초안 → [저장])은 안 고침 - events = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts") - assert 'import { showConfirmDialog, showToast } from "@ui/ui_template_elements";' in events - assert "ctx.emit();" in events - - -def test_CSS_단추_줄(): - css = _read("B05_Profile", "B05_Profile_UI_Style.css") - assert ".b05-route__irregular-clear {" in css - assert ".b05-route__irregular-clear .b05-route__irregular-btn {" in css - - -# ── 313-2 실제 브라우저(정적 쪽 · 서버 불필요) — 위 [삭제] 단추와 같은 높이 · 같은 위험 색 ── - -MARKUP = ( - "
" - "
" - "" - "" - "
" - "
    " - "
  • NO.1배수관
  • " - "
  • " - "
" -) - - -@pytest.mark.parametrize("theme", ["light", "dark"]) -def test_단추_높이_위_삭제와_같음(theme): - from playwright.sync_api import sync_playwright - - css = "".join( - p.read_text("utf-8") - for folder in ("ui_template", "B05_Profile", "B06_Section") - for p in sorted((ROOT / folder).glob("*.css")) - ) - html = ( - f"" - f"{MARKUP}" - ) - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - page = browser.new_page() - page.set_content(html) - out = page.evaluate( - """() => ['top', 'clear'].map((id) => { - const el = document.getElementById(id); - return [el.getBoundingClientRect().height, getComputedStyle(el).color]; - })""" - ) - browser.close() - (top_h, top_color), (clear_h, clear_color) = out - assert top_h == clear_h, out # 두 단추 높이 px 같음(공용 --panel-control-h) - assert clear_color == top_color, out # 위험 색 꼴 그대로 - css_rule = _read("B05_Profile", "B05_Profile_UI_Style_Structures.css") - assert ".b05-route__irregular-clear > button {" in css_rule - assert "px" not in css_rule.split(".b05-route__irregular-clear {")[-1].split("}")[0] diff --git a/resources/tester/test_349_0d_follow_store.py b/resources/tester/test_349_0d_follow_store.py deleted file mode 100644 index 5e750a141..000000000 --- a/resources/tester/test_349_0d_follow_store.py +++ /dev/null @@ -1,213 +0,0 @@ -"""PLAN 349-0d sub7 — 노선을 놓으면 관 · 구조물 새 측점이 가게에 바로. - -· 열기만 = 가게 안 씀(저장값 그대로) -· 꼭짓점 한 번 끌기 = 묶음 한 번 · 가게 관 · 구조물 측점 바뀜 - · 사진첩이 찍기 전(setTimeout 0)에 씀(한 걸음) -· 새 노선에서 20 m 넘게 먼 구조물 수 알림 - · 좌표가 그 누가거리를 낳는 구조물만(아니면 410 낡음 = 노선 위로 · 알림 0) -· 되돌리기(사진첩이 세션을 되씀) · 초안만 버림 = 초안 전 값(가게 비움 · 입력 = 저장 관) -· 계산 · 알림 안 입력 쓰기 0 -짝: `helper_349_0d_follow_store.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜 · 검증_sub7 노선 · 관). -""" - -from __future__ import annotations - -import csv -import json -import math -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest -from shapely.geometry import LineString, Point - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 -from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 -from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -ROUTE = PROJECT / "B05_Profile/route" -SECTION = PROJECT / "B06_Section" -GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" -ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} - - -def _csv(path: Path) -> list[list[float]]: - with path.open(encoding="utf-8") as handle: - return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - - -def _json(path: Path): - return json.loads(path.read_text(encoding="utf-8")) - - -def _structure(sid: str, line: list[list[float]], ratio: float, off: float) -> dict: - """노선 위(off 0) · 옆(off m) 점 구조물 — 저장 노선 정수 m 누가거리 · 좌표. - 좌표가 그 누가거리를 낳는 쪽 옆(410 낡음 판정 밖 · 굽은 노선은 한쪽 옆이 - 다른 구간에 더 가까움).""" - lengths = [math.dist(a, b) for a, b in zip(line, line[1:])] - at = float(round(sum(lengths) * ratio)) - walked = 0.0 - for (a, b), length in zip(zip(line, line[1:]), lengths): - if walked + length >= at: - t = (at - walked) / length - dx, dy = (b[0] - a[0]) / length, (b[1] - a[1]) / length - foot = (a[0] + dx * length * t, a[1] + dy * length * t) - break - walked += length - route = LineString(line) - side = lambda s: (foot[0] - dy * s, foot[1] + dx * s) # noqa: E731 - x, y = next( - (side(s) for s in (off, -off) if round(route.project(Point(side(s)))) == at), side(off) - ) - return { - "structure_id": sid, - "type_id": "erosion_check", - "placement": "point", - "chainage_m": at, - "start_m": None, - "end_m": None, - "x": x, - "y": y, - "options": {}, - } - - -@pytest.fixture(scope="module") -def r() -> dict: - if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): - pytest.skip(SKIP_REASON) - curves = _json(ROUTE / "planned_route_curves.json") - sections = sorted( - (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), - key=lambda s: s["chainage_m"], - ) - pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - line = _csv(ROUTE / "planned_route.csv") - structures = [_structure("s-near", line, 0.5, 0.0), _structure("s-far", line, 0.7, 30.0)] - job = { - "line": line, - "structures": structures, - "nodes": _csv(ROUTE / "planned_route_nodes.csv"), - "curves": [ - {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] - ], - "stationSnap": bool(curves.get("station_snap")), - "minRadius": curves["min_radius_m"], - "interval": 20.0, - "types": [ - {"type_id": t.type_id, "group": t.group, "placement": t.placement} - for t in structure_type_map().values() - ], - "pipes": pipes, - "standard": STANDARD_CROSS_SECTION, - "conversion": ONES, - "detail": { - "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), - "cross_sections": sections, - }, - } - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - grid = Path(folder) / "grid.bin" - grid.write_bytes(dtm_grid_bytes(GRID)) - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_0d_follow_store.cjs"), str(path), str(grid)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_열기만_하면_가게_안_씀(r): - o = r["open"] - assert o["storePipes"] is None and o["storeStructures"] is None - assert o["followed"] == [] - - -def test_꼭짓점_끌기_가게에_새_측점_한_번(r): - d = r["drag"] - assert d["runs"] == [["route"]], d["runs"] - assert d["pipes"] == d["inputPipes"] - assert d["pipes"] != r["saved"] - assert len(d["pipes"]) == len(r["saved"]) # 같은 관 그대로(자리만 다시 매김) - assert {i: c for i, c in d["structures"]} != {i: c for i, c in r["savedStructures"]} - assert d["atRecord"] == {"pipes": d["pipes"], "structures": d["structures"]} # 한 걸음 안 - assert d["followed"] == [1] # s-far 만 20 m 넘음 - assert d["blocked"] == 0 - - -def test_되돌리기_초안_전_값(r): - u = r["undo"] - assert u["runs"] == [["route"]], u["runs"] - assert u["storePipes"] is None and u["storeStructures"] is None - assert u["inputPipes"] == r["saved"] - assert u["followed"] == [1, 0] - - -def test_초안만_버림_초안_전_값(r): - d = r["drop"] - assert d["second"] != r["saved"] - assert d["storePipes"] is None - assert d["inputPipes"] == r["saved"] - assert d["blocked"] == 0 - - -_REPORTS_JS = r""" -const fs = require("fs"); -const ts = require(process.argv[1] + "/config/node_modules/typescript"); -const file = process.argv[1] + "/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts"; -const src = fs.readFileSync(file, "utf8"); -const options = { compilerOptions: { module: ts.ModuleKind.CommonJS } }; -const js = ts.transpileModule(src, options).outputText; -const box = { exports: {} }; -new Function("exports", "module", js)(box.exports, box); -const f = box.exports.followPipeReports; -const reports = [ - { chainage_m: 86, effective_diameter_mm: 800, start_m: 84, end_m: 88 }, - { chainage_m: 175, effective_diameter_mm: 1000 }, -]; -const pipes = [{ chainage_m: 179.374 }, { chainage_m: 89.123, start_m: 87.5 }]; -process.stdout.write(JSON.stringify({ - moved: f(reports, pipes), - saved: f(reports, null), - count: f(reports, pipes.slice(1)), -})); -""" - - -def test_관_알림_목록_가게_측점으로(): - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", "-e", _REPORTS_JS, str(ROOT)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=60, - ) - assert proc.returncode == 0, proc.stderr - out = json.loads(proc.stdout) - first, second = out["moved"] - assert (first["chainage_m"], first["effective_diameter_mm"]) == (89.12, 800) - assert first["start_m"] == 87.5 # 가게 구간 그대로 - assert abs(first["end_m"] - 91.12) < 1e-9 # 없으면 옮긴 만큼 - assert (second["chainage_m"], second["effective_diameter_mm"]) == (179.37, 1000) - assert [p["chainage_m"] for p in out["saved"]] == [86, 175] - assert [p["chainage_m"] for p in out["count"]] == [86, 175] # 관 수 다르면 그대로 diff --git a/resources/tester/test_349_0g_draft_b06_loop.py b/resources/tester/test_349_0g_draft_b06_loop.py deleted file mode 100644 index 9fc0899ee..000000000 --- a/resources/tester/test_349_0g_draft_b06_loop.py +++ /dev/null @@ -1,115 +0,0 @@ -"""PLAN 349-0g sub7 — 노선 초안을 둔 채 횡단 보기(B06) → 묶음 고리 없음 · 누가토량 그대로. - -· B05 꼭짓점 한 번 끌기 = 한 번 -· B06 그리기 여러 번(카드가 새 노선 상세를 종단도에 render) = 0 번 - · 누가토량 · 횡단 면적 B05 와 같음 -· B06 에서 계획고 조작 하나 = 한 번 · B05 로 돌아와도 안 돎 · 입력 쓰기 막음 0 -짝: `helper_349_0g_draft_b06_loop.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜 · 검증_sub7 노선). -""" - -from __future__ import annotations - -import csv -import json -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 -from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 -from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -ROUTE = PROJECT / "B05_Profile/route" -SECTION = PROJECT / "B06_Section" -GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" -ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} - - -def _csv(path: Path) -> list[list[float]]: - with path.open(encoding="utf-8") as handle: - return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - - -def _json(path: Path): - return json.loads(path.read_text(encoding="utf-8")) - - -@pytest.fixture(scope="module") -def r() -> dict: - if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): - pytest.skip(SKIP_REASON) - curves = _json(ROUTE / "planned_route_curves.json") - sections = sorted( - (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), - key=lambda s: s["chainage_m"], - ) - pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - job = { - "line": _csv(ROUTE / "planned_route.csv"), - "nodes": _csv(ROUTE / "planned_route_nodes.csv"), - "curves": [ - {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] - ], - "stationSnap": bool(curves.get("station_snap")), - "minRadius": curves["min_radius_m"], - "interval": 20.0, - "types": [ - {"type_id": t.type_id, "group": t.group, "placement": t.placement} - for t in structure_type_map().values() - ], - "pipes": pipes, - "standard": STANDARD_CROSS_SECTION, - "conversion": ONES, - "detail": { - "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), - "cross_sections": sections, - }, - } - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - grid = Path(folder) / "grid.bin" - grid.write_bytes(dtm_grid_bytes(GRID)) - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_0g_draft_b06_loop.cjs"), str(path), str(grid)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_b05_끌기_한_번(r): - assert r["b05"]["runs"] == [["route"]], r["b05"]["runs"] - - -def test_b06_그리기_돌지_않음_누가토량_같음(r): - b = r["inB06"] - assert b["drawnIsDraft"] - assert b["runs"] == 0 - assert b["final"] == r["b05"]["final"] - assert b["cut"] == r["b05"]["cut"] - - -def test_b06_조작_하나_한_번_돌아와도_안_돎(r): - assert r["grade"]["runs"] == [["grade"]], r["grade"]["runs"] - assert r["back"]["runs"] == 0 - assert r["back"]["blocked"] == 0 diff --git a/resources/tester/test_349_0k_undo_saved.py b/resources/tester/test_349_0k_undo_saved.py deleted file mode 100644 index 523eedbe2..000000000 --- a/resources/tester/test_349_0k_undo_saved.py +++ /dev/null @@ -1,133 +0,0 @@ -"""PLAN 349-0k sub7 — 되돌리기로 저장 노선에 돌아오면 저장 정본 그대로(다시 셈 없음). - -· 열기 → 노선 꼭짓점 끌기(상세가 새 노선 측점으로 갈아끼워지고 다시 셈) → 되돌리기 한 번 -· 되돌린 뒤 상세 = 열 때 받은 저장 정본 객체 · 측점 · 횡단 · 누가토량 = 열기 값 -· 되돌리기 = 묶음 한 번 · 더 안 돎 -짝: `helper_349_0k_undo_saved.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜 · 종단 보기 고리 흉내). -""" - -from __future__ import annotations - -import csv -import json -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 -from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 -from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -ROUTE = PROJECT / "B05_Profile/route" -SECTION = PROJECT / "B06_Section" -GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" -ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} - - -def _csv(path: Path) -> list[list[float]]: - with path.open(encoding="utf-8") as handle: - return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - - -def _json(path: Path): - return json.loads(path.read_text(encoding="utf-8")) - - -@pytest.fixture(scope="module") -def r() -> dict: - if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): - pytest.skip(SKIP_REASON) - curves = _json(ROUTE / "planned_route_curves.json") - sections = sorted( - (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), - key=lambda s: s["chainage_m"], - ) - pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - # 저장 횡단 면적 = 서버가 상세를 낼 때 셈(파일엔 없음) — 정본 대신 정한 값 - for i, section in enumerate(sections): - section["design"] = { - "ground_type": "soil", - "design_elevation_m": section["center_z"], - "cut_area_m2": 4.0 + (i % 5), - "fill_area_m2": 1.5 + (i % 3), - } - job = { - "line": _csv(ROUTE / "planned_route.csv"), - "nodes": _csv(ROUTE / "planned_route_nodes.csv"), - "curves": [ - {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] - ], - "stationSnap": bool(curves.get("station_snap")), - "minRadius": curves["min_radius_m"], - "interval": 20.0, - "types": [ - {"type_id": t.type_id, "group": t.group, "placement": t.placement} - for t in structure_type_map().values() - ], - "pipes": pipes, - "standard": STANDARD_CROSS_SECTION, - "conversion": ONES, - "detail": { - "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), - "cross_sections": sections, - }, - } - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - grid = Path(folder) / "grid.bin" - grid.write_bytes(dtm_grid_bytes(GRID)) - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_0k_undo_saved.cjs"), str(path), str(grid)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_열_때_한_번_저장_정본(r): - o = r["open"] - assert [x["outcome"] for x in o["runs"]] == ["done"], o["runs"] - assert o["same"] - assert o["final"] is not None - - -def test_끌기_새_측점으로_갈아끼움(r): - d = r["drag"] - assert [x["sources"] for x in d["runs"]] == [["route"]], d["runs"] - assert d["swapped"] # 이 시험이 갈아끼움 길을 실제로 거침 - assert d["final"] != r["open"]["final"] - - -def test_되돌리기_열기_값(r): - u, o = r["undo"], r["open"] - assert [x["sources"] for x in u["runs"]] == [["route"]], u["runs"] - assert u["restores"] == [{"saved": True, "back": {"same": True}}] - assert u["sameObject"] - assert u["stations"] == o["stations"] - assert u["cross"] == o["cross"] - assert u["final"] == o["final"] - - -def test_되돌린_뒤_조용함(r): - assert r["after"]["runs"] == 0 - assert r["after"]["blocked"] == 0 diff --git a/resources/tester/test_349_0l_undo_draw.py b/resources/tester/test_349_0l_undo_draw.py deleted file mode 100644 index e73feb13d..000000000 --- a/resources/tester/test_349_0l_undo_draw.py +++ /dev/null @@ -1,155 +0,0 @@ -"""PLAN 349-0l sub7 — 되돌리면 종단도 · 상세 · 측점이 열 때와 한 벌(다시 셈 0). - -· 열기 → 노선 꼭짓점 끌기 → 되돌리기 → 횡단 보기 → 종단 보기 — 누가토량 · 측점 · 횡단 = 열기 값 -· 진짜 종단도 낡음 판정 · crossPreview 예약을 거침 — 되돌린 뒤 다시 셈 0 · 낡음 0 -· 묶음 기록에 되돌림 길(restored · 까닭) · 되돌리기 = 묶음 한 번 -· 되돌리기 = 화면 Ctrl+Z 꼴(사진첩이 세션 노선 초안 칸을 먼저 비움 · 알림 두 번 · 349-0m) -· 되돌린 뒤 값 = 버림 그리기 멈춤이 풀린 다음 틀 그림(묶음이 조용해진 뒤 한 번 · 472-5 덧) -짝: `helper_349_0l_undo_draw.cjs`(실제 TS 를 Node 로 · 화면 부품 · 서버 창구만 가짜). -""" - -from __future__ import annotations - -import csv -import json -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 -from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 -from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -ROUTE = PROJECT / "B05_Profile/route" -SECTION = PROJECT / "B06_Section" -GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" -ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} - - -def _csv(path: Path) -> list[list[float]]: - with path.open(encoding="utf-8") as handle: - return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - - -def _json(path: Path): - return json.loads(path.read_text(encoding="utf-8")) - - -@pytest.fixture(scope="module") -def r() -> dict: - if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): - pytest.skip(SKIP_REASON) - curves = _json(ROUTE / "planned_route_curves.json") - sections = sorted( - (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), - key=lambda s: s["chainage_m"], - ) - pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - # 저장 횡단 면적 = 서버가 상세를 낼 때 셈(파일엔 없음) — 정본 대신 정한 값 - for i, section in enumerate(sections): - section["design"] = { - "ground_type": "soil", - "design_elevation_m": section["center_z"], - "cut_area_m2": 4.0 + (i % 5), - "fill_area_m2": 1.5 + (i % 3), - } - job = { - "line": _csv(ROUTE / "planned_route.csv"), - "nodes": _csv(ROUTE / "planned_route_nodes.csv"), - "curves": [ - {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] - ], - "stationSnap": bool(curves.get("station_snap")), - "minRadius": curves["min_radius_m"], - "interval": 20.0, - "types": [ - {"type_id": t.type_id, "group": t.group, "placement": t.placement} - for t in structure_type_map().values() - ], - "pipes": pipes, - "standard": STANDARD_CROSS_SECTION, - "conversion": ONES, - # 구조물 하나 — 첫 관 자리(같은 측점을 덮음 · 측점 목록 그대로) · follow 가 함께 옮김 - "structures": [ - { - "structure_id": "s-349l", - "type_id": "cross_drain_exposed", - "chainage_m": pipes[0]["chainage_m"], - "x": pipes[0]["x"], - "y": pipes[0]["y"], - } - ], - "detail": { - "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), - "cross_sections": sections, - }, - } - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - grid = Path(folder) / "grid.bin" - grid.write_bytes(dtm_grid_bytes(GRID)) - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_0l_undo_draw.cjs"), str(path), str(grid)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_열기_낡음_0_다시_셈_0(r): - o = r["open"] - assert [x["outcome"] for x in o["runs"]] == ["done"], o["runs"] - assert o["lastKey"] == "" and o["drawnSaved"] - assert o["refresh"] == 0 - assert o["sameObject"] and o["final"] is not None - - -def test_끌기_새_측점(r): - d = r["drag"] - assert [x["sources"] for x in d["runs"]] == [["route"]], d["runs"] - assert not d["sameObject"] # 시험이 갈아끼움 길을 실제로 거침 - assert d["final"] != r["open"]["final"] - - -def test_되돌리기_열기_값_다시_셈_0(r): - u, o = r["undo"], r["open"] - assert [(x["sources"], x["restored"], x["why"]) for x in u["runs"]] == [ - (["route"], True, "saved") - ], u["runs"] - assert u["refresh"] == 0 - assert u["lastKey"] == "" and u["drawnSaved"] and u["drawnLive"] - assert u["sameObject"] # 열 때 객체 그대로(횡단 초안 측점 장부가 이 객체에 묶임 · 349-0q) - assert u["stations"] == o["stations"] - assert u["cross"] == o["cross"] - assert u["final"] == o["final"] - assert u["edits"] == o["edits"] and u["pipeEdits"] == u["edits"] - - -def test_횡단_보기_다녀와도_같은_값(r): - b, o = r["back"], r["open"] - assert b["runs"] == [], b["runs"] - assert b["refresh"] == 0 - assert b["lastKey"] == "" and b["drawnSaved"] - assert b["final"] == o["final"] - assert b["cross"] == o["cross"] - assert r["blocked"] == 0 diff --git a/resources/tester/test_349_0o_redo_same.py b/resources/tester/test_349_0o_redo_same.py deleted file mode 100644 index d3c40c79a..000000000 --- a/resources/tester/test_349_0o_redo_same.py +++ /dev/null @@ -1,198 +0,0 @@ -"""PLAN 349-0o sub7 — 값 한 벌: 되돌림 · 다시 하기 · 횡단 보기가 같은 값. - -· 열기 → 끌기 → 되돌리기 → 다시 하기 → 되돌리기 → 횡단 보기 → 종단 보기 = - 열기 · 끌기 · 열기 · 끌기 · 열기 · 열기 · 열기 값(누가토량 · 횡단 · 측점) -· 저장 상세에 없는 세월교 측점(묶음 측점 ≠ 상세 측점) — 되돌림 = 초안 전 상세 · 묶음 기록에 첫 다른 측점 · 개수 -· 횡단 초안 칸(카드 선택 · 단면 유형) = 열기 값 — 초안 동안 넘김이 버린 칸도 -· 횡단 보기 진입 다시 셈 판정은 진짜(`Design_Sync`) — 되돌린 뒤 다시 셈 0 -· 되돌리기 · 다시 하기 = 화면 꼴(세션 노선 초안 칸 먼저 · 알림 두 번) -짝: `helper_349_0o_redo_same.cjs`(실제 TS 를 Node 로 · 화면 부품 · 서버 창구만 가짜). -""" - -from __future__ import annotations - -import csv -import json -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 -from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 -from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -ROUTE = PROJECT / "B05_Profile/route" -SECTION = PROJECT / "B06_Section" -GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" -ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} - - -def _csv(path: Path) -> list[list[float]]: - with path.open(encoding="utf-8") as handle: - return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - - -def _json(path: Path): - return json.loads(path.read_text(encoding="utf-8")) - - -def _between(sections: list[dict], index: int) -> dict: - """두 측점 사이 한 점(0.37) — 누가거리 · 좌표.""" - a, b = sections[index], sections[index + 1] - k = 0.37 - return { - "chainage_m": round(a["chainage_m"] + k * (b["chainage_m"] - a["chainage_m"]), 2), - "x": a["center_x"] + k * (b["center_x"] - a["center_x"]), - "y": a["center_y"] + k * (b["center_y"] - a["center_y"]), - } - - -@pytest.fixture(scope="module") -def r() -> dict: - if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): - pytest.skip(SKIP_REASON) - curves = _json(ROUTE / "planned_route_curves.json") - sections = sorted( - (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), - key=lambda s: s["chainage_m"], - ) - pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - # 세월교 하나 더(측점 사이) — 저장 상세엔 그 측점이 없음 = 묶음 저장 노선 측점과 다름(검증_sub7 세월교 2 + 1) - pipes = [*pipes, {"source": "manual", "facility": "ford_bridge", **_between(sections, -6)}] - # 저장 횡단 면적 = 서버가 상세를 낼 때 셈(파일엔 없음) — 정본 대신 정한 값 - for i, section in enumerate(sections): - section["design"] = { - "ground_type": "soil", - "design_elevation_m": section["center_z"], - "cut_area_m2": 4.0 + (i % 5), - "fill_area_m2": 1.5 + (i % 3), - } - job = { - "line": _csv(ROUTE / "planned_route.csv"), - "nodes": _csv(ROUTE / "planned_route_nodes.csv"), - "curves": [ - {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] - ], - "stationSnap": bool(curves.get("station_snap")), - "minRadius": curves["min_radius_m"], - "interval": 20.0, - "types": [ - {"type_id": t.type_id, "group": t.group, "placement": t.placement} - for t in structure_type_map().values() - ], - "pipes": pipes, - "standard": STANDARD_CROSS_SECTION, - "conversion": ONES, - # 구조물 하나 — 첫 관 자리 횡단배수(같은 측점을 덮음) - "structures": [ - { - "structure_id": "s-349o", - "type_id": "cross_drain_exposed", - "chainage_m": pipes[0]["chainage_m"], - "x": pipes[0]["x"], - "y": pipes[0]["y"], - } - ], - # 열기 전에 고친 카드 선택 — 끌면 밀리는 측점(뒤쪽) · 단면 유형은 노선이 바뀌면 안 넘기는 칸 - "touched": { - str(sections[-3]["chainage_m"]): { - "ground_type": "blasting_rock", - "section_mode": "right_cut", - } - }, - "detail": { - "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), - "cross_sections": sections, - }, - } - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - grid = Path(folder) / "grid.bin" - grid.write_bytes(dtm_grid_bytes(GRID)) - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_0o_redo_same.cjs"), str(path), str(grid)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def _same(a: dict, b: dict) -> None: - assert a["final"] == b["final"] - assert a["cross"] == b["cross"] - - -def test_열기(r): - o = r["open"] - assert [x["outcome"] for x in o["runs"]] == ["done"], o["runs"] - assert o["lastKey"] == "" and o["drawnSaved"] and o["refresh"] == 0 - assert o["final"] is not None - - -def test_끌기_새_노선(r): - d = r["first"] - assert [x["sources"] for x in d["runs"]] == [["route"]], d["runs"] - assert not d["drawnSaved"] # 묶음 새 노선 상세를 그림 - assert d["final"] != r["open"]["final"] - - -def test_되돌리기_열기_값_측점_다름은_기록(r): - o = r["open"] - for u in (r["back1"], r["back2"]): - assert [(x["sources"], x["restored"], x["why"]) for x in u["runs"]] == [ - (["route"], True, "saved-diff") - ], u["runs"] - assert u["refresh"] == 0 - assert u["lastKey"] == "" and u["drawnSaved"] and u["drawnLive"] - assert u["stations"] == o["stations"] - _same(u, o) - assert u["edits"] == o["edits"] - assert u["cells"] == o["cells"] # 횡단 초안 칸 = 열기 값 - diffs = r["restoreDiffs"] - assert len(diffs) == 2 and diffs[0] == diffs[1] - d = diffs[0] - assert d["pipeline"] == d["saved"] + 1 # 세월교 측점 하나 더 - assert d["pipelineM"] is not None and d["savedM"] is not None - - -def test_초안_상세는_다시_셈이_안_고침(r): - for step in (r["first"], r["redo"]): - assert step["draftRefresh"] == {"updated": [], "same": True} - - -def test_다시_하기_첫_끌기_값(r): - d, f = r["redo"], r["first"] - assert [x["sources"] for x in d["runs"]] == [["route"]], d["runs"] - _same(d, f) - assert d["stations"] == f["stations"] - - -def test_횡단_보기_다녀와도_열기_값(r): - o = r["open"] - for step in (r["cross"], r["back"]): - assert step["runs"] == [], step["runs"] - assert step["refresh"] == 0 # 진입 다시 셈 판정 · crossPreview 모두 안 돎 - assert step["lastKey"] == "" and step["drawnSaved"] - _same(step, o) - assert step["cells"] == o["cells"] - assert r["blocked"] == 0 diff --git a/resources/tester/test_349_0s_draw_work.py b/resources/tester/test_349_0s_draw_work.py deleted file mode 100644 index 90642b0e4..000000000 --- a/resources/tester/test_349_0s_draw_work.py +++ /dev/null @@ -1,156 +0,0 @@ -"""PLAN 349-0s sub7 — 끌기 한 걸음 = 종단도 한 번 그림 · 묶음 기록에 알림 받는 쪽 · 그리기 몫(ms). - -· 꼭짓점 끌기 다섯 걸음 · 되돌리기 — 걸음마다 종단도 그리기 ≤ 2(종단 먼저 · 횡단 · 유토곡선 다음 틀) - (옛 길 = 갈아끼움 · 정책 · 이어 공사 기준 · 비정규 측점 · 새 상세 알림마다 바로 그림) -· runs[].work — 가게 · 새 상세 · 종단도 · 쉼 이름별 ms(알림 받는 쪽 · 그리기 몫) -· 되돌리기 = 열기 값 규칙 그대로(restored) · 그리기 = 버림 그리기 멈춤이 풀린 다음 틀(472-5 덧) -짝: `helper_349_0s_draw_work.cjs`(실제 TS 를 Node 로 · 화면 부품 · 서버 창구만 가짜). -""" - -from __future__ import annotations - -import csv -import json -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 -from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 -from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -ROUTE = PROJECT / "B05_Profile/route" -SECTION = PROJECT / "B06_Section" -GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" -ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} - - -def _csv(path: Path) -> list[list[float]]: - with path.open(encoding="utf-8") as handle: - return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - - -def _json(path: Path): - return json.loads(path.read_text(encoding="utf-8")) - - -def _between(sections: list[dict], index: int) -> dict: - """두 측점 사이 한 점(0.37) — 누가거리 · 좌표.""" - a, b = sections[index], sections[index + 1] - k = 0.37 - return { - "chainage_m": round(a["chainage_m"] + k * (b["chainage_m"] - a["chainage_m"]), 2), - "x": a["center_x"] + k * (b["center_x"] - a["center_x"]), - "y": a["center_y"] + k * (b["center_y"] - a["center_y"]), - } - - -@pytest.fixture(scope="module") -def r() -> dict: - if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): - pytest.skip(SKIP_REASON) - curves = _json(ROUTE / "planned_route_curves.json") - sections = sorted( - (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), - key=lambda s: s["chainage_m"], - ) - pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - # 세월교 하나 더(측점 사이) — 저장 상세엔 그 측점이 없음 = 묶음 저장 노선 측점과 다름(검증_sub7 세월교 2 + 1) - pipes = [*pipes, {"source": "manual", "facility": "ford_bridge", **_between(sections, -6)}] - # 저장 횡단 면적 = 서버가 상세를 낼 때 셈(파일엔 없음) — 정본 대신 정한 값 - for i, section in enumerate(sections): - section["design"] = { - "ground_type": "soil", - "design_elevation_m": section["center_z"], - "cut_area_m2": 4.0 + (i % 5), - "fill_area_m2": 1.5 + (i % 3), - } - job = { - "line": _csv(ROUTE / "planned_route.csv"), - "nodes": _csv(ROUTE / "planned_route_nodes.csv"), - "curves": [ - {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] - ], - "stationSnap": bool(curves.get("station_snap")), - "minRadius": curves["min_radius_m"], - "interval": 20.0, - "types": [ - {"type_id": t.type_id, "group": t.group, "placement": t.placement} - for t in structure_type_map().values() - ], - "pipes": pipes, - "standard": STANDARD_CROSS_SECTION, - "conversion": ONES, - # 구조물 하나 — 첫 관 자리 횡단배수(같은 측점을 덮음) - "structures": [ - { - "structure_id": "s-349o", - "type_id": "cross_drain_exposed", - "chainage_m": pipes[0]["chainage_m"], - "x": pipes[0]["x"], - "y": pipes[0]["y"], - } - ], - # 열기 전에 고친 카드 선택 — 끌면 밀리는 측점(뒤쪽) · 단면 유형은 노선이 바뀌면 안 넘기는 칸 - "touched": { - str(sections[-3]["chainage_m"]): { - "ground_type": "blasting_rock", - "section_mode": "right_cut", - } - }, - "steps": 5, - "detail": { - "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), - "cross_sections": sections, - }, - } - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - grid = Path(folder) / "grid.bin" - grid.write_bytes(dtm_grid_bytes(GRID)) - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_0s_draw_work.cjs"), str(path), str(grid)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_걸음마다_종단도_한두_번(r): - for step in r["steps"]: - assert [x["outcome"] for x in step["runs"]][-1] == "done", step["runs"] - assert 1 <= step["draws"] <= 2, step - - -def test_그리기_몫_기록(r): - done = [x for step in r["steps"] for x in step["runs"] if x["outcome"] == "done"] - for x in done: - assert {"가게", "새 상세 · 종단도", "종단도", "쉼"} <= set(x["work"]), x["work"] - assert x["totalMs"] >= x["stagesMs"] * 0.99 - - -def test_되돌리기_그대로(r): - u = r["undo"] - assert [x["why"] for x in u["runs"]] == ["saved-diff"], u["runs"] # 세월교 하나 더(0o 꼴) - assert 1 <= u["draws"] <= 2, u - assert r["refresh"] == 0 diff --git a/resources/tester/test_349_0u_follow_draft_xy.py b/resources/tester/test_349_0u_follow_draft_xy.py deleted file mode 100644 index 881c6a7ae..000000000 --- a/resources/tester/test_349_0u_follow_draft_xy.py +++ /dev/null @@ -1,204 +0,0 @@ -"""PLAN 349-0u sub7 — 세션 관 · 구조물 초안에 좌표가 없어도 노선 따라 측점이 밀림(349-0d 되살림). - -· 세션 초안 = 정본 PUT 꼴(좌표 뺌) — 앞쪽 꺾임점을 크게 끌면 그 뒤 관 · 구조물 측점이 - 길이 는 만큼 뒤로 -· 되돌리기 = 초안 값 그대로 · 다시하기 = 다시 밂 · 조작 하나 = 묶음 한 번 · 입력 쓰기 막음 0 -짝: `helper_349_0u_follow_draft_xy.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜 · 검증_sub7). -""" - -from __future__ import annotations - -import csv -import json -import math -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 -from common_util.common_util_drainage_pipes import ( # noqa: E402 - PipePoint, - project_pipe_points, -) -from common_util.common_util_route_geometry import RouteVertex # noqa: E402 -from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 -from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -ROUTE = PROJECT / "B05_Profile/route" -SECTION = PROJECT / "B06_Section" -GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" -ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} - - -def _csv(path: Path) -> list[list[float]]: - with path.open(encoding="utf-8") as handle: - return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - - -def _json(path: Path): - return json.loads(path.read_text(encoding="utf-8")) - - -def _structure(sid: str, line: list[list[float]], ratio: float, off: float) -> dict: - """노선 위(off 0) · 옆(off m) 점 구조물 — 저장 노선 누가거리 · 좌표.""" - lengths = [math.dist(a, b) for a, b in zip(line, line[1:])] - at = sum(lengths) * ratio - walked = 0.0 - for (a, b), length in zip(zip(line, line[1:]), lengths): - if walked + length >= at: - t = (at - walked) / length - dx, dy = (b[0] - a[0]) / length, (b[1] - a[1]) / length - x, y = a[0] + dx * length * t - dy * off, a[1] + dy * length * t + dx * off - break - walked += length - return { - "structure_id": sid, - "type_id": "erosion_check", - "placement": "point", - "chainage_m": round(at, 2), - "start_m": None, - "end_m": None, - "x": x, - "y": y, - "options": {}, - } - - -@pytest.fixture(scope="module") -def r() -> dict: - if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): - pytest.skip(SKIP_REASON) - curves = _json(ROUTE / "planned_route_curves.json") - sections = sorted( - (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), - key=lambda s: s["chainage_m"], - ) - pipes = [ # 서버 GET 처럼 id 붙음 - {**pipe, "id": f"p{i}"} - for i, pipe in enumerate( - _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - ) - ] - line = _csv(ROUTE / "planned_route.csv") - structures = [_structure("s-near", line, 0.5, 0.0), _structure("s-far", line, 0.7, 30.0)] - job = { - "line": line, - "structures": structures, - "nodes": _csv(ROUTE / "planned_route_nodes.csv"), - "curves": [ - {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] - ], - "stationSnap": bool(curves.get("station_snap")), - "minRadius": curves["min_radius_m"], - "interval": 20.0, - "types": [ - {"type_id": t.type_id, "group": t.group, "placement": t.placement} - for t in structure_type_map().values() - ], - "pipes": pipes, - "dx": -60.0, - "dy": 0.0, - "standard": STANDARD_CROSS_SECTION, - "conversion": ONES, - "detail": { - "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), - "cross_sections": sections, - }, - } - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - grid = Path(folder) / "grid.bin" - grid.write_bytes(dtm_grid_bytes(GRID)) - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_0u_follow_draft_xy.cjs"), str(path), str(grid)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - ) - assert proc.returncode == 0, proc.stderr - out = json.loads(proc.stdout) - # 끈 꼭짓점(1) 다음 꺾임점 — 그 뒤는 선 모양 그대로 - out["afterM"] = _near_m(line, job["nodes"][2]) - return out - - -def _near_m(line: list[list[float]], point: list[float]) -> float: - """저장 노선 위 그 점에 가장 가까운 꼭짓점의 누가거리.""" - walked, best = 0.0, (math.inf, 0.0) - for i, xy in enumerate(line): - walked += math.dist(line[i - 1], xy) if i else 0.0 - best = min(best, (math.dist(xy, point), walked)) - return best[1] - - -def _moved(before: list, after: list) -> list[float]: - return [b[1] - a[1] for a, b in zip(before, after)] - - -def test_앞쪽_꺾임점_끌기_측점_밀림(r): - d = r["drag"] - assert d["runs"] == [["route"]], d["runs"] - assert d["blocked"] == 0 - grow = d["length"] - r["savedLength"] - assert grow > 5, grow # 앞쪽 길이 늚 - pipes = _moved(r["draftPipes"], d["pipes"]) - assert len(d["pipes"]) == len(r["draftPipes"]) - # 끈 꼭짓점 다음 꺾임점 뒤 관 = 길이 는 만큼(정수 m 맞춤 · 1 m 안) — 관 수 · 자리는 프로젝트마다 - tail = [m for (_, c), m in zip(r["draftPipes"], pipes) if c > r["afterM"]] - assert tail and all(abs(m - grow) <= 1.0 for m in tail), (grow, pipes) - s = _moved(r["draftStructures"], d["structures"]) - assert all(abs(m - grow) <= 1.0 for m in s), (grow, s) - assert len(d["followed"]) == 1 - - -def test_되돌리기_초안_값(r): - for u in (r["undo"], r["undo2"]): - assert u["runs"] == [["route"]], u["runs"] - assert u["pipes"] == r["draftPipes"] - assert u["structures"] == r["draftStructures"] - assert abs(u["length"] - r["savedLength"]) < 1e-6 - assert u["blocked"] == 0 - - -def test_다시하기_다시_밂(r): - d, again = r["drag"], r["redo"] - assert again["runs"] == [["route"]], again["runs"] - assert again["pipes"] == d["pipes"] and again["structures"] == d["structures"] - assert again["blocked"] == 0 - - -def test_열기만_가게_그대로(r): - o = r["open"] - assert o["pipes"] == r["draftPipes"] and o["structures"] == r["draftStructures"] - assert o["followed"] == [] - - -def test_따라감_서버와_같음(r): - """좌표 없는 초안도 안 옮긴 관 = 정본 좌표 — 서버 노선 저장과 같은 측점.""" - line, walked, vertices = r["drag"]["line"], 0.0, [] - for i, (x, y) in enumerate(line): - walked += math.dist(line[i - 1], (x, y)) if i else 0.0 - vertices.append(RouteVertex(x=x, y=y, z=0.0, chainage_m=walked)) - pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - server = project_pipe_points( - [PipePoint(chainage_m=p["chainage_m"], x=p["x"], y=p["y"]) for p in pipes], vertices - ) - assert [c for _, c in r["drag"]["pipes"]] == [p.chainage_m for p in server] diff --git a/resources/tester/test_349_0u_save_rebase.py b/resources/tester/test_349_0u_save_rebase.py deleted file mode 100644 index 9dcaa9287..000000000 --- a/resources/tester/test_349_0u_save_rebase.py +++ /dev/null @@ -1,126 +0,0 @@ -"""PLAN 349-0u 덧 sub7 — 노선을 저장한 [저장]에서 세션 관 초안이 서버 따라감을 덮지 않음. - -· 초안 관이 노선 저장 전 정본 자리 그대로(따라감 전 값) = 서버가 옮긴 자리(옵션 고침은 얹음) -· 사용자가 옮긴 · 새로 놓은 관 = 그대로 · 지운 관 = 없음 · 서버가 새로 놓은 교차 관 = 붙음 -· 노선 저장 없음 = 초안 그대로 · 초안 없음 = 관 GET · PUT 없음 -· 서버 따라감 = 실제 `project_pipe_points`(검증_sub7 관 · 노선 꼭짓점 하나 옮김) -짝: `helper_349_0u_save_rebase.cjs`(실제 `design_save.ts` 를 Node 로 · 서버 창구만 가짜). -""" - -from __future__ import annotations - -import copy -import csv -import json -import math -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from common_util.common_util_drainage_pipes import ( # noqa: E402 - parse_pipe_points, - project_pipe_points, -) -from common_util.common_util_route_geometry import RouteVertex # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -PIPES = PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json" -LINE = PROJECT / "B05_Profile/route/planned_route.csv" - - -def _vertices(line: list[list[float]]) -> list[RouteVertex]: - walked, out = 0.0, [] - for i, (x, y) in enumerate(line): - walked += math.dist(line[i - 1], (x, y)) if i else 0.0 - out.append(RouteVertex(x=x, y=y, z=0.0, chainage_m=walked)) - return out - - -@pytest.fixture(scope="module") -def case() -> dict: - if not (PIPES.is_file() and LINE.is_file()): - pytest.skip(SKIP_REASON) - with LINE.open(encoding="utf-8") as handle: - line = [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - raw = json.loads(PIPES.read_text(encoding="utf-8"))["points"] - if len(raw) < 6: # 시험이 관 여섯 개째(p5)까지 만짐 - pytest.skip("검증 프로젝트 관 6 개 미만") - before = [{**pipe, "id": f"p{i}"} for i, pipe in enumerate(raw)] - # 서버 노선 저장 — 앞쪽 꼭짓점을 서쪽으로 크게 옮긴 새 노선에 좌표 투영 + 새 교차 관 하나 - moved = copy.deepcopy(line) - moved[1] = [moved[1][0] - 60.0, moved[1][1]] - points = project_pipe_points(parse_pipe_points(before), _vertices(moved)) - after = [point.as_dict() for point in points] - after.append({"chainage_m": 30.0, "source": "spacing", "id": "p-new", "x": 1.0, "y": 2.0}) - # 세션 초안 = 정본 PUT 꼴(좌표 뺌 · 옛 노선 누가거리) — p1 옵션 고침 · p3 사용자가 7 m 옮김 · - # p5 지움 · 새 관 하나(id 없음) - draft = [{k: v for k, v in pipe.items() if k not in ("x", "y")} for pipe in before] - draft[1]["options"] = {**draft[1].get("options", {}), "pipe_count": 3} - draft[3]["chainage_m"] += 7.0 - del draft[5] - draft.append({"chainage_m": 333.0, "source": "user"}) - job = {"before": before, "after": after, "draft": draft} - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_0u_save_rebase.cjs"), str(path)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=120, - ) - assert proc.returncode == 0, proc.stderr - return {**job, "out": json.loads(proc.stdout)} - - -def test_노선_저장_뒤_관_차례가_서버_따라감_위에(case): - r = case["out"]["rebased"] - assert r["ok"] and r["calls"] == ["GET", "ROUTE", "GET", "PUT"], r["calls"] - sent = {p.get("id", "user"): p for p in r["sent"][0]} - after = {p["id"]: p for p in case["after"]} - before = {p["id"]: p for p in case["before"]} - assert set(sent) == ({p["id"] for p in case["before"]} - {"p5"}) | {"p-new", "user"} - for pid in sent.keys() - {"p3", "user"}: - assert sent[pid]["chainage_m"] == after[pid]["chainage_m"], pid # 서버 자리 - moved = [p for p in before if p in after and after[p]["chainage_m"] != before[p]["chainage_m"]] - assert moved # 실제로 서버가 옮긴 관이 있음 - assert sent["p1"]["options"]["pipe_count"] == 3 # 사용자 옵션 고침은 얹음 - assert sent["p3"]["chainage_m"] == before["p3"]["chainage_m"] + 7.0 # 사용자가 옮긴 자리 - assert sent["user"]["chainage_m"] == 333.0 - chain = [p["chainage_m"] for p in r["sent"][0]] - assert chain == sorted(chain) - assert r["left"] is None # 내보낸 뒤 초안 비움 - - -def test_초안_없으면_관_안_건드림(case): - r = case["out"]["noDraft"] - assert r["ok"] and r["calls"] == ["ROUTE"], r["calls"] - - -def test_노선_그대로면_초안_그대로(case): - r = case["out"]["noRoute"] - assert r["ok"] and r["calls"] == ["PUT"], r["calls"] - assert r["sent"][0] == case["draft"] - - -def test_안_옮긴_관은_정본_좌표(case): - a, b, c = case["out"]["placed"] - assert (a["x"], a["y"]) == (40.4, 3) # 같은 id · 같은 누가거리 = 정본 좌표(노선 밖이어도) - assert (b["x"], b["y"]) == (60, 0) # 옮긴 관 = 노선 위 그 누가거리 - assert (c["x"], c["y"]) == (70, 0) diff --git a/resources/tester/test_349_pipeline_once.py b/resources/tester/test_349_pipeline_once.py deleted file mode 100644 index 60ab28a1c..000000000 --- a/resources/tester/test_349_pipeline_once.py +++ /dev/null @@ -1,135 +0,0 @@ -"""PLAN 349 sub7 — 조작 하나 = 묶음 한 번 · 열기만 하면 저장 정본 그대로. - -· 열 때 묶음 한 번 · 측점 목록 = 저장 측점(갈아끼움 없음 · 관 누가거리 = 저장값) -· 노선 꼭짓점 한 번 끌기 = 한 번(sources [route]) — 갈아끼운 상세 다시 그림이 처음부터 다시 안 부름 -· 되돌리기 한 번(열 때로) = 한 번 · 관 · 계획고 = 저장값 · 측점 = 저장 측점 -· 다시 열면 같은 값 · 계산 · 알림 안 입력 쓰기 0 -짝: `helper_349_pipeline_once.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜 · 종단 보기 고리 흉내). -""" - -from __future__ import annotations - -import csv -import json -import subprocess -import sys -import tempfile -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from helper_verify_project import ROUTE_SET, SKIP_REASON, find_project # noqa: E402 - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 -from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402 -from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "typescript").is_dir(), - reason="프론트엔드 의존성 미설치", -) - -PROJECT = find_project(*ROUTE_SET) # 지금 있는 검증 프로젝트(읽기만) -ROUTE = PROJECT / "B05_Profile/route" -SECTION = PROJECT / "B06_Section" -GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz" -ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")} - - -def _csv(path: Path) -> list[list[float]]: - with path.open(encoding="utf-8") as handle: - return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)] - - -def _json(path: Path): - return json.loads(path.read_text(encoding="utf-8")) - - -@pytest.fixture(scope="module") -def r() -> dict: - if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): - pytest.skip(SKIP_REASON) - curves = _json(ROUTE / "planned_route_curves.json") - sections = sorted( - (_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")), - key=lambda s: s["chainage_m"], - ) - pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"] - job = { - "line": _csv(ROUTE / "planned_route.csv"), - "nodes": _csv(ROUTE / "planned_route_nodes.csv"), - "curves": [ - {k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"] - ], - "stationSnap": bool(curves.get("station_snap")), - "minRadius": curves["min_radius_m"], - "interval": 20.0, - "types": [ - {"type_id": t.type_id, "group": t.group, "placement": t.placement} - for t in structure_type_map().values() - ], - "pipes": pipes, - "standard": STANDARD_CROSS_SECTION, - "conversion": ONES, - "detail": { - "longitudinal": _json(SECTION / "longitudinal/longitudinal.json"), - "cross_sections": sections, - }, - } - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "job.json" - path.write_text(json.dumps(job), encoding="utf-8") - grid = Path(folder) / "grid.bin" - grid.write_bytes(dtm_grid_bytes(GRID)) - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_349_pipeline_once.cjs"), str(path), str(grid)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - ) - assert proc.returncode == 0, proc.stderr - out = json.loads(proc.stdout) - out["savedPipes"] = [p["chainage_m"] for p in pipes] - return out - - -def _same(a: list[float], b: list[float]) -> bool: - return len(a) == len(b) and all(abs(x - y) < 1e-6 for x, y in zip(a, b)) - - -def test_열_때_한_번_저장_측점_그대로(r): - o = r["open"] - assert [x["outcome"] for x in o["runs"]] == ["done"], o["runs"] - assert o["swaps"] == 0 # 열 때 측점을 가게에 안 냄 — 상세 = 저장 정본 그대로 - # 묶음 측점 ≈ 저장 측점 — 끝점만 저장 노선 csv 자릿수(0.1 mm)만큼 다를 수 있음(그래서 안 냄) - assert len(o["stations"]) == len(o["saved"]) - assert all(abs(x - y) < 0.001 for x, y in zip(o["stations"], o["saved"])) - assert o["pipeChainages"] == r["savedPipes"] - assert o["footprint"] > 0 - - -def test_꼭짓점_끌기_한_번(r): - d = r["drag"] - assert [x["sources"] for x in d["runs"]] == [["route"]], d["runs"] - assert d["swaps"] == 1 # 새 노선 측점으로 갈아끼움은 한 번(고리 아님) - - -def test_되돌리기_한_번_저장값으로(r): - u = r["undo"] - assert [x["sources"] for x in u["runs"]] == [["route"]], u["runs"] - assert u["pipeChainages"] == r["savedPipes"] - assert u["alignmentSaved"] - assert _same(u["stations"], r["open"]["stations"]) - - -def test_다시_열면_같은_값_고리_없음(r): - assert r["reopen"]["runs"] == 1 - assert r["reopen"]["final"] == r["open"]["final"] - assert r["reopen"]["stations"] == r["open"]["stations"] - assert r["blocked"] == 0 diff --git a/resources/tester/test_367_5_profile_fold_hit.py b/resources/tester/test_367_5_profile_fold_hit.py deleted file mode 100644 index 681eccf6d..000000000 --- a/resources/tester/test_367_5_profile_fold_hit.py +++ /dev/null @@ -1,64 +0,0 @@ -"""367-5 종단 판 접은 뒤 횡단 카드 누름 — 실제 브라우저(서버 없이 · 진짜 CSS). - -B06 꼴(가운데 카드 칸 + 아래 종단 판)을 진짜 CSS 로 세우고 판을 펼쳤다 접은 **바로 뒤** -카드 자리 elementFromPoint = 카드. 높이 transition 이 있으면 그 순간 판이 아직 펼친 높이라 -종단 판(`b05-route-profile__*`)이 누름을 받음(그려지지 않는 탭에선 전환이 시작도 안 해 그대로 멈춤). -""" - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -CSS = [ - "ui_template/ui_template_theme.css", - "A00_Common/design/design_page.css", - "B05_Profile/B05_Profile_UI_Style.css", - "B05_Profile/B05_Profile_UI_Style_MassHaul.css", - "B06_Section/B06_Section_UI_Style_Cross.css", -] -HTML = """ -
-
-
-
-
-
-
-
-
-
-
-
-
-
-""" -HIT = "document.elementFromPoint(400, 700).className" - - -def test_fold_releases_card_area_at_once(): - from playwright.sync_api import sync_playwright - - css = "\n".join((ROOT / p).read_text(encoding="utf-8") for p in CSS) - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - page = browser.new_page(viewport={"width": 1000, "height": 900}) - page.set_content(f"{HTML}") - # 펼침 = 판이 카드 자리 아래쪽 600px 차지 - assert page.evaluate(HIT).startswith("b05-route-profile") - # 접은 같은 순간 — 카드가 받음 - hit = page.evaluate( - "(() => { document.querySelector('.b05-route-profile').classList.add('is-collapsed');" - f" return {HIT}; }})()" - ) - assert hit == "b06-cross-card" - # 다시 펼침도 같은 순간 판이 받음(어느 쪽도 전환을 기다리지 않음) - hit = page.evaluate( - "(() => { document.querySelector('.b05-route-profile').classList.remove('is-collapsed');" - f" return {HIT}; }})()" - ) - assert hit.startswith("b05-route-profile") - browser.close() - - -def test_profile_has_no_height_transition(): - css = (ROOT / "B05_Profile/B05_Profile_UI_Style.css").read_text(encoding="utf-8") - assert "transition: height" not in css diff --git a/resources/tester/test_367_6_masshaul_legend_handle.py b/resources/tester/test_367_6_masshaul_legend_handle.py deleted file mode 100644 index f61045c5f..000000000 --- a/resources/tester/test_367_6_masshaul_legend_handle.py +++ /dev/null @@ -1,64 +0,0 @@ -"""367-6 → 392-2 유토곡선 손잡이 · 범례 안 겹침 — 실제 브라우저(서버 없이 · 진짜 CSS). - -10-14 판(392-2) — 유토 손잡이는 판 위 경계 **밖**(bottom = 판 높이 · JS 가 넣음)이라 판 안 -범례(오른쪽 붙임)와 안 겹침 · 접힌 표 손잡이(바닥 간격 = 유토 높이)와 ±44 px 나란히. -판 폭마다 범례 단추 셋 · 두 손잡이 가운데 elementFromPoint = 그 단추. -""" - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -CSS = [ - "ui_template/ui_template_theme.css", - "ui_template/ui_template_overlay.css", - "common_util/common_util_mass_haul.css", - "B05_Profile/B05_Profile_UI_Style_MassHaul.css", -] -BUTTONS = ["도형 위치 초기화", "횡단 기준", "토량 분배"] -HANDLE = """
-
""" -HTML = ( - """ -
-
-
-
- - - -
-
- """ - + HANDLE.replace("{k}", "masshaul").replace("{label}", "유토곡선") - + HANDLE.replace("{k}", "table").replace("{label}", "테이블") - + "\n
" -) -PROBE = """() => [...document.querySelectorAll('button')].map((b) => { - const r = b.getBoundingClientRect(); - const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); - return [b.textContent.trim(), b === hit || b.contains(hit), Math.round(r.top), - Math.round(r.left + r.width / 2), Math.round(b.parentElement.getBoundingClientRect().bottom)]; -})""" -TOP = "Math.round(document.querySelector('.b05-profile__masshaul-overlay').getBoundingClientRect().top)" - - -def test_legend_buttons_clear_of_handle(): - from playwright.sync_api import sync_playwright - - css = "\n".join((ROOT / p).read_text(encoding="utf-8") for p in CSS) - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - page = browser.new_page(viewport={"width": 1300, "height": 600}) - for width in (360, 434, 700, 1162): - page.set_content(f"{HTML.format(w=width)}") - rows = page.evaluate(PROBE) - assert [r[0] for r in rows] == [*BUTTONS, "유토곡선", "테이블"] - assert all(r[1] for r in rows), (width, rows) - mass, table = rows[3], rows[4] - # 손잡이 = 판 위 경계 밖(손잡이 바닥 = 판 위) · 두 손잡이 한 줄 · 가운데 ±44 - assert mass[4] == page.evaluate(TOP), (width, rows) - assert mass[2] == table[2] - assert mass[3] - table[3] == 88 - browser.close() diff --git a/resources/tester/test_505_3_cross_card_screen.py b/resources/tester/test_505_3_cross_card_screen.py deleted file mode 100644 index 34cd6abe4..000000000 --- a/resources/tester/test_505_3_cross_card_screen.py +++ /dev/null @@ -1,253 +0,0 @@ -"""PLAN 505-3 화면 — 자기 포트(5179) Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. - -- 종단 측점선 · 원지반선 겹친 자리 누름 = 측점선이 받음(지반선 누름 통과) → 임시 횡단 창 = B06 카드 + 「⚠ 배관 관경 빈칸」 칩 -- 관경 800(세션만 · [저장] 안 함) → 창 카드에 관 형상 · Ctrl+휠 확대가 창 칸 밖으로 안 넘침 · 밝음 · 어두움 한 장씩 -- 곡선 판 = 도구줄 아래 · 열린 횡단 판 왼쪽(판을 나중에 열어도 비킴) -- 계정 화면 취향(종단 · 횡단 판 열림 · 높이)만 시험 앞 값으로 되돌림 - - ./venv/Scripts/python.exe -m pytest resources/tester/test_505_3_cross_card_screen.py -q -""" - -from __future__ import annotations - -import json -import os -import urllib.request -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -# 기본 = sub4 환경 · 다른 창은 AISLO_505_3_BASE · AISLO_505_3_PROJECT 로 바꿔 test_0 만 돌림(test_1 · 2 는 sub4 자료) -from helper_verify_project import own_ports # noqa: E402 - -BASE = os.environ.get("AISLO_505_3_BASE", own_ports()[1]) # 화면 서버 = OWNERS.md 이 폴더 줄 -PROJECT = os.environ.get("AISLO_505_3_PROJECT", "c725d747-50ae-46b0-87ce-4d4e56a53cf3") # 검증_sub4 -SHOTS = ROOT / "tmp" / "shots_505_3" -PIPE_M = 388 # 검증_sub4 배관 하나(관경 빈칸 · 19+8) -PREFS = "/api/dashboard/me/ui-prefs" -# 이 시험이 건드리는 취향만 되돌림 — 계정을 다른 창도 같이 써서 통째로 덮으면 남의 값이 사라짐 -TOUCHED = ("profile-collapsed", "profile-height", "b05-routeedit-cross-v2") - - -def _up() -> bool: - try: - urllib.request.urlopen(BASE, timeout=3) - return True - except Exception: - return False - - -pytestmark = pytest.mark.skipif(not _up(), reason="서버 없음") - -# 측점 m 을 가로 가운데로 · 그 측점선과 원지반선 교점(화면 좌표) · 그 자리 맨 위 요소 -JS_SCROLL = r"""(m) => { - const marker = [...document.querySelectorAll('.b06-chart__station')] - .find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6); - const body = marker.closest('.b05-route-profile__body'); - body.scrollLeft = Math.max(0, +marker.querySelector('line').getAttribute('x1') - body.clientWidth / 2); -}""" -JS_POINT = r"""(m) => { - const marker = [...document.querySelectorAll('.b06-chart__station')] - .find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6); - const line = marker.querySelector('.b06-chart__station-line'); - const svg = line.ownerSVGElement; - const x = +line.getAttribute('x1'); - const poly = svg.querySelector('.b06-chart__profile'); - const pts = poly.getAttribute('points').trim().split(/\s+/).map((s) => s.split(',').map(Number)); - const k = Math.max(1, pts.findIndex((p) => p[0] >= x)); - const [a, b] = [pts[k - 1], pts[k]]; - const pt = svg.createSVGPoint(); - pt.x = x; - pt.y = a[1] + ((b[1] - a[1]) * (x - a[0])) / (b[0] - a[0] || 1); - const s = pt.matrixTransform(poly.getScreenCTM()); - return { x: s.x, y: s.y, hit: document.elementFromPoint(s.x, s.y)?.getAttribute('class') }; -}""" -# 지도 캔버스에서 교각점 원(--map-route-node 색)을 찾아 화면 좌표로 — 픽셀 고정 자리 대신 요소 · 색으로 -JS_NODES = r"""() => { - const canvas = document.querySelector('.b05-routeedit__canvas'); - const box = canvas.getBoundingClientRect(); - const hex = getComputedStyle(canvas).getPropertyValue('--map-route-node').trim() || '#15803d'; - const want = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); - const sx = canvas.width / box.width; - const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; - const cell = 14, cells = new Map(); - for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) { - const i = (y * canvas.width + x) * 4; - if (Math.abs(data[i] - want[0]) + Math.abs(data[i + 1] - want[1]) + Math.abs(data[i + 2] - want[2]) > 12) continue; - const key = `${Math.floor(x / cell)},${Math.floor(y / cell)}`; - const c = cells.get(key) ?? { n: 0, x: 0, y: 0 }; - c.n++; c.x += x; c.y += y; cells.set(key, c); - } - return [...cells.values()].filter((c) => c.n > 60) - .map((c) => ({ x: box.left + c.x / c.n / sx, y: box.top + c.y / c.n / sx })) - .filter((p) => document.elementFromPoint(p.x, p.y) === canvas); -}""" -JS_MODAL = r"""() => { - const r = document.querySelector('.b05-crossmodal'); - const card = r.querySelector('.b06-cross-card'); - const box = r.querySelector('.b05-crossmodal__box').getBoundingClientRect(); - const svg = card?.querySelector('svg').getBoundingClientRect(); - return { - station: r.querySelector('.b05-crossmodal__station').textContent, - card: !!card, - chips: [...r.querySelectorAll('.b05-cross-chips > *')].map((e) => e.textContent), - pipes: card ? card.querySelectorAll('.b06-chart__culvert-pipe').length : 0, - inside: !!svg && svg.top >= box.top && svg.bottom <= box.bottom + 1 && svg.right <= box.right + 1, - width: Math.round(r.getBoundingClientRect().width), - buttons: [...r.querySelectorAll('.b05-crossmodal__actions button')].map((e) => e.className), - scale: [...(card?.querySelectorAll('svg g[transform]') ?? [])] - .map((g) => g.getAttribute('transform')).find((t) => /^translate\([^)]*\) scale\(/.test(t)) ?? '', - }; -}""" -JS_RECTS = r"""() => { - const q = (s) => [...document.querySelectorAll(s)].find((x) => x.getClientRects().length); - const r = (s) => { const o = q(s)?.getBoundingClientRect(); return o ? [o.left, o.top, o.right, o.bottom] : null; }; - return { label: r('.b05-routeedit__label'), tools: r('.b05-routeedit__tools'), side: r('.b05-routeedit__side-wrap') }; -}""" - - -@pytest.fixture(scope="module") -def page(): - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - ctx = chrome.new_context(viewport={"width": 1600, "height": 1000}) - login = {k: cred[k] for k in ("email", "password")} - assert ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok - prefs = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {}) - tab = ctx.new_page() - tab.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')") - tab.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - tab.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - tab.wait_for_timeout(8000) - SHOTS.mkdir(parents=True, exist_ok=True) - try: - yield tab - finally: - tab.wait_for_timeout(2000) # 취향 올리기(1.5 s 모음) 끝난 뒤 되돌림 - now = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {}) - for key in (f"aislo:pref:{name}" for name in TOUCHED): - if key in prefs: - now[key] = prefs[key] - else: - now.pop(key, None) - assert ctx.request.put(f"{BASE}{PREFS}", data={"prefs": now}).ok - chrome.close() - - -def _profile(page, open_: bool) -> None: - height = page.evaluate( - "() => document.querySelector('.b05-route-profile')?.getBoundingClientRect().height ?? 0" - ) - if (height > 100) != open_: # 접힘 = 높이 1 px(측점 요소는 DOM 에 남음) - page.get_by_text("종단", exact=True).last.click() - page.wait_for_timeout(3000) - - -def _open_modal(page) -> dict: - _profile(page, True) - page.evaluate(JS_SCROLL, PIPE_M) - page.wait_for_timeout(800) - at = page.evaluate(JS_POINT, PIPE_M) - assert "b06-chart__station-line" in (at["hit"] or ""), at # 지반선이 아니라 측점선이 받음 - page.mouse.move(at["x"], at["y"]) - page.wait_for_timeout(300) - page.mouse.click(at["x"], at["y"]) - page.wait_for_function( - "() => document.querySelector('.b05-crossmodal .b06-cross-card')", timeout=30000 - ) - page.wait_for_timeout(1500) - return page.evaluate(JS_MODAL) - - -def test_0_곡선_판_도구줄_횡단판_비킴(page): - _profile(page, False) - rects = page.evaluate(JS_RECTS) - if rects["side"][2] - rects["side"][0] > 100: # 횡단 판 접고 시작 - page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click() - page.wait_for_timeout(2000) - shown = "() => [...document.querySelectorAll('.b05-routeedit__label')].some((x) => !x.hidden)" - nodes = page.evaluate(JS_NODES) - assert nodes, "지도에서 교각점을 못 찾음" - for node in nodes[1:-1] or nodes: # 양끝(시점 · 종점) 말고 곡선 있는 가운데 점 - for _ in range(3): # 첫 누름이 앞 고름(구조물) 풂으로 갈 수 있음 - page.mouse.click(node["x"], node["y"]) - page.wait_for_timeout(1500) - if page.evaluate(shown): - break - if page.evaluate(shown): - break - page.screenshot(path=str(SHOTS / "curve_pick.png")) - assert page.evaluate(shown) - # 지도를 밀어 고른 꺾임점을 도구줄 · 오른쪽 끝 밑으로 - canvas = page.locator(".b05-routeedit__canvas").bounding_box() - tools = page.evaluate(JS_RECTS)["tools"] - goal = (canvas["x"] + canvas["width"] - 60, tools[3] + 40) - page.mouse.move(*(canvas["x"] + canvas["width"] / 2, canvas["y"] + canvas["height"] * 0.65)) - page.mouse.down(button="middle") - page.mouse.move( - canvas["x"] + canvas["width"] / 2 + goal[0] - node["x"], - canvas["y"] + canvas["height"] * 0.65 + goal[1] - node["y"], - steps=16, - ) - page.mouse.up(button="middle") - page.wait_for_timeout(1200) - rects = page.evaluate(JS_RECTS) - assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5 - page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click() - page.wait_for_timeout(2500) - rects = page.evaluate(JS_RECTS) - assert rects["label"][2] <= rects["side"][0] - 8 + 0.5 # 판을 나중에 열어도 비킴 - assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5 - page.screenshot(path=str(SHOTS / "curve_light.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") - page.screenshot(path=str(SHOTS / "curve_dark.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") - page.keyboard.press("Escape") # 꺾임점 고름 풂 - - -def test_1_빈칸_칩_카드(page): - modal = _open_modal(page) - assert modal["station"] == "19+8" and modal["card"] - assert modal["chips"] == ["⚠ 배관 관경 빈칸"] - assert modal["pipes"] == 0 and modal["width"] == 380 - assert all("ui-btn ui-btn--ghost ui-btn--sm" in name for name in modal["buttons"]) - page.locator(".b05-crossmodal").get_by_text("닫기").click() - - -def test_2_횡단_판_칩_관경_넣으면_형상_확대_밝음_어두움(page): - _profile(page, False) - page.locator(".b05-route__irregular-item", has_text="19+8").first.click() - page.wait_for_timeout(3000) - chips = page.eval_on_selector_all( - ".b05-routeedit__cross .b05-cross-chips > *", "(els) => els.map((e) => e.textContent)" - ) - assert "⚠ 배관 관경 빈칸" in chips # 「횡단」 판 = 카드 머리를 걷어도 칩은 그래프 오른쪽 위 - page.locator(".b05-routeedit__cross").first.screenshot(path=str(SHOTS / "panel_chip.png")) - if not page.locator("select:visible").filter(has=page.locator("option[value='800']")).count(): - page.get_by_text("구조물 배치", exact=True).click() - page.wait_for_timeout(1500) - page.locator("select:visible").filter( - has=page.locator("option[value='800']") - ).first.select_option("800") # 세션 초안만 — [저장] 안 누름 - page.wait_for_timeout(5000) - modal = _open_modal(page) - assert modal["chips"] == [] and modal["pipes"] > 0 - shot = page.locator(".b05-crossmodal") - shot.screenshot(path=str(SHOTS / "modal_light.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") - shot.screenshot(path=str(SHOTS / "modal_dark.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") - box = page.locator(".b05-crossmodal__box").bounding_box() - cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 - page.evaluate( - "([x, y]) => document.elementFromPoint(x, y).dispatchEvent(new WheelEvent('wheel'," - " {deltaY: 300, ctrlKey: true, clientX: x, clientY: y, bubbles: true, cancelable: true}))", - [cx, cy], - ) - page.wait_for_timeout(800) - zoomed = page.evaluate(JS_MODAL) - assert "scale(1.15)" in zoomed["scale"] and zoomed["inside"] - page.locator(".b05-crossmodal").get_by_text("닫기").click() diff --git a/resources/tester/test_505_4_refresh_blank_pipe.py b/resources/tester/test_505_4_refresh_blank_pipe.py deleted file mode 100644 index 7f44abdd0..000000000 --- a/resources/tester/test_505_4_refresh_blank_pipe.py +++ /dev/null @@ -1,104 +0,0 @@ -"""PLAN 505-4 — 구조물 고른 채 새로 고침 = 평면 편집기 뜸 · 빈 관경 = 빈 선택지(값 안 채움). - -글 시험 = 소스 순서 · 화면 시험 = 자기 포트(5179) Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. - - ./venv/Scripts/python.exe -m pytest resources/tester/test_505_4_refresh_blank_pipe.py -q -""" - -from __future__ import annotations - -import json -import urllib.request -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -from helper_verify_project import own_ports # noqa: E402 - -BASE = own_ports()[1] # 화면 서버 = OWNERS.md 이 폴더 줄 -PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md) -LIST_URL = f"{BASE}/api/projects/{PROJECT}/route/structures" - - -def _read(*parts: str) -> str: - return ROOT.joinpath(*parts).read_text(encoding="utf-8") - - -def test_횡단_뜸_콜백은_만드는_도중_안_그림() -> None: - edit = _read("B05_Profile", "B05_Profile_UI_RouteEdit.ts") - # createCrossPreview 가 고른 측점을 만들며 곧바로 부름 — draw 는 아래 선언(closed · view)이 선 뒤에 - assert "onShown: () => queueMicrotask(draw)," in edit - assert "onShown: () => draw()" not in edit - assert edit.index("const crossPreview = createCrossPreview(") < edit.index( - "let closed = false;" - ) - - -def test_관경_빈칸은_빈_보기_채움_없음() -> None: - form = _read("A00_Common", "design", "design_structure_form.ts") - assert 'spec?.typeId === "pipe" && c.id === "pipe" && item.id === "diameter"' in form - assert "c.required && !tag && !blankKept(c, item) ? [] : [new Option(blank," in form - fill = form.index("필수 상자 빈 고르기 칸 = 정의 기본값") - assert form.index("if (blankKept(c, item)) continue;", fill) < form.index( - "values[item.id] =", fill - ) - - -def _up() -> bool: - try: - urllib.request.urlopen(BASE, timeout=3) - return True - except Exception: - return False - - -JS_DIAMETER = """() => [...document.querySelectorAll('select')] - .filter((s) => s.offsetParent && [...s.options].some((o) => o.value === '800')) - .map((s) => ({ value: s.value, first: s.options[0].value, count: s.options.length }))""" -JS_CANVAS = """() => { - const c = document.querySelector('.b05-routeedit__canvas'); - const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; - let n = 0; - for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n += 1; - return { visible: !!c.offsetParent, painted: n }; -}""" - - -@pytest.mark.skipif(not _up(), reason="5179 서버 없음") -def test_화면_새로_고침_편집기_뜸_빈_관경_빈_선택지() -> None: - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - try: - ctx = chrome.new_context(viewport={"width": 1600, "height": 1000}) - login = {k: cred[k] for k in ("email", "password")} - assert ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok - tab = ctx.new_page() - errors: list[str] = [] - tab.on("pageerror", lambda e: errors.append(str(e))) - tab.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')") - tab.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - tab.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - tab.wait_for_timeout(8000) - before = ctx.request.get(LIST_URL).text() - # 관경 빈칸 관(19+8) — 빈 선택지 · 값 안 채움 - tab.locator(".b05-route__irregular-item", has_text="19+8").first.click() - tab.wait_for_timeout(3000) - if not tab.evaluate(JS_DIAMETER): - tab.get_by_text("구조물 배치", exact=True).click() - tab.wait_for_timeout(1500) - assert tab.evaluate(JS_DIAMETER) == [{"value": "", "first": "", "count": 5}] - assert ctx.request.get(LIST_URL).text() == before # 열기만 — 저장 값 그대로 - # 고른 채 새로 고침 — 편집기가 뜸(전엔 `closed` 선언 전 씀으로 멈춤) - errors.clear() - tab.reload(wait_until="domcontentloaded") - tab.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - tab.wait_for_timeout(9000) - assert not [e for e in errors if "before initialization" in e], errors - canvas = tab.evaluate(JS_CANVAS) - assert canvas["visible"] and canvas["painted"] > 0 - assert ctx.request.get(LIST_URL).text() == before - finally: - chrome.close() diff --git a/resources/tester/test_506_3_b07_browser.py b/resources/tester/test_506_3_b07_browser.py deleted file mode 100644 index c0c275adb..000000000 --- a/resources/tester/test_506_3_b07_browser.py +++ /dev/null @@ -1,268 +0,0 @@ -# -*- coding: utf-8 -*- -"""506-3 화면 — 자기 포트(5177) Playwright 머리 없는 브라우저 · 검증_sub2 · `AISLO_LIVE_DB=1` 일 때만. - -- B07 표지에 선 하나 → CAD 자동백업 열쇠 = `OPEN_WEB_CAD__RECOVERY__b07:{프로젝트}:cover` -- 특성 판 색 칸 다섯 번 끌기 → 되돌리기 한 번 = 처음 색 -- 미리받기가 도는 중 [저장] → 「저장했습니다」 · 머묾 글 없음 · 저장 동안 새 미리받기 GET 없음 -- [초기화] 물음 글 = 프로젝트 도각 안내(밝음 · 어두움 한 장씩 · tmp/shots_506_3) → [취소] -- B09 [인쇄 미리보기] = PUT /estimation 뒤 #/b09-print · complete 안 부름 -- 단계 행 · B07 · B09 storage 를 먼저 떠 두고 끝에 되돌림 - - AISLO_LIVE_DB=1 ./venv/Scripts/python.exe -m pytest resources/tester/test_506_3_b07_browser.py -q -s -""" - -from __future__ import annotations - -import asyncio -import json -import os -import shutil -import sys -import time -import urllib.request -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) -from helper_verify_project import own_ports # noqa: E402 - -BASE = own_ports()[1] # 화면 서버 = OWNERS.md 이 폴더 줄 -PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) -SHOTS = ROOT / "tmp" / "shots_506_3" -COLS = ( - "state", - "progress_percent", - "params", - "message", - "started_at", - "completed_at", - "updated_at", -) - - -def _up() -> bool: - try: - urllib.request.urlopen(BASE, timeout=3) - return True - except Exception: - return False - - -pytestmark = pytest.mark.skipif( - os.environ.get("AISLO_LIVE_DB") != "1" or not _up(), - reason="AISLO_LIVE_DB=1 · 5177 서버 있을 때만(검증_sub2 storage · 단계 행을 씀)", -) - - -async def _keep(mode: str, held: dict) -> None: - """단계 행 · projects 상태 · B07 · B09 storage 떠 두기(save) · 되돌리기(restore).""" - import aiomysql - - from common_util.common_util_storage import resolve_stored_project_path - from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER - - conn = await aiomysql.connect( - host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, - charset="utf8mb4", autocommit=False, - ) # fmt: skip - try: - async with conn.cursor(aiomysql.DictCursor) as cur: - if mode == "save": - await cur.execute("SELECT storage_path FROM projects WHERE id=%s", (PROJECT,)) - root = Path(resolve_stored_project_path((await cur.fetchone())["storage_path"])) - await cur.execute( - "SELECT * FROM project_workflow_stages WHERE project_id=%s", (PROJECT,) - ) - held["stages"] = await cur.fetchall() - await cur.execute("SELECT status, updated_at FROM projects WHERE id=%s", (PROJECT,)) - held["project"] = await cur.fetchone() - held["copy"] = ROOT / "tmp" / f"pt_506_3_keep_{int(time.time())}" - held["dirs"] = [ - d for d in root.iterdir() if d.is_dir() and d.name.startswith(("B07", "B09")) - ] - for d in held["dirs"]: - shutil.copytree(d, held["copy"] / d.name) - return - for row in held["stages"]: - await cur.execute( - f"UPDATE project_workflow_stages SET {', '.join(f'{k}=%s' for k in COLS)} " - "WHERE id=%s", - (*[row[k] for k in COLS], row["id"]), - ) - project = held["project"] - await cur.execute( - "UPDATE projects SET status=%s, updated_at=%s WHERE id=%s", - (project["status"], project["updated_at"], PROJECT), - ) - await conn.commit() - for d in held["dirs"]: - shutil.rmtree(d) - shutil.copytree(held["copy"] / d.name, d) - finally: - conn.close() - - -@pytest.fixture(scope="module") -def ctx(): - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - held: dict = {} - asyncio.run(_keep("save", held)) - try: - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - context = chrome.new_context(viewport={"width": 1600, "height": 950}) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - context.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')") - yield context - chrome.close() - finally: - asyncio.run(_keep("restore", held)) - - -def _cad(page): - return next(f for f in page.frames if "/b07-cad/" in f.url) - - -def _type(page, *lines: str) -> None: - for line in lines: - page.keyboard.type(line) - page.keyboard.press("Enter") - - -def test_b07_저장_길_화면(ctx): - page = ctx.new_page() - gets: list[tuple[float, str]] = [] - puts: list[tuple[float, float]] = [] - page.on( - "request", - lambda q: ( - q.method == "GET" - and "/design-drawings/" in q.url - and gets.append((time.time(), q.url.rsplit("/", 1)[-1])) - ), - ) - starts: dict = {} - page.on( - "request", - lambda q: q.method == "PUT" and "/confirm" in q.url and starts.update(t=time.time()), - ) - page.on( - "requestfinished", - lambda q: ( - q.method == "PUT" and "/confirm" in q.url and puts.append((starts["t"], time.time())) - ), - ) - page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded") - page.wait_for_selector( - '.b07-drawing-button[data-drawing-id="cover"][data-active="true"]', timeout=90000 - ) - page.wait_for_selector(".cad-host[data-loading='false']", timeout=90000) - cad = _cad(page) - - # 선 하나 — 표지 미저장 · 자동백업 - cad.locator("canvas").first.click(position={"x": 900, "y": 600}) - page.keyboard.press("Escape") - _type(page, "l", "100,100", "400,100") - page.keyboard.press("Escape") - page.keyboard.press("Control+a") - cad.wait_for_timeout(300) - - # 특성 판 색 다섯 번 끌기 → 되돌리기 한 번 - if cad.locator(".cad-inspector[data-collapsed='true']").count(): - cad.locator(".cad-inspector__collapse").click() - cad.locator(".cad-inspector-tabs button", has_text="특성").click() - color = cad.locator(".cad-properties-editor input[type=color]") - color.wait_for(timeout=10000) - first = color.input_value() - for value in ("#110000", "#220000", "#330000", "#440000", "#550000"): - color.evaluate( - "(el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')" - ".set.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); }", - value, - ) - cad.wait_for_timeout(60) - painted = color.input_value() - cad.wait_for_timeout(600) # 멎음 → 한 칸 - cad.locator("canvas").first.click(position={"x": 600, "y": 60}) # 초점 = 캔버스(고름은 다시) - page.keyboard.press("Control+z") - page.keyboard.press("Control+a") - cad.wait_for_timeout(300) - undone = color.input_value() - print(f"색 처음 {first} · 끈 뒤 {painted} · 되돌리기 한 번 {undone}") - assert painted == "#550000" and undone == first - - # 자동백업 열쇠(적재 뒤 5 s 쉼 + 5 s 미룸) - cad.wait_for_timeout(11000) - keys = cad.evaluate( - "Object.keys(localStorage).filter(k => k.startsWith('OPEN_WEB_CAD__RECOVERY'))" - ) - print("자동백업 열쇠", keys) - assert f"OPEN_WEB_CAD__RECOVERY__b07:{PROJECT}:cover" in keys - assert "OPEN_WEB_CAD__RECOVERY__cover" not in keys - - # 미리받기 도는 중 [저장] - before = len(gets) - page.get_by_role("button", name="저장", exact=True).click() - toast = page.wait_for_selector( - ".ui-toast:has-text('저장했습니다'), .ui-toast:has-text('머묾')", timeout=90000 - ).inner_text() - page.wait_for_function( - "() => document.querySelector('.b07-drawing-button[data-drawing-id=cover]')?.dataset.confirmed === 'true'", - timeout=90000, - ) - put_start, put_end = puts[-1] - during = [name for t, name in gets if put_start < t < put_end] - print( - f"[저장] 앞 미리받기 GET {before} · PUT {put_end - put_start:.2f} s · 알림 「{toast}」 · 저장 중 새 GET {during}" - ) - assert before > 1 # 미리받기 도는 중 - assert "머묾" not in toast and "저장했습니다" in toast - assert during == [] - - # [초기화] 물음 글 → [취소] - page.get_by_role("button", name="초기화", exact=True).click() - message = page.wait_for_selector(".ui-confirm__message", timeout=10000) - text = message.inner_text() - print("초기화 물음", text) - assert "프로젝트 도각은 그대로" in text and "[기본 도각 복원]" in text - SHOTS.mkdir(parents=True, exist_ok=True) - panel = page.locator(".ui-confirm__panel") - panel.screenshot(path=str(SHOTS / "reset_light.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") - panel.screenshot(path=str(SHOTS / "reset_dark.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") - page.locator(".ui-confirm__actions button", has_text="취소").click() - page.close() - - -def test_b09_인쇄_미리보기_저장_뒤_이동(ctx): - page = ctx.new_page() - calls: list[str] = [] - page.on( - "request", - lambda q: ( - "/estimation" in q.url - and q.method != "GET" - and calls.append(f"{q.method} {q.url.split('/estimation')[-1] or '/'}") - ), - ) - page.goto(f"{BASE}/#/b09-estimation", wait_until="domcontentloaded") - button = page.wait_for_selector("[data-b09-print]", timeout=90000) - page.wait_for_timeout(6000) # 원가 자료 받기 - user = ctx.request.get(f"{BASE}/api/projects/{PROJECT}/estimation").json().get("user") or {} - # 고친 값 있음 표 — 지금 서버 값 그대로(PUT 이 같은 값을 씀) - page.evaluate( - "([k, v]) => sessionStorage.setItem(k, v)", - [f"aislo:draft:b09-estimation:{PROJECT}", json.dumps(user, ensure_ascii=False)], - ) - button.click() - page.wait_for_url("**/#/b09-print", timeout=60000) - print("B09 [인쇄 미리보기] 요청", calls) - assert calls[:1] == ["PUT /"] - assert not any("complete" in call for call in calls) - page.close() diff --git a/resources/tester/test_508_2_browser.py b/resources/tester/test_508_2_browser.py deleted file mode 100644 index 68cc6b2e2..000000000 --- a/resources/tester/test_508_2_browser.py +++ /dev/null @@ -1,389 +0,0 @@ -"""PLAN 508-2 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. - -① 판 D3 = 서버 · 저장 3D 모형 열쇠를 낡게(머리 hash 만 바꾼 사본 · 끝나면 바이트 되돌림) - → 들어오면 옛 모형이 섬 · [3D 업데이트] 「늦음」 · 3D 창을 열어도 안 지음 - · [3D 업데이트] = /calc corridor.mesh 한 번 → 새 모형 · 늦음 꺼짐 · 한 번 더 = /calc 0(같은 입력) -② 추천 노선 창 — 저장본 있는 갈래 = [재계산] 숨김 · 저장본 없음(409) = 안내 줄 옆 [재계산] - · 제약 칸 바꿈 = 종단 반영 오른쪽 [재계산] · [재계산] · [적용]은 안 누름 -③ 노선 편집 — 교각점을 범위(저장 노선 띠 300 + 여유 300 m) 밖으로 끌어 놓으면 잠잠 뒤 bbox 로 - 잘린 도엽 다시 받음 · 받는 양 · 밝음 · 어두움 한 장씩(`tmp/5082/`) · 측점 스냅 끔은 세션만 -- 저장 안 함(세션 초안만) · 판 = 파일 바이트 되돌림 · 시험 브라우저 표식 시스템 로그 행만 지움 - - ./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_browser.py -q -s -""" - -from __future__ import annotations - -import json -import re -import sys -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(HERE) not in sys.path: - sys.path.insert(0, str(HERE)) - -from helper_verify_project import own_ports # noqa: E402 -from test_505_3_cross_card_screen import JS_NODES # noqa: E402 -from test_511_a_calc_sites_browser import _UP, _started # noqa: E402 - -API, BASE = own_ports() -BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" -PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md) -MARK = "AisloTest-508-2" -SHOTS = ROOT / "tmp" / "5082" - -pytestmark = pytest.mark.skipif( - _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" -) - -JS_CORRIDOR = r"""() => { - const s = window.__corridorScene; - const g = s && s.scene.getObjectByName('corridor'); - if (!g) return null; - g.userData.mark ??= Math.random().toString(36).slice(2); - return { mark: g.userData.mark, children: g.children.length }; -}""" -JS_PENDING = r"""() => { - const b = [...document.querySelectorAll('button')] - .find((x) => /3D 업데이트|짓는 중|갱신 중/.test(x.textContent)); - return b ? { text: b.textContent.trim(), pending: b.classList.contains('is-pending') } : null; -}""" -JS_BUILT = ( - "() => ![...document.querySelectorAll('button')].some((b) => /짓는 중/.test(b.textContent))" -) -JS_IDLE = ( - "() => !/셈하는 중/.test(document.querySelector('.b05-routerec [data-state]').textContent)" -) -JS_SHOWN = ( - "() => /미리 보기/.test(document.querySelector('.b05-routerec [data-state]').textContent)" -) -JS_REC = r"""() => { - const m = document.querySelector('.b05-routerec'); - const r = m?.querySelector('[data-act="rerun"]'); - return m && { - state: m.querySelector('[data-state]').textContent, - shown: !!r && !r.hidden, - beside: !!r && r.previousElementSibling?.matches('[data-state]'), - inRow: !!r && !!r.closest('.b05-routerec__row'), - }; -}""" - - -def _project_root() -> Path: - import asyncio - - from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path - from common_util.common_util_storage import resolve_stored_project_path - from config.config_db import close_db_pool, init_db_pool - - async def run(): - pool = await init_db_pool() - try: - async with pool.acquire() as connection: - return await get_project_storage_relative_path(connection, PROJECT) - finally: - await close_db_pool() - - return Path(resolve_stored_project_path(asyncio.run(run()))) - - -# 플레이라이트 고리 밖에서 한 번(그 안에서는 asyncio.run 못 씀) -PROJECT_ROOT = _project_root() if _UP and _UP == _started(API) else None - - -@pytest.fixture(scope="module") -def ctx(): - sync = pytest.importorskip("playwright.sync_api") - committed = BOARD.read_bytes() - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - SHOTS.mkdir(parents=True, exist_ok=True) - try: - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - try: - yield context - finally: - chrome.close() - finally: - BOARD.write_bytes(committed) - _drop_logs() - - -def _drop_logs() -> None: - import asyncio - - from config.config_db import close_db_pool, init_db_pool - - async def run(): - pool = await init_db_pool() - try: - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) - await connection.commit() - finally: - await close_db_pool() - - asyncio.run(run()) - - -# 계정 화면 취향(서버 user_ui_prefs) — 새 문맥이어도 남음 · 끌기 시험이 건드리는 것 -VIEW_PREFS = (f"aislo:pref:routeedit-view:{PROJECT}", "aislo:pref:profile-collapsed") - - -def _prefs(email: str, values: dict[str, str | None] | None = None) -> dict[str, str | None]: - """`VIEW_PREFS` 값 읽기 · `values` 있으면 그 키만 되씀(None = 지움 · 다른 키 그대로).""" - import asyncio - import concurrent.futures - - from config.config_db import close_db_pool, init_db_pool - - async def run(): - pool = await init_db_pool() - try: - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute("SELECT id FROM users WHERE email = %s", (email,)) - user = (await cursor.fetchone())[0] - for key, value in (values or {}).items(): - path = f'$."{key}"' - if value is None: - sql, args = "JSON_REMOVE(prefs, %s)", (path,) - else: - sql, args = "JSON_SET(prefs, %s, %s)", (path, value) - await cursor.execute( - f"UPDATE user_ui_prefs SET prefs = {sql} WHERE user_id = %s", (*args, user) - ) - await connection.commit() - await cursor.execute("SELECT prefs FROM user_ui_prefs WHERE user_id = %s", (user,)) - row = await cursor.fetchone() - prefs = json.loads(row[0]) if row else {} - return {key: prefs.get(key) for key in VIEW_PREFS} - finally: - await close_db_pool() - - # 플레이라이트 고리 안 — asyncio.run 은 딴 실에서 - with concurrent.futures.ThreadPoolExecutor(1) as pool: - return pool.submit(asyncio.run, run()).result() - - -def _board(corridor: str | None) -> None: - board = json.loads(BOARD.read_text("utf-8")) - board["sites"]["corridor_mesh"] = corridor or "both" - BOARD.write_text(json.dumps(board, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def _open(context, on_response=None): - page = context.new_page() - page.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}');") - if on_response: - page.on("response", on_response) - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - return page - - -def test_3D_서버_낡음_옛_모형_단추로_바꿔_끼움(ctx): - root = PROJECT_ROOT - route_id = max( - int(p.stem.split("_")[1]) - for p in (root / "B05_Profile" / "corridor").glob("corridor_*.json") - ) - stored = root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json" - original = stored.read_bytes() - head = original[:4096] - found = re.search(rb'"hash"\s*:\s*"([0-9a-fA-F]+)"', head) - assert found - stale = original.replace(found.group(0), b'"hash":"0badbeef"', 1) - calls: list[str] = [] - - def on_response(res): - if res.url.endswith("/calc") and res.request.method == "POST": - body = json.loads(res.request.post_data) - calls.append("+".join(body["run"])) - - _board("server") - stored.write_bytes(stale) - try: - page = _open(ctx, on_response) - page.get_by_role("button", name="3D", exact=True).first.click() - page.wait_for_timeout(6000) - entry = {"model": page.evaluate(JS_CORRIDOR), "button": page.evaluate(JS_PENDING)} - opened_calls = list(calls) - page.screenshot(path=str(SHOTS / "d3_stale_entry.png")) - button = page.locator("button", has_text="3D 업데이트").first - button.click() - page.wait_for_timeout(300) - during = page.evaluate(JS_PENDING) - page.wait_for_function(JS_BUILT, timeout=120000) - page.wait_for_timeout(1000) - after = { - "model": page.evaluate(JS_CORRIDOR), - "button": page.evaluate(JS_PENDING), - "source": page.evaluate("() => window.__corridorSource?.source ?? null"), - } - page.screenshot(path=str(SHOTS / "d3_rebuilt.png")) - built_calls = list(calls) - page.locator("button", has_text="3D 업데이트").first.click() - page.wait_for_timeout(4000) - again_calls = list(calls) - page.close() - finally: - stored.write_bytes(original) - _board(None) - print("entry", entry, "during", during, "after", after) - print("calls", opened_calls, built_calls, again_calls) - assert entry["model"] and entry["model"]["children"] > 0 # 옛 모형(빈 3D 아님) - assert entry["button"]["pending"] is True - assert "corridor.mesh" not in opened_calls # 창 열 때 안 지음 - assert during["text"] == "서버에서 짓는 중…" - assert built_calls.count("corridor.mesh") == 1 - assert after["model"]["mark"] != entry["model"]["mark"] # 바꿔 끼움 - assert after["button"]["pending"] is False and after["source"] == "built" - assert again_calls.count("corridor.mesh") == 1 # 같은 입력 = 다시 안 지음 - assert stored.read_bytes() == original - - -def test_추천_창_재계산_409_때만(ctx): - page = _open(ctx) - page.get_by_text("노선 추천", exact=True).first.click() - page.wait_for_timeout(800) - page.locator("button", has_text="노선 추천").last.click() - page.wait_for_selector(".b05-routerec", timeout=30000) - seen = [] - for logic in ("follow", "gradeline", "earthwork", "gentle", "weighted"): - page.click(f'.b05-routerec [data-logic="{logic}"]') - page.wait_for_function(JS_IDLE, timeout=120000) - seen.append({"logic": logic, **page.evaluate(JS_REC)}) - terrain = page.locator('.b05-routerec [data-act="terrain"]') - if terrain.is_visible(): - other = "sheet" if terrain.input_value() == "las" else "las" - terrain.select_option(other) - page.wait_for_function(JS_IDLE, timeout=120000) - seen.append({"logic": f"terrain:{other}", **page.evaluate(JS_REC)}) - print(json.dumps(seen, ensure_ascii=False, indent=1)) - missing = [s for s in seen if "저장본이 없습니다" in s["state"]] - if missing: - page.screenshot(path=str(SHOTS / "rec_409_rerun.png")) - # 제약 칸 바꿈(저장본 있는 갈래로 돌아가서) — 종단 반영 오른쪽 - stored = [s for s in seen if s["state"].startswith("미리 보기")] - if stored: - if terrain.is_visible(): - terrain.select_option("las") # 지형 되돌림 — 마지막 갈래(비중) 라이다 저장본 - else: - page.click(f'.b05-routerec [data-logic="{stored[0]["logic"]}"]') - page.wait_for_function(JS_SHOWN, timeout=120000) - field = page.locator(".b05-routerec__criteria input").first - field.fill("7") - page.wait_for_timeout(300) - dirty = page.evaluate(JS_REC) - page.screenshot(path=str(SHOTS / "rec_dirty_rerun.png")) - page.close() - print(json.dumps(seen, ensure_ascii=False, indent=1)) - for s in seen: - if s["state"].startswith("미리 보기"): - assert s["shown"] is False, s - if "저장본이 없습니다" in s["state"]: - assert s["shown"] and s["beside"] and not s["inRow"], s - assert stored, "저장본 있는 갈래 없음" - assert dirty["shown"] and dirty["inRow"] and not dirty["beside"], dirty - if not missing: - pytest.skip("저장본 없는 갈래 없음 — 409 줄은 단위 시험 몫") - - -def test_노선_크게_끌어_놓기_등고선_다시_받음(ctx): - got: list[dict] = [] - - def on_response(res): - if "/geojson?" in res.url and "scope=route" in res.url: - got.append({"bbox": "bbox=" in res.url, "status": res.status, "bytes": len(res.body())}) - - # 새 문맥 — 앞 시험 페이지가 남긴 보던 화면 · 보관함 없이(축척 · 받기가 늘 같게) - fresh_ctx = ctx.browser.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - # 보던 화면은 계정(서버)에도 남음 — 지난 판 끌기 · 줄임이 쌓여 시작 화면이 매번 달랐음(514-4) - kept = _prefs(cred["email"]) - _prefs(cred["email"], {VIEW_PREFS[0]: None}) - try: - _drag_and_fetch(fresh_ctx, cred, got, on_response) - finally: - fresh_ctx.close() - _prefs(cred["email"], kept) # 계정 화면 취향 되돌림 - - -def _drag_and_fetch(fresh_ctx, cred, got, on_response) -> None: - login = {k: cred[k] for k in ("email", "password")} - assert fresh_ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok - page = _open(fresh_ctx, on_response) - handle = page.locator(".ui-workflow-overlay__handle--bottom", has_text="종단").first - for _ in range(6): # 종단 접음 — 평면 넓게(배치가 늦게 되살아나면 다시 접음) - if handle.bounding_box()["y"] > 900: - break - handle.click() - page.wait_for_timeout(2500) - page.mouse.move(960, 500) - for _ in range(3): # 줄여 봄 — 오른쪽 끝 교각점을 범위 밖까지 끌 자리 - page.mouse.wheel(0, -300) - page.wait_for_timeout(300) - first = [g for g in got if not g["bbox"]] # 앞 시험이 담아 두었으면 보관함 — 0 일 수 있음 - nodes = [] - for _ in range(20): # 접은 뒤 다시 그릴 때까지(사진 한 장 = 그림 한 번 내보냄) - page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) - nodes = page.evaluate(JS_NODES) - if nodes: - break - page.wait_for_timeout(500) - page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) - assert nodes, "교각점 못 찾음" - page.get_by_role("button", name="측점 스냅", exact=True).click() # 끔(세션만) — 큰 끌기 막음 풂 - page.wait_for_timeout(500) - during = None - drops = 0 - for _ in range(4): # 끌어 놓기 · 화면을 왼쪽으로 옮김 — 범위 밖까지 - node = max(nodes, key=lambda n: n["x"]) # 오른쪽 끝 교각점 — 오른쪽 바깥으로 - page.mouse.move(node["x"], node["y"]) - page.mouse.down() - page.mouse.move(node["x"] + 150, node["y"], steps=8) - page.wait_for_timeout(600) - if during is None: - during = sum(g["bbox"] for g in got) # 끄는 동안(잠잠 시간보다 오래 쥠) - page.mouse.move(1590, node["y"], steps=8) - page.mouse.up() - drops += 1 - page.wait_for_timeout(4000) - if any(g["bbox"] for g in got): - # 둘째 도엽(수 MB)은 늦게 옴 — 바쁜 서버(싱크 때 시험 묶음)면 4 초 안에 하나만(514-4) - for _ in range(60): - if sum(g["bbox"] for g in got) >= 2: - break - page.wait_for_timeout(500) - page.wait_for_timeout(1500) # 더 오는 것(다시 받기 두 번)도 잡히게 - break - page.mouse.move(1500, 900) - page.mouse.down(button="middle") - page.mouse.move(900, 900, steps=10) - page.mouse.up(button="middle") - page.wait_for_timeout(800) - page.screenshot(path=str(SHOTS / "sheet_follow_before.png")) - nodes = page.evaluate(JS_NODES) - assert nodes, "옮긴 뒤 교각점 못 찾음" - status = page.locator(".b05-routeedit__status").first.text_content() - after = list(got) - page.screenshot(path=str(SHOTS / "sheet_follow_light.png")) - page.evaluate("() => document.documentElement.setAttribute('data-theme', 'dark')") - page.wait_for_timeout(1500) - page.screenshot(path=str(SHOTS / "sheet_follow_dark.png")) - page.evaluate("() => document.documentElement.setAttribute('data-theme', 'light')") - page.close() - fresh = [g for g in after if g["bbox"]] - print("first", first, "fresh", fresh, "drops", drops, "status", status) - assert during == 0 # 끄는 동안 = 안 받음 - assert len(fresh) == 2 and all(g["bbox"] and g["status"] == 200 for g in fresh) - assert all(g["bytes"] < 5_000_000 for g in fresh) # 통째 사본(36.8 MB) 아님 diff --git a/resources/tester/test_508_3_b07_prefetch_browser.py b/resources/tester/test_508_3_b07_prefetch_browser.py deleted file mode 100644 index e10943b2c..000000000 --- a/resources/tester/test_508_3_b07_prefetch_browser.py +++ /dev/null @@ -1,332 +0,0 @@ -# -*- coding: utf-8 -*- -"""508-3b · c 화면 — 자기 포트(5177) Playwright 머리 없는 브라우저 · 검증_sub2 · `AISLO_LIVE_DB=1` 일 때만. - -- B07 열기 → 미리받기 차례 = 가벼운 것 먼저 · 평면도(무거운 것) 끝 -- B06 으로 가 첫 측점 깎기 비탈 고치고 [저장] → B07 돌아옴 → 지문 바뀐 도면만 다시 받음 - (횡단 = 고친 측점 든 장 하나 · 측점 지문도 그 측점만) · 페이지 옮겨도 받은 것은 그대로 -- 안 받은 평면도(유역도 · 저장본 지움) 열기 → 「받는 중」 → 받은 뒤 보임 -- 평면도 둘째 열기 = 서버 저장본(시간 전 · 후) · GET 이 지우는 파일 없음 -- 못 받은 도면 = 「다시 받기」 → 누르면 다시 받음(밝음 · 어두움 한 장씩 · tmp/shots_508_3) -- 단계 행 · projects · 노선 · 종 · 횡단 행 · B05 노선 · B06 · B07 storage 를 먼저 떠 두고 끝에 되돌림 - - AISLO_LIVE_DB=1 ./venv/Scripts/python.exe -m pytest resources/tester/test_508_3_b07_prefetch_browser.py -q -s -""" - -from __future__ import annotations - -import asyncio -import json -import os -import shutil -import sys -import time -import urllib.request -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) -from helper_verify_project import own_ports # noqa: E402 - -BASE = own_ports()[1] # 화면 서버 = OWNERS.md 이 폴더 줄 -PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) -SHOTS = ROOT / "tmp" / "shots_508_3" -STAGE_COLS = ( - "state", - "progress_percent", - "params", - "message", - "started_at", - "completed_at", - "updated_at", -) -KEEP_DIRS = ("B05_Profile/route", "B06_Section", "B07_DesignDetail") - - -def _up() -> bool: - try: - urllib.request.urlopen(BASE, timeout=3) - return True - except Exception: - return False - - -pytestmark = pytest.mark.skipif( - os.environ.get("AISLO_LIVE_DB") != "1" or not _up(), - reason="AISLO_LIVE_DB=1 · 5177 서버 있을 때만(검증_sub2 storage · DB 행을 씀)", -) - - -async def _keep(mode: str, held: dict) -> None: - """단계 · projects · routes · 종 · 횡단 행 + storage 폴더 떠 두기(save) · 되돌리기(restore).""" - import aiomysql - - from common_util.common_util_storage import resolve_stored_project_path - from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER - - conn = await aiomysql.connect( - host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, - charset="utf8mb4", autocommit=False, - ) # fmt: skip - try: - async with conn.cursor(aiomysql.DictCursor) as cur: - if mode == "save": - await cur.execute("SELECT storage_path FROM projects WHERE id=%s", (PROJECT,)) - held["root"] = Path( - resolve_stored_project_path((await cur.fetchone())["storage_path"]) - ) - for name, sql in ( - ("stages", "SELECT * FROM project_workflow_stages WHERE project_id=%s"), - ("project", "SELECT status, updated_at FROM projects WHERE id=%s"), - ("routes", "SELECT id, status FROM routes WHERE project_id=%s"), - ("long", "SELECT id, data, status FROM longitudinal_sections WHERE project_id=%s"), - ("cross", "SELECT * FROM cross_sections WHERE project_id=%s"), - ): # fmt: skip - await cur.execute(sql, (PROJECT,)) - held[name] = await cur.fetchall() - held["copy"] = ROOT / "tmp" / f"pt_508_3_keep_{int(time.time())}" - for rel in KEEP_DIRS: - shutil.copytree(held["root"] / rel, held["copy"] / rel) - return - for row in held["stages"]: - await cur.execute( - f"UPDATE project_workflow_stages SET {', '.join(f'{k}=%s' for k in STAGE_COLS)} " - "WHERE id=%s", - (*[row[k] for k in STAGE_COLS], row["id"]), - ) - project = held["project"][0] - await cur.execute( - "UPDATE projects SET status=%s, updated_at=%s WHERE id=%s", - (project["status"], project["updated_at"], PROJECT), - ) - for row in held["routes"]: - await cur.execute( - "UPDATE routes SET status=%s WHERE id=%s", (row["status"], row["id"]) - ) - for row in held["long"]: - await cur.execute( - "UPDATE longitudinal_sections SET data=%s, status=%s WHERE id=%s", - (row["data"], row["status"], row["id"]), - ) - await cur.execute("DELETE FROM cross_sections WHERE project_id=%s", (PROJECT,)) - for row in held["cross"]: - cols = list(row) - await cur.execute( - f"INSERT INTO cross_sections ({', '.join(cols)}) " - f"VALUES ({', '.join(['%s'] * len(cols))})", - [row[c] for c in cols], - ) - await conn.commit() - for rel in KEEP_DIRS: - shutil.rmtree(held["root"] / rel) - shutil.copytree(held["copy"] / rel, held["root"] / rel) - finally: - conn.close() - - -@pytest.fixture(scope="module") -def ctx(): - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - held: dict = {} - asyncio.run(_keep("save", held)) - try: - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - context = chrome.new_context(viewport={"width": 1600, "height": 950}) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - context.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')") - context.held = held # type: ignore[attr-defined] - yield context - chrome.close() - finally: - asyncio.run(_keep("restore", held)) - print("되돌림 사본 자리", held.get("copy")) - - -def _list(ctx) -> dict: - body = ctx.request.get(f"{BASE}/api/projects/{PROJECT}/design-drawings", timeout=120000) - return {item["id"]: item for item in body.json()["drawings"]} - - -def _watch(page) -> list[tuple[float, str]]: - gets: list[tuple[float, str]] = [] - page.on( - "requestfinished", - lambda q: ( - q.method == "GET" - and "/design-drawings/" in q.url - and gets.append((time.time(), q.url.rsplit("/", 1)[-1])) - ), - ) - return gets - - -def _settle(page, gets: list, want: set[str], start: int = 0, timeout_s: float = 240) -> None: - """`start` 뒤로 원하는 도면을 다 받고 5 s 동안 새 GET 이 없을 때까지.""" - end = time.time() + timeout_s - while time.time() < end: - got = {name for _t, name in gets[start:]} - quiet = len(gets) == start or time.time() - gets[-1][0] > 5 - if want <= got and quiet: - return - page.wait_for_timeout(500) - raise AssertionError(f"못 받음 {sorted(want - {name for _t, name in gets[start:]})}") - - -def _b07(page) -> None: - page.wait_for_selector(".b07-drawing-button[data-active='true']", timeout=90000) - - -def _open_group(page, kind: str) -> None: - """접힌 목록 묶음을 폄.""" - group = page.locator(f".b07-drawing-group[data-kind='{kind}']") - if "is-collapsed" in (group.get_attribute("class") or ""): - group.locator(".ui-collapsible__title").first.click() - - -def _cut_slope_and_save(page, value: str) -> str: - """B06 첫 횡단 카드 깎기 비탈 → [저장] · 카드 머리 글을 돌려줌.""" - box = page.locator(".b06-design__cutslope-input").first - box.wait_for(timeout=120000) - page.wait_for_timeout(3000) - title = page.locator("article.b06-cross-card").first.inner_text().split("\n")[0] - box.fill(value) - box.press("Enter") - page.wait_for_timeout(800) - with page.expect_response( - lambda r: "/design/save" in r.url and r.request.method == "POST", timeout=180000 - ) as saved: - page.locator("button.ui-btn", has_text="저장").last.click() - assert saved.value.ok, f"B06 저장 실패 {saved.value.status}" - page.wait_for_timeout(2000) - return title - - -def test_b07_미리받기_지문_화면(ctx): - root: Path = ctx.held["root"] - SHOTS.mkdir(parents=True, exist_ok=True) - page = ctx.new_page() - gets = _watch(page) - # 옛 판 자료는 첫 B06 [저장]이 모든 측점 설계에 새 칸을 붙임(한 번만 · 실제 자료 바뀜) — 먼저 한 번 - page.goto(f"{BASE}/#/b06-section", wait_until="domcontentloaded") - _cut_slope_and_save(page, "0.45") - before = _list(ctx) - mark = len(gets) - page.evaluate("location.hash = '#/b07-design-detail'") - _b07(page) - _settle(page, gets, set(before), mark) - order = [name for _t, name in gets[mark:]] - heavy = [i for i, name in enumerate(order) if before[name]["kind"] == "plan"] - light = [i for i, name in enumerate(order) if before[name]["kind"] != "plan"] - print("미리받기 차례", order) - assert len(order) == len(set(order)) == len(before) # 한 장 한 번 - assert max(light) < min(heavy) # 무거운 것 끝 - - # B06 으로 가 측점 하나 고치고 [저장] → B07 로 돌아옴(같은 창 · 페이지 밖 모듈 그대로) - page.evaluate("location.hash = '#/b06-section'") - card = _cut_slope_and_save(page, "0.55") - after = _list(ctx) - changed = {key for key in before if before[key]["fingerprint"] != after[key]["fingerprint"]} - sheets = {key for key in changed if before[key]["kind"] == "cross"} - stations = { - (key, c) - for key in sheets - for c, value in (after[key]["stations"] or {}).items() - if before[key]["stations"].get(c) != value - } - print(f"고친 카드 「{card}」 · 지문 바뀐 도면 {sorted(changed)} · 측점 {sorted(stations)}") - assert len(sheets) == 1 and len(stations) == 1 # 그 장만 · 그 측점만 - mark = len(gets) - page.evaluate("location.hash = '#/b07-design-detail'") - _b07(page) - _settle(page, gets, changed, mark) - again = [name for _t, name in gets[mark:]] - print("돌아와 다시 받음", again) - assert set(again) == changed and len(again) == len(changed) - - # 되돌림(화면 조작으로 바꾼 값 = 원래 값) — 행 · 파일은 끝에 통째로 되돌림 - page.evaluate("location.hash = '#/b06-section'") - _cut_slope_and_save(page, "0.40") - page.close() - - # 평면도 둘째 열기 = 서버 저장본(시간 전 · 후) · GET 이 지우는 파일 없음 - shutil.rmtree(root / "_derived" / "b07_plan_plan_watershed", ignore_errors=True) - shutil.rmtree(root / "_derived" / "b07_plan_plan_terrain", ignore_errors=True) - files = {p for p in root.rglob("*") if p.is_file() and "B05_Profile" not in p.parts} - url = f"{BASE}/api/projects/{PROJECT}/design-drawings/plan_terrain" - spent = [] - for _ in range(2): - start = time.time() - assert ctx.request.get(url, timeout=120000).ok - spent.append(time.time() - start) - print(f"평면도(지형) 첫 열기 {spent[0]:.2f} s → 둘째(저장본) {spent[1]:.2f} s") - assert spent[1] < spent[0] - gone = {p for p in files if not p.exists()} - assert not gone, f"GET 이 지움 {sorted(map(str, gone))[:5]}" - assert list((root / "_derived" / "b07_plan_plan_terrain").glob("*.json")) - - # 안 받은 평면도(유역도 · 저장본 없음) 열기 → 「받는 중」 → 받은 뒤 보임 - page = ctx.new_page() - page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded") - _b07(page) - _open_group(page, "plan") - start = time.time() - page.locator(".b07-drawing-button[data-drawing-id='plan_watershed']").click() - page.wait_for_selector(".ui-toast:has-text('받는 중')", timeout=10000) - page.wait_for_function( - "() => document.querySelector('.b07-drawing-button[data-drawing-id=plan_watershed]')" - "?.dataset.fetch === 'ready' && document.querySelector('.cad-host')?.dataset.loading === 'false'", - timeout=120000, - ) - print(f"유역도 받는 중 → 보임 {time.time() - start:.2f} s") - page.screenshot(path=str(SHOTS / "watershed_light.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") - page.wait_for_timeout(500) - page.screenshot(path=str(SHOTS / "watershed_dark.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") - page.close() - - -def test_b07_다시_받기(ctx): - page = ctx.new_page() - target = "mass_haul" - fails = {"n": 0} - - def fail(route): - fails["n"] += 1 - route.abort() - - page.route(f"**/design-drawings/{target}", fail) - page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded") - _b07(page) - button = page.locator(f".b07-drawing-button[data-drawing-id='{target}']") - page.wait_for_function( - f"() => document.querySelector('.b07-drawing-button[data-drawing-id={target}]')" - "?.dataset.fetch === 'error'", - timeout=120000, - ) - print("미리받기 실패 횟수(한 번 다시 포함)", fails["n"]) - assert fails["n"] == 2 - text = page.evaluate( - f"getComputedStyle(document.querySelector('.b07-drawing-button[data-drawing-id={target}]" - " .b07-drawing-button__name'), '::after').content" - ) - assert "다시 받기" in text - panel = button.locator("xpath=..") - panel.screenshot(path=str(SHOTS / "retry_light.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") - panel.screenshot(path=str(SHOTS / "retry_dark.png")) - page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") - page.unroute(f"**/design-drawings/{target}") - button.click() - page.wait_for_function( - f"() => document.querySelector('.b07-drawing-button[data-drawing-id={target}]')" - "?.dataset.fetch === 'ready' && document.querySelector('.cad-host')?.dataset.loading === 'false'", - timeout=60000, - ) - page.screenshot(path=str(SHOTS / "retry_after.png")) - page.close() diff --git a/resources/tester/test_511_a_calc_sites_browser.py b/resources/tester/test_511_a_calc_sites_browser.py deleted file mode 100644 index 5d8cad785..000000000 --- a/resources/tester/test_511_a_calc_sites_browser.py +++ /dev/null @@ -1,235 +0,0 @@ -"""PLAN 511-A 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub2. - -- 판 기본값(both) = B05 계획고 ▲ · 교각점 끌기 동안 /calc 0 · 브라우저 셈 기록만 -- 묶음 하나씩(quantity · carry · fixed_browser) server → 같은 조작 → /calc 요청 · 답마다 - 그 페이지 TS 로 다시 셈한 값 = 서버 값 · 그 묶음 id 만 감 → 판 기본값으로(파일 바이트 그대로) -- 저장 안 함(세션 초안만 · 창 닫으면 버림) · 시험 브라우저 표식 시스템 로그 행만 지움 -- 시간 표 = `tmp/511a_times.json`(묶음마다 브라우저 ms · 왕복 ms · Node ms · 흉내 0 · 30 · 80 ms) - - ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_sites_browser.py -q -s -""" - -from __future__ import annotations - -import asyncio -import json -import statistics -import sys -import urllib.request -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(HERE) not in sys.path: - sys.path.insert(0, str(HERE)) - -from helper_verify_project import own_ports # noqa: E402 -from test_505_3_cross_card_screen import JS_NODES # noqa: E402 - -API, BASE = own_ports() -BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" -PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) -MARK = "AisloTest-511-A" -TIMES = ROOT / "tmp" / "511a_times.json" -GROUP_IDS = { - "quantity": {"structure.layouts", "cross.berm_spans", "masshaul.series"}, - "carry": {"stations.carry_keyed"}, - "fixed_browser": {"route.handle", "route.station_fit", "stations.swap", "profile.tools"} - | {"cross.fit", "footprint.plan"}, -} - - -def _started(base: str) -> str | None: - try: - with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: - return json.loads(res.read()).get("started_at") - except Exception: - return None - - -_UP = _started(BASE) if BASE else None -pytestmark = pytest.mark.skipif( - _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" -) - -JS_GRADE = r"""async () => { - const url = performance.getEntriesByType('resource').map((e) => e.name) - .find((n) => /design_pipeline_page\.ts/.test(n)); - const m = await import(url); - const pid = localStorage.getItem('frd_current_project_id'); - const pipe = m.pagePipeline(pid); - const cur = pipe.input('alignment') ?? { station_offsets: {}, curve_radii: {} }; - const st = pipe.output('ground').stations; - const key = st[Math.floor(st.length / 2)].chainageM.toFixed(3); - m.pipelineGrade(pid, { ...cur, station_offsets: { ...cur.station_offsets, - [key]: (cur.station_offsets?.[key] ?? 0) + 0.3 } }); -}""" - -# 잡은 /calc 하나를 이 페이지의 같은 TS 로 다시 셈 → 같은지 -JS_MIRROR = r"""async (calls) => { - const urls = performance.getEntriesByType('resource').map((e) => e.name); - const mod = async (re) => { - const u = urls.find((n) => re.test(n)); - if (!u) throw new Error('모듈 없음 ' + re); - return import(u); - }; - const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) => - x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null'); - const where = { - 'structure.layouts': [/B06_Section_Structure_Layouts\.ts/, 'structureAreaRows'], - 'cross.berm_spans': [/common_util_berm_spans\.ts/, 'bermSpansFromStructures'], - 'masshaul.series': [/common_util_mass_haul\.ts/, 'computeMassHaulSeries'], - 'route.handle': [/B05_Profile_UI_RouteEdit_Curve\.ts/, 'dragHandleTo'], - 'route.station_fit': [/B05_Profile_RouteEdit_StationFit\.ts/, 'fitAfter'], - 'stations.swap': [/B06_Section_Station_Swap\.ts/, 'swapCrossSections'], - 'profile.tools': [/B05_Profile_UI_Profile_Straighten\.ts/, 'straightenBetween'], - 'cross.fit': [/B06_Section_UI_Cross_Fit\.ts/, 'toeOffsets'], - 'footprint.plan': [/B05_Profile_Footprint_Stage\.ts/, 'footprintStage'], - 'stations.carry_keyed': [/B06_Section_Route_Carry\.ts/, 'carryKeyedStates'], - }; - const out = []; - for (const { run, inputs, results } of calls) { - for (const id of run) { - const fn = (await mod(where[id][0]))[where[id][1]]; - const args = inputs.args?.[id]; - const local = id === 'structure.layouts' ? fn(inputs.detail.cross_sections) - : id === 'cross.berm_spans' ? fn(inputs.berm.structures) - : id === 'footprint.plan' ? fn({ out: args[0], range: null, prev: null }) - : id === 'stations.carry_keyed' ? fn((n) => args[0][n] ?? {}, args[1], args[2]) - : fn(...args); - const theirs = id === 'structure.layouts' ? results[id].areas - : id === 'cross.berm_spans' ? results[id].spans : results[id]; - out.push({ id, same: JSON.stringify(plain(local)) === JSON.stringify(theirs) }); - } - } - return out; -}""" - - -def _drop_test_logs() -> None: - from config.config_db import close_db_pool, init_db_pool - - async def run(): - pool = await init_db_pool() - try: - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) - await connection.commit() - finally: - await close_db_pool() - - asyncio.run(run()) - - -@pytest.fixture(scope="module") -def ctx(): - sync = pytest.importorskip("playwright.sync_api") - committed = BOARD.read_bytes() - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - try: - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - try: - yield context - finally: - for key in GROUP_IDS: - _put(context, key, "both") - chrome.close() - finally: - BOARD.write_bytes(committed) - _drop_test_logs() - - -def _put(context, key: str, value: str) -> None: - r = context.request.put(f"{BASE}/api/dashboard/admin/calc-sites", data={"sites": {key: value}}) - assert r.ok, r.status - - -def _session(context, delay: int = 0): - page = context.new_page() - page.add_init_script( - f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" - " performance.setResourceTimingBufferSize(20000);" - ) - calls: list[dict] = [] - - def on_response(res): - if res.url.endswith("/calc") and res.request.method == "POST": - calls.append({**json.loads(res.request.post_data), "results": res.json()["results"]}) - - page.on("response", on_response) - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - seed = "{ calls: [], local: {}, delayMs: 0 }" - page.evaluate(f"(d) => {{ (window.__aisloCalcServer ??= {seed}).delayMs = d; }}", delay) - return page, calls - - -def _act(page) -> dict: - page.evaluate(JS_GRADE) # 계획고 ▲ 한 번(가운데 측점 +0.3 m) - page.wait_for_timeout(3500) - nodes = page.evaluate(JS_NODES) - assert len(nodes) >= 3, "지도에서 교각점을 못 찾음" - node = nodes[len(nodes) // 2] - page.mouse.move(node["x"], node["y"]) - page.mouse.down() - page.mouse.move(node["x"] + 14, node["y"] + 9, steps=10) # 교각점 끌기(노선 초안) - page.mouse.up() - page.wait_for_timeout(5000) - return page.evaluate("() => window.__aisloCalcServer") - - -def _median(values: list[float]) -> float | None: - values = [v for v in values if v is not None] - return round(statistics.median(values), 2) if values else None - - -def test_기본값_서버_부름_0(ctx): - page, calls = _session(ctx) - log = _act(page) - page.close() - assert calls == [] and log["calls"] == [] - assert {"structure.layouts", "masshaul.series", "footprint.plan"} <= set(log["local"]) - assert {"route.station_fit", "stations.swap", "stations.carry_keyed"} <= set(log["local"]) - TIMES.write_text(json.dumps({"browser": log["local"]}, ensure_ascii=False), encoding="utf-8") - - -@pytest.mark.parametrize("group", list(GROUP_IDS)) -def test_묶음_서버_요청_값_같음_시간(ctx, group): - _put(ctx, group, "server") - table = json.loads(TIMES.read_text(encoding="utf-8")) if TIMES.exists() else {} - try: - for delay in (0, 30, 80): - page, calls = _session(ctx, delay) - log = _act(page) - mirror = page.evaluate(JS_MIRROR, calls) - page.close() - sent = {id_ for call in calls for id_ in call["run"]} - assert sent and sent <= GROUP_IDS[group], (group, sent) - assert mirror and all(row["same"] for row in mirror), (group, mirror) - assert all(c["same"] is not False for c in log["calls"]), log["calls"] - rows = {} - for call in log["calls"]: - rows.setdefault(call["id"], []).append(call) - table[f"{group}@{delay}"] = { - id_: { - "n": len(group_calls), - "round_ms": _median([c["roundMs"] for c in group_calls]), - "node_ms": _median([c["nodeMs"] for c in group_calls]), - "wall_ms": _median([c["wallMs"] for c in group_calls]), - "kb": round(_median([c["bytes"] for c in group_calls]) / 1024), - } - for id_, group_calls in rows.items() - } - print(group, delay, table[f"{group}@{delay}"]) - finally: - _put(ctx, group, "both") - TIMES.write_text(json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8") - assert json.loads(BOARD.read_text("utf-8"))["sites"][group] == "both" diff --git a/resources/tester/test_511_b1_calc_node_stages.py b/resources/tester/test_511_b1_calc_node_stages.py deleted file mode 100644 index ac3af1462..000000000 --- a/resources/tester/test_511_b1_calc_node_stages.py +++ /dev/null @@ -1,363 +0,0 @@ -"""PLAN 511-B B-1 (요구 320-4) — 묶음 단계 Node 입구 · 판 다섯 줄. - -못박는 것 - - 단계 id 다섯(`stage.route` · `follow` · `stations` · `profile` · `cross`) = TS 표 = - 파이썬 `STAGE_IDS` · `available`(등록부 `node:`) 밖 · 창구(`run_calc_served`)가 받음. - - 판 = 단계 줄(B-2 ~ B-5 가 엶 · 기본 both · 옛 까닭 「짝 모으는 중 · B-n」 = B-6 걷음) · - F9 = 폼 기본값 · - 커밋한 판 그대로. - - 고정 자료(`fixtures/golden_472`) — Node 단계가 저장 노선 한 바퀴를 돎 - (측점 = 저장 상세 측점 0.01 m). - - 화면(검증_sub2 · 이 폴더 서버 · Playwright) — 페이지 묶음 입력 · 저장 기준을 떠서 Node - 단계에 넣으면 단계 출력 = 페이지 단계 출력 0 차 — 노선 초안 둔 채 다시 열기 = 다섯 · - 저장 노선 = 넷(횡단은 페이지가 다시 셈 창구 = 파이썬 저장 설계 · B-4 몫 · 열쇠만 같음). - 저장 안 함 · 세션만. - - ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b1_calc_node_stages.py -q -""" - -from __future__ import annotations - -import asyncio -import json -import re -import subprocess -import sys -import urllib.request -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) -if str(HERE) not in sys.path: - sys.path.insert(0, str(HERE)) - -from helper_verify_project import own_ports # noqa: E402 - -from common_util import common_util_calc_runner as runner # noqa: E402 -from common_util import common_util_calc_sites as calc_sites # noqa: E402 - -FIXTURE = HERE / "fixtures" / "golden_472" -STAGES_TS = ROOT / "A00_Common" / "design" / "logic" / "design_calc_node_stages.ts" -PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) -CAPTURE = ROOT / "tmp" / "511b1_capture.json" -STAGES = ("route", "follow", "stations", "profile", "cross") - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" -) - - -def _fixture(name: str): - return json.loads((FIXTURE / name).read_text(encoding="utf-8")) - - -def _stages(stages: dict) -> dict: - """다섯 단계를 한 요청으로(늘 떠 있는 Node) → 단계 이름 → 출력.""" - answer = runner.run_calc_served([f"stage.{n}" for n in STAGES], {"stages": stages}) - assert answer is not None and "error" not in answer, answer - return {n: answer["results"][f"stage.{n}"] for n in STAGES} - - -def test_단계_id_표_같음_등록부_밖(): - names = re.search(r"STAGE_NAMES = \[([^\]]+)\]", STAGES_TS.read_text(encoding="utf-8")) - ts = {f"stage.{n}" for n in re.findall(r'"(\w+)"', names.group(1))} - assert ts == runner.STAGE_IDS == {f"stage.{n}" for n in STAGES} - out = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": [], "inputs": {}}) - assert set(out["available"]) == runner.NODE_IDS # 단계 id 는 등록부 밖 - with pytest.raises(ValueError, match="stage.ground"): - runner.run_calc_served(["stage.ground"], {}) - bad = runner.run_calc_served(["stage.route"], {}) - assert "단계 입력" in bad["error"] # 입력 없음 = 그 줄 오류(프로세스는 삶) - - -def test_판_다섯_줄_잠금_기본값_그대로(): - by_key = {g["key"]: g for g in calc_sites.GROUPS} - expected = {} - for key, (nos, why) in expected.items(): - group = by_key[key] - assert group["nos"] == nos and group["why"] == why, key - assert group["switchable"] is False and group["fixed"] == "both", key - # cross = B-4 끝에 엶(511-B B-4 · 기본값 both 그대로) - cross = by_key["cross"] - assert cross["nos"] == ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] - assert cross["switchable"] is True and cross["fixed"] is None and cross["why"] == "cross" - assert by_key["form_default"]["nos"] == ["F9"] and by_key["form_default"]["fixed"] == "both" - assert "stages" not in by_key and "structure_pair" not in by_key - assert set(calc_sites.SWITCHABLE) == { - "ground_sample", - "corridor_mesh", - "quantity", - "carry", - "route", # B-5 = 엶(511-B B-5) - "follow", # B-5 = 엶(511-B B-5) - "stations", # B-2 끝 = 엶(511-B B-2) - "profile", # B-3 엶(511-B · test_511_b3_profile_node) - "cross", - "haul", # 511-C E2 엶(기본 server · 판 파일 바뀜 0) - "fixed_browser", - } - committed = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["git", "show", "HEAD:resources/master_template/calc_sites.json"], - cwd=ROOT, - capture_output=True, - check=True, - ).stdout - assert calc_sites.PATH.read_bytes().replace(b"\r\n", b"\n") == committed.replace(b"\r\n", b"\n") - locale = (ROOT / "ui_template" / "ui_template_locale_m1.ts").read_text(encoding="utf-8") - assert "짝 모으는 중" not in locale # B-6 — 단계 줄 모두 엶 · 옛 까닭 글 걷음 - - -def test_페이지_빼기만_같은_맞춤_함수(): - page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8") - assert len(page.splitlines()) <= 693 # 늘리지 않음(B-1 전 693) - assert ( - "placedFollowInputs(ctx, savedLine(), saved)" in page and "contextCross(ctx, status" in page - ) - assert "placed(" not in page and "crossWithContext(" not in page - stages = STAGES_TS.read_text(encoding="utf-8") - assert "placedFollowInputs(ctx" in stages and "contextCross(ctx" in stages - - -def test_고정_자료_Node_단계_저장_노선_한_바퀴(tmp_path): - from common_util.common_util_surface_sampler import dtm_grid_bytes - - grid = tmp_path / "grid.bin" - grid.write_bytes( - dtm_grid_bytes(FIXTURE / "project" / "B04_PreProcess/models/dtm_classification_smooth.npz") - ) - detail = _fixture("detail.json") - stages = { - "plan": _fixture("plan.json"), - "detail": detail, - "types": _fixture("structure_types.json")["types"], - "saved_pipes": [ - {k: v for k, v in p.items() if k != "lonlat"} - for p in _fixture("pipes.json")["pipe_points"] - ], - "saved_structures": _fixture("structures.json")["structures"], - "interval_m": 20, - "grid": {"path": str(grid), "design": "las"}, - "input": {"plusStations": _fixture("plus_stations.json")["chainages"]}, - } - out = _stages(stages) - assert out["route"]["line"] == [[x, y] for x, y in stages["plan"]["planned"]] - # 끝점 = 노선 길이(파이썬 저장 상세는 0.6 mm 다름 · 차례 뒤바뀜 = B-2 몫) → 0.01 m · 차례 없이 - saved = sorted(round(s["chainage_m"], 2) for s in detail["cross_sections"]) - assert [round(s["chainageM"], 2) for s in out["stations"]] == saved - assert len(out["profile"]["pvi"]) >= 2 and out["cross"] == {"byKey": {}} # 횡단 입력 없음 - - -# ── 화면 — 페이지 단계 출력 대 Node 단계 출력(검증_sub2) ────────────────────────────── - -API, BASE = own_ports() - - -def _started(base: str) -> str | None: - try: - with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: - return json.loads(res.read()).get("started_at") - except Exception: - return None - - -_UP = _started(BASE) if BASE else None -screen = pytest.mark.skipif( - _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" -) - -# 페이지 묶음 입력(`inputsNow` 꼴 · 가게 null = 저장분) · 저장 기준 · 다섯 단계 출력을 뜸 -JS_CAPTURE = r"""async () => { - const urls = performance.getEntriesByType('resource').map((e) => e.name); - const mod = async (re) => { - const u = urls.find((n) => re.test(n)); - if (!u) throw new Error('모듈 없음 ' + re); - return import(u); - }; - const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) => - x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null'); - const page = await mod(/design_pipeline_page\.ts/); - const pid = localStorage.getItem('frd_current_project_id'); - const pipe = page.pagePipeline(pid); - await page.reconcilePagePipeline(pid); - await pipe.settled(); - const data = await mod(/design_page_data\.ts/); - const routeId = data.pageRouteId(pid); - const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); - const types = await (await mod(/B05_Profile_Api_Structures\.ts/)).fetchStructureTypes(true); - const list = await data.pageStructures(pid); - const pipes = await data.pagePipes(pid); - const detail = (await mod(/B06_Section_Section_Canon\.ts/)).savedSectionDetail(pid, routeId); - const store = (await mod(/design_store\.ts/)).useDesignStore(pid, null); - const edit = await mod(/B05_Profile_UI_Profile_Edit\.ts/); - const prof = await mod(/B05_Profile_UI_Profile_Data\.ts/); - const plus = await mod(/B05_Profile_Api_PlusStations\.ts/); - const state = await mod(/b_page_state\.ts/); - const route = store.get('routeDraft'); - const savedEdits = edit.readAlignmentDraft(routeId) - ?? prof.readAlignment(detail.longitudinal)?.edits ?? null; - const terrain = await (await fetch(`/api/projects/${pid}/route/terrain-sources`, - { credentials: 'include' })).json(); - return plain({ - routeId, - terrain: { design: terrain.design, grid: terrain.grid }, - stages: { - plan, detail, types, - saved_pipes: (pipes?.pipe_points ?? []).map(({ lonlat: _l, ...p }) => p), - saved_structures: list?.structures ?? [], - saved_edits: savedEdits, - saved_plus: plus.savedPlusRows(pid), - interval_m: 20, - before_draft: route ? (state.readState('route-draft-hold', pid)?.facilities ?? null) : null, - pipe_ids: state.readState('pipe-ids', pid), - input: { - route, - alignment: route ? (edit.readAlignmentDraft(0) || savedEdits) : pipe.input('alignment'), - cross: pipe.input('cross'), - pipes: store.get('pipes'), - structures: store.get('structures'), - plusStations: plus.plusStationsOf(pid), - }, - }, - outputs: Object.fromEntries(['route', 'follow', 'stations', 'profile', 'cross'] - .map((n) => [n, pipe.output(n)])), - runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), - }); -}""" - -# 노선 초안 — 가운데 교각점 하나 옮김(편집기 다리 · 세션 칸 route-draft) -JS_DRAFT = r"""async () => { - const urls = performance.getEntriesByType('resource').map((e) => e.name); - const mod = async (re) => import(urls.find((n) => re.test(n))); - const page = await mod(/design_pipeline_page\.ts/); - const pid = localStorage.getItem('frd_current_project_id'); - const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); - const flat = (await mod(/B05_Profile_UI_RouteEdit_Edits\.ts/)) - .flattenServerPlan(plan.nodes, plan.curves ?? [], plan.node_edits); - const nodes = flat.planned.map(([x, y]) => [x, y]); - const at = Math.floor(nodes.length / 2); - nodes[at] = [nodes[at][0] + 6, nodes[at][1] + 4]; - page.routeDraftBridge(pid, null).edited({ - nodes, - curveOn: flat.curveOn.map((on) => on !== false), - curveRadius: flat.curveRadius.map((r) => r ?? null), - curveLock: flat.curveLock ?? nodes.map(() => null), - curveArc: flat.curveArc ?? nodes.map(() => null), - stationSnap: plan.station_snap ?? false, - stationSnapStep: plan.station_snap_step ?? null, - }); - await page.reconcilePagePipeline(pid); - await page.pagePipeline(pid).settled(); - return at; -}""" - - -def _grid_path(terrain: dict) -> dict | None: - """페이지가 받은 격자와 같은 파일(`read_surface_grid` 고르기) — DB 읽기만.""" - info = terrain.get("grid") - if not info: - return None - from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path - from common_util.common_util_storage import resolve_stored_project_path - from common_util.common_util_surface_sampler import dtm_grid_file - from config.config_db import close_db_pool, init_db_pool - - async def lookup(): - pool = await init_db_pool() - try: - async with pool.acquire() as connection: - stored = await get_project_storage_relative_path(connection, PROJECT) - async with connection.cursor() as cursor: - await cursor.execute( - "SELECT model_file_path FROM surface_models" - " WHERE id = %s AND project_id = %s", - (info["model_id"], PROJECT), - ) - row = await cursor.fetchone() - return stored, row[0] - finally: - await close_db_pool() - - stored, rel = asyncio.run(lookup()) - model = Path(resolve_stored_project_path(stored)) / rel - smooth = model.with_name(f"{model.stem}_smooth.npz") - npz = smooth if info["smooth"] and smooth.is_file() else model - return {"path": str(dtm_grid_file(npz)), "design": terrain.get("design") or "las"} - - -@pytest.fixture(scope="module") -def captured(): - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - shots = {} - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - try: - context = chrome.new_context(viewport={"width": 1600, "height": 1000}) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - page = context.new_page() - page.add_init_script( - f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" - " performance.setResourceTimingBufferSize(20000);" - ) - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - shots["saved"] = page.evaluate(JS_CAPTURE) - page.evaluate(JS_DRAFT) - page.wait_for_timeout(3000) - page.reload(wait_until="domcontentloaded") # 초안 둔 채 다시 열기 = 처음부터 한 바퀴 - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - shots["draft"] = page.evaluate(JS_CAPTURE) - page.close() # 세션 초안은 창과 함께 버림(저장 안 함) - finally: - chrome.close() - CAPTURE.write_text(json.dumps(shots, ensure_ascii=False), encoding="utf-8") - return shots - - -def _diff(a, b, path="") -> str | None: - """첫 다른 자리(0 차 — 수도 정확히 같아야 함).""" - if type(a) is not type(b) and not (isinstance(a, (int, float)) and isinstance(b, (int, float))): - return f"{path}: {type(a).__name__} ≠ {type(b).__name__}" - if isinstance(a, dict): - for k in sorted(set(a) | set(b)): - if k not in a or k not in b: - return f"{path}.{k}: 한쪽만" - hit = _diff(a[k], b[k], f"{path}.{k}") - if hit: - return hit - return None - if isinstance(a, list): - if len(a) != len(b): - return f"{path}: 길이 {len(a)} ≠ {len(b)}" - for i, (x, y) in enumerate(zip(a, b)): - hit = _diff(x, y, f"{path}[{i}]") - if hit: - return hit - return None - return None if a == b else f"{path}: {a!r} ≠ {b!r}" - - -@screen -@pytest.mark.parametrize("case", ["saved", "draft"]) -def test_화면_Node_단계_출력_페이지와_0차(captured, case): - shot = captured[case] - assert shot["runs"] == ["done"], shot["runs"] - assert (shot["stages"]["input"]["route"] is not None) == (case == "draft") - stages = {**shot["stages"], "grid": _grid_path(shot["terrain"])} - out = _stages(stages) - # 저장 노선 횡단 = 페이지는 다시 셈 창구(저장 상세 설계 · 파이썬 짝 · 칸 더 많음) - # — 엔진 대조는 B-4 몫 - names = STAGES if case == "draft" else STAGES[:-1] - for name in names: - hit = _diff(out[name], shot["outputs"][name], name) - assert hit is None, hit - assert len(out["stations"]) > 10 and out["cross"]["byKey"] - assert set(out["cross"]["byKey"]) == set(shot["outputs"]["cross"]["byKey"]) # 측점 열쇠는 같음 diff --git a/resources/tester/test_511_b2_stations_node.py b/resources/tester/test_511_b2_stations_node.py deleted file mode 100644 index 7d461361a..000000000 --- a/resources/tester/test_511_b2_stations_node.py +++ /dev/null @@ -1,321 +0,0 @@ -"""PLAN 511-B B-2 (요구 320-4) — 서버 측점 생성 = 화면과 같은 TS(Node `stations.sections`). - -못박는 것 - - 고정 자료(`fixtures/golden_472`) 노선 · 관 · 구조물 · 추가 측점 — `build_sections` - Node 길 = 파이썬 `generate_sections` 길 0 차(칸 · 차례 · 값 · 측점 열쇠 `key` 만 더함) — - 곡선표 있음 · 없음 · 스냅 10 m · 측점 간격 10 m · 끝 측점 뺌. - - DTM 아닌 지표면 = 파이썬 길(Node 안 부름 · 열쇠 없음). - - 열쇠 = Node `stations.list`(화면 묶음 stations 단계 `buildStations`) 열쇠. - - 창구(`/calc`)는 `stations.sections`(서버 파일 경로 입력)를 안 받음. - - 등록부 R5 · R6 · R7 = node:stations.list · G4 = node:stations.sections(472-4 until 없음). - - 공유 DB · 실제 storage 안 씀(고정 자료 사본). - - ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b2_stations_node.py -q -""" - -from __future__ import annotations - -import sys -from dataclasses import replace -from pathlib import Path - -import numpy as np -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -for path in (ROOT, HERE): - if str(path) not in sys.path: - sys.path.insert(0, str(path)) - -import helper_472_1_paths as paths # noqa: E402 - -from B05_Profile import B05_Profile_Engine_Sections as sections # noqa: E402 -from B05_Profile import B05_Profile_Engine_Sections_Node as node # noqa: E402 -from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions # noqa: E402 -from common_util import common_util_calc_runner as runner # noqa: E402 -from common_util.common_util_surface_sampler import ( # noqa: E402 - CallableSurfaceSampler, - build_surface_sampler, -) - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" -) - - -@pytest.fixture(scope="module") -def project(tmp_path_factory): - root = paths.stage_project(tmp_path_factory.mktemp("b2") / "project") - summary = paths.read("summary.json")["longitudinal"]["data"] - options = SectionGenerationOptions( - **{k: v for k, v in summary["options"].items() if k != "extra_stations"} - ) - polyline = sections._load_route_polyline(root, paths.ROUTE) - pipes = sections._load_pipe_points(root, polyline) - facilities = sections.station_facilities(root, pipes, polyline) - extras = sections.resolve_extra_stations( - root, facilities[0], structures=facilities[1], plus=facilities[2] - ) - return { - "root": root, - "options": options, - "polyline": polyline, - "pipes": pipes, - "facilities": facilities, - "extras": extras, - "sampler": build_surface_sampler(root / "B04_PreProcess/models", *paths.SURFACE), - "curves": sections._load_design_curves(root), - } - - -def _both(project, sampler=None, **change): - kw = { - "options": project["options"], - "grade_options": None, - "source_snapshot": {"filter": "classification"}, - "crs": "EPSG:5186", - "design_curves": project["curves"], - "station_snap": sections.load_station_snap(project["root"]), - "station_snap_step": sections.load_station_snap_step(project["root"]), - **change, - } - args = (project["polyline"], sampler or project["sampler"], project["pipes"], project["extras"]) - python, _ = sections.build_sections(*args, **kw) - noded, _ = sections.build_sections(*args, facilities=project["facilities"], **kw) - return python, noded - - -def _without_key(value): - if isinstance(value, dict): - return {k: _without_key(v) for k, v in value.items() if k != "key"} - if isinstance(value, list): - return [_without_key(v) for v in value] - return value - - -def _first_diff(a, b, path=""): - """첫 다른 자리(값 · 칸 차례) — 큰 글 비교를 pytest 에 안 넘김. 수 = 같은 값(0 ↔ 0.0).""" - if isinstance(a, dict) and isinstance(b, dict): - if list(a) != list(b): - return f"{path}: 칸 {list(a)} != {list(b)}" - return next((d for k in a if (d := _first_diff(a[k], b[k], f"{path}.{k}"))), None) - if isinstance(a, list) and isinstance(b, list): - if len(a) != len(b): - return f"{path}: 길이 {len(a)} != {len(b)}" - return next( - (d for i, (x, y) in enumerate(zip(a, b)) if (d := _first_diff(x, y, f"{path}[{i}]"))), - None, - ) - return None if a == b else f"{path}: {a!r} != {b!r}" - - -def _same(python, noded): - hit = _first_diff(python, _without_key(noded)) # 칸 차례 · 자릿수까지 - assert hit is None, hit - keys = [s.get("key") for s in noded["longitudinal"]["stations"]] - assert all(keys) and keys == [s["key"] for s in noded["cross_sections"]] - return keys - - -@pytest.mark.parametrize( - "case", - ["as_saved", "no_curves", "snap10", "interval10", "no_endpoint"], -) -def test_Node_길_파이썬_길_0차(project, case): - change = { - "as_saved": {}, - "no_curves": {"design_curves": []}, - "snap10": {"station_snap": True, "station_snap_step": 10.0}, - "interval10": {"options": replace(project["options"], station_interval_m=10.0)}, - "no_endpoint": {"options": replace(project["options"], include_endpoint=False)}, - }[case] - python, noded = _both(project, **change) - keys = _same(python, noded) - assert len(keys) == len(set(keys)) > 10 - if case == "as_saved": - assert len(keys) == 57 and keys[0] == "end:BP" and keys[-1] == "end:EP" - kinds = {s["kind"] for s in python["longitudinal"]["stations"]} - assert {"bp", "ep", "regular", "irregular"} <= kinds - assert any(k.startswith("pipe:") for k in keys) - - -def test_열쇠_Node_측점_목록과_같음(project): - _, noded = _both(project) - opts = project["options"] - stations = node.stations_input( - [[p[0], p[1]] for p in project["polyline"]], - *project["facilities"], - opts, - project["curves"], - sections.load_station_snap(project["root"]), - sections.load_station_snap_step(project["root"]), - ) - listed = node.node_stations(stations) - assert [s["key"] for s in listed] == [s["key"] for s in noded["longitudinal"]["stations"]] - assert [s["chainageM"] for s in listed] == [ - s["chainage_m"] for s in noded["longitudinal"]["stations"] - ] - - -def test_DTM_아닌_지표면_파이썬_길(project, monkeypatch): - called = [] - monkeypatch.setattr(runner, "run_calc_served", lambda *a: called.append(a)) - flat = CallableSurfaceSampler(lambda xy: np.full(len(xy), 100.0)) - python, noded = _both(project, sampler=flat) - assert called == [] and python == noded - assert "key" not in noded["longitudinal"]["stations"][0] - - -def test_Node_못_돌림_파이썬_길(project, monkeypatch): - monkeypatch.setattr(runner, "run_calc_served", lambda *a: None) - python, noded = _both(project) - assert python == noded - - -def test_창구는_서버_측점_생성_안_받음(): - from fastapi import FastAPI - from fastapi.testclient import TestClient - - from B06_Section import B06_Section_Router_Calc as calc_router - - app = FastAPI() - app.include_router(calc_router.router) - http = TestClient(app) - url = "/api/projects/11111111-2222-3333-4444-555555555555/calc" - body = {"run": ["stations.sections"], "inputs": {"stations": {"grid": "C:/x"}}} - bad = http.post(url, json=body) - assert bad.status_code == 400 and "창구 밖" in bad.json()["message"] - assert runner.SERVER_ONLY_IDS <= runner.NODE_IDS - - -def test_등록부_측점_줄_Node(): - src = (ROOT / "A00_Common/design/logic/design_calc_registry.ts").read_text(encoding="utf-8") - for no, run in ( - ("R5", "stations.list"), - ("R6", "stations.list"), - ("R7", "stations.list"), - ("G4", "stations.sections"), - ): - row = src.split(f'no: "{no}"')[1].split("\n },")[0] - assert f'runner: "node:{run}"' in row and "until" not in row, no - - -def test_이월_열쇠_Node_출력_없으면_파이썬(project): - from B05_Profile.B05_Profile_Route_Save_Parts import keyed_stations, station_keys - - _, noded = _both(project) - stations = noded["longitudinal"]["stations"] - keyed = keyed_stations(stations) - pipes, structures, _ = project["facilities"] - assert keyed == station_keys(stations, pipes, structures, 20.0) # 고정 자료 = 같은 열쇠 - assert keyed_stations([{k: v for k, v in s.items() if k != "key"} for s in stations]) is None - assert keyed_stations([*stations, {"chainage_m": 1.0}]) is None # 섞이면(구조물 측점 덧) 파이썬 - route_save = (ROOT / "B05_Profile/B05_Profile_Route_Save.py").read_text(encoding="utf-8") - assert route_save.count("keyed_stations(") == 2 - - -def test_창구_측점_목록에_서버_정의_붙임(monkeypatch): - from fastapi import FastAPI - from fastapi.testclient import TestClient - - from B06_Section import B06_Section_Router_Calc as calc_router - - seen = [] - monkeypatch.setattr( - calc_router, "run_calc_served", lambda run, inputs: seen.append(inputs) or {"results": {}} - ) - app = FastAPI() - app.include_router(calc_router.router) - url = "/api/projects/11111111-2222-3333-4444-555555555555/calc" - body = {"run": ["stations.list"], "inputs": {"stations": {"line": [], "definition": {"x": 1}}}} - assert TestClient(app).post(url, json=body).status_code == 200 - definition = seen[0]["stations"]["definition"] - assert definition != {"x": 1} and definition["types"] # 브라우저가 보낸 정의 안 씀 - - -def test_판_stations_줄_열림_기본_both(): - from common_util import common_util_calc_sites as calc_sites - - group = next(g for g in calc_sites.GROUPS if g["key"] == "stations") - assert group["switchable"] and group["fixed"] is None - assert calc_sites.read_sites()["sites"]["stations"] == "both" - page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8") - assert "remote: stationsOnServer" in page - - -# ── 화면 — 계산 자리 stations 「서버」 측점 = 화면 셈 측점(검증_sub3 · Playwright · 저장 안 함) ── - -VERIFY = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" # 검증_sub3(OWNERS.md) - -JS_STATIONS = r"""async () => { - const urls = performance.getEntriesByType('resource').map((e) => e.name); - const page = await import(urls.find((n) => /design_pipeline_page\.ts/.test(n))); - const pid = localStorage.getItem('frd_current_project_id'); - const pipe = page.pagePipeline(pid); - await page.reconcilePagePipeline(pid); - await pipe.settled(); - const calls = (window.__aisloCalcServer?.calls ?? []).filter((c) => c.id === 'stations.list'); - return { stations: JSON.parse(JSON.stringify(pipe.output('stations'))), - calls: calls.map((c) => ({ round: c.roundMs, node: c.nodeMs, bytes: c.bytes })), - runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome) }; -}""" - - -@pytest.fixture(scope="module") -def screens(): - from test_511_b1_calc_node_stages import BASE, JS_DRAFT, _UP, _started - - if not BASE or _UP is None or _UP != _started(BASE.replace("5178", "8005")): - pytest.skip("이 폴더 서버 없음") - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - shots = {} - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - try: - for mode in ("both", "server"): - context = chrome.new_context(viewport={"width": 1600, "height": 1000}) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post( - f"{BASE}/api/auth/login/request", data=login, timeout=60000 - ) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - if mode == "server": # 판은 안 고침 — 이 창이 받는 판만 stations = server - - def board(route): - body = route.fetch().json() - body["sites"]["stations"] = "server" - route.fulfill(json=body) - - context.route("**/api/dashboard/calc-sites", board) - page = context.new_page() - page.add_init_script( - f"localStorage.setItem('frd_current_project_id', '{VERIFY}');" - " performance.setResourceTimingBufferSize(20000);" - ) - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - shots[mode] = {"saved": page.evaluate(JS_STATIONS)} - page.evaluate(JS_DRAFT) # 교각점 하나 옮김(세션 초안 · 창과 함께 버림) - page.wait_for_timeout(3000) - shots[mode]["draft"] = page.evaluate(JS_STATIONS) - context.close() - finally: - chrome.close() - (ROOT / "tmp" / "511b2_screens.json").write_text(json.dumps(shots), encoding="utf-8") - return shots - - -import json # noqa: E402 - - -@pytest.mark.parametrize("case", ["saved", "draft"]) -def test_화면_서버_측점_화면_셈과_같음(screens, case): - both, server = screens["both"][case], screens["server"][case] - assert both["runs"] == server["runs"] == ["done"] - assert len(both["stations"]) > 10 - hit = _first_diff(both["stations"], server["stations"]) - assert hit is None, hit - assert both["calls"] == [] and server["calls"] # server 판만 서버를 부름 diff --git a/resources/tester/test_511_b4_cross_server_browser.py b/resources/tester/test_511_b4_cross_server_browser.py deleted file mode 100644 index b2fa3c5d3..000000000 --- a/resources/tester/test_511_b4_cross_server_browser.py +++ /dev/null @@ -1,128 +0,0 @@ -"""PLAN 511-B B-4 (요구 320-4) — 계산 자리 cross = server 화면 길(검증_sub2 · 이 폴더 서버 · Playwright). - -못박는 것 - - 판 both 와 server(판 응답만 바꿈 · 판 파일 안 씀)에서 페이지 묶음 cross 출력 · 저장 상세 설계(구조물 폐회로 - 면적 층까지)가 0 차 — 저장 노선(`cross.refresh` · 다시 셈 창구) · 노선 초안 둔 채 다시 열기(`stage.cross`). - - server 일 때만 그 id 를 서버에 묻고 · both 면 /calc 0. - - 왕복 시간(`__aisloCalcServer.calls`)을 남김(PLAN 시간 줄). -저장 안 함 · 세션만(노선 초안은 창과 함께 버림). - - ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b4_cross_server_browser.py -q -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -for path in (ROOT, HERE): - if str(path) not in sys.path: - sys.path.insert(0, str(path)) - -from test_511_b1_calc_node_stages import BASE, JS_DRAFT, PROJECT, _diff, screen # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" -) -OUT = ROOT / "tmp" / "511b4_capture.json" - -# 묶음 cross 출력 · 저장 상세 설계(면적 · 구조물 층) · 서버 부름 기록 -JS_STATE = r"""async () => { - const urls = performance.getEntriesByType('resource').map((e) => e.name); - const mod = async (re) => { - const u = urls.find((n) => re.test(n)); - if (!u) throw new Error('모듈 없음 ' + re); - return import(u); - }; - const page = await mod(/design_pipeline_page\.ts/); - const pid = localStorage.getItem('frd_current_project_id'); - const pipe = page.pagePipeline(pid); - await page.reconcilePagePipeline(pid); - await pipe.settled(); - const data = await mod(/design_page_data\.ts/); - const routeId = data.pageRouteId(pid); - const detail = (await mod(/B06_Section_Section_Canon\.ts/)).savedSectionDetail(pid, routeId); - const shown = page.pipelineDetail(pid) ?? detail; - const calls = (window.__aisloCalcServer?.calls ?? []).map((c) => ({ - id: c.id, roundMs: c.roundMs, nodeMs: c.nodeMs, wallMs: c.wallMs, bytes: c.bytes, - })); - return JSON.parse(JSON.stringify({ - cross: pipe.output('cross'), - designs: (shown?.cross_sections ?? []).map((s) => ({ - chainage_m: s.chainage_m, design: s.design, samples: (s.samples ?? []).length, - })), - calls, - runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), - })); -}""" - - -def _open(play, cred: dict, cross: str) -> tuple: - chrome = play.chromium.launch(channel="msedge", headless=True) - context = chrome.new_context(viewport={"width": 1600, "height": 1000}) - login = {k: cred[k] for k in ("email", "password")} - assert context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok - - def board(route): # 판 응답만 바꿈(판 파일 안 씀) - response = route.fetch() - body = response.json() - body["sites"]["cross"] = cross - route.fulfill(response=response, json=body) - - context.route("**/api/dashboard/calc-sites", board) - page = context.new_page() - page.add_init_script( - f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" - " performance.setResourceTimingBufferSize(20000);" - ) - return chrome, page - - -def _shot(page) -> dict: - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - return page.evaluate(JS_STATE) - - -@pytest.fixture(scope="module") -def shots(): - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - out = {} - with sync.sync_playwright() as play: - for cross in ("both", "server"): - chrome, page = _open(play, cred, cross) - try: - out[f"saved_{cross}"] = _shot(page) - page.evaluate(JS_DRAFT) # 노선 초안(가운데 교각점 옮김 · 세션만) - page.wait_for_timeout(3000) - page.reload(wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - out[f"draft_{cross}"] = page.evaluate(JS_STATE) - finally: - chrome.close() # 세션 초안은 창과 함께 버림 - OUT.write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8") - return out - - -@screen -@pytest.mark.parametrize("case", ["saved", "draft"]) -def test_cross_server_화면_값_브라우저와_0차(shots, case): - both, server = shots[f"{case}_both"], shots[f"{case}_server"] - assert both["runs"] == ["done"] and server["runs"] == ["done"], (both["runs"], server["runs"]) - assert both["cross"]["byKey"], "cross 출력 없음" - hit = _diff(server["cross"], both["cross"], "cross") - assert hit is None, hit - hit = _diff(server["designs"], both["designs"], "designs") - assert hit is None, hit - # 부름 — both = /calc 없음 · server = 그 길 id - assert not [c for c in both["calls"] if "cross" in c["id"]] - want = "cross.refresh" if case == "saved" else "stage.cross" - assert any(c["id"] == want for c in server["calls"]), server["calls"] diff --git a/resources/tester/test_511_b5_route_node.py b/resources/tester/test_511_b5_route_node.py deleted file mode 100644 index cbcc3b58a..000000000 --- a/resources/tester/test_511_b5_route_node.py +++ /dev/null @@ -1,519 +0,0 @@ -"""PLAN 511-B B-5 (요구 320-4) — 노선 · 따라감 서버 몫 = 화면과 같은 TS(늘 떠 있는 Node). - -못박는 것 - - 노선 저장 폴리라인 파일(선 csv · 노드 csv · 곡선표 + 편집표) — Node `route.polyline` 길 = - 파이썬 길 바이트 같음(거울 CASES · 위반 글 딱 반 R 12.25 · 12.75 · 겹친 점 · 짧은 칸 · 고정 · - 고정 길이 · 알려진 차 `KNOWN_ARC` = 직각 원호 점 ±1) · - 검증_sub3 저장 노선(노드 · 편집표) 다시 씀 = 저장된 곡선표 · 노드 그대로. - - 저장 좌표 맞춤(F1) — Node `facility.follow` + fill = 파이썬 짝 - `_fill_structure_coordinates_py`(좌표 없음 · 측점 낳음 · 낡은 좌표 · 옛 좌표 지킴 · 자리 바뀜 · - site · 읽을 때 · 노선 밖). - - 남은 측점 계획(F3) — Node `stations.plan` = 파이썬 `plan_stations`(322-3 CASES · 묶음마다). - - 공유 DB 안 씀 · storage 읽기만(검증_sub3 · 없으면 건너뜀) · 쓰기는 tmp. - - ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b5_route_node.py -q -""" - -from __future__ import annotations - -import asyncio -import json -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -for folder in (ROOT, HERE): - if str(folder) not in sys.path: - sys.path.insert(0, str(folder)) - -from B05_Profile import B05_Profile_Route_Polyline_Write as writer # noqa: E402 -from common_util import common_util_route_follow as follow # noqa: E402 - -pytestmark = pytest.mark.skipif( - not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" -) - -SUB3 = ( - ROOT / "storage" / "1" / "3" / "12953f5a-5ba1-42ad-a427-8ac857a58bc2" / "B05_Profile" / "route" -) -FIXTURE = HERE / "fixtures" / "golden_472" -NAMES = ("planned_route.csv", "planned_route_nodes.csv", "planned_route_curves.json") -# 알려진 차(브레인 승인 · 511-B B-5) — 교각 딱 5° 배수(합성 직각)면 원호 칸 수가 libm 끝자리로 갈림 -# (파이썬 89.99999999999992° → 17 칸 · Node 90.0° → 18 칸) · 원호 점 ±1 · 실데이터 저장 노선 0 차 -KNOWN_ARC = { - "직각 한 번 · R 자동", - "R 못박음 30m", - "R 이 접선 자리보다 커서 눌림", - "겹친 점(0.3m) 섞임 — 서버와 같이 버려야 함", - "딱 반 R(위반 글 짝수 쪽)", -} - - -def _files(folder: Path, monkeypatch, node: bool, args: dict) -> tuple[dict, dict]: - folder.mkdir(parents=True, exist_ok=True) - with monkeypatch.context() as patch: - if not node: - patch.setattr(writer, "node_polyline", lambda *a: None) - else: # Node 못 돌리면 파이썬으로 새는 것을 막음 - made = writer.node_polyline - patch.setattr(writer, "node_polyline", lambda *a: made(*a) or pytest.fail("Node 없음")) - summary = writer._write_planned_polyline(folder / NAMES[0], simplify=False, **args) - return summary, {name: (folder / name).read_bytes() for name in NAMES} - - -def _cases() -> list[tuple[str, dict]]: - import test_route_polyline_browser_mirror as mirror - - out = [ - ( - name, - { - "points": points, - "radius_m": mirror.MIN_RADIUS_M, - "curve_flags": flags, - "radii": radii, - }, - ) - for name, points, flags, radii in mirror.CASES - ] - corner = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (200.0, 100.0)] - out += [ - ( - "딱 반 R(위반 글 짝수 쪽)", - { - "points": corner, - "radius_m": 15.0, - "curve_flags": [True] * 4, - "radii": [None, 12.25, 12.75, None], - }, - ), - ( - "겹친 점 · 짧은 칸 · 고정 · 고정 길이", - { - "points": [(0.0, 0.0), (80.0, 0.0), (80.3, 0.1), (120.0, 60.0), (200.0, 60.0)], - "radius_m": 12.0, - "curve_flags": [True, True, True, False], - "radii": [None, 30.0], - "locks": [None, "radius", "arc", "bad", None], - "arcs": [None, None, 20.5, None], - }, - ), - ( - "점 둘", - { - "points": [(0.0, 0.0), (10.0, 0.0)], - "radius_m": 12.0, - "curve_flags": [True, True], - "radii": [None, None], - }, - ), - ] - return out - - -@pytest.mark.parametrize("name,args", _cases(), ids=[name for name, _ in _cases()]) -def test_노선_폴리라인_Node_파이썬_바이트_같음(tmp_path, monkeypatch, name, args): - (py_sum, py), (node_sum, node) = ( - _files(tmp_path / mode, monkeypatch, mode == "node", args) for mode in ("py", "node") - ) - if name in KNOWN_ARC and node[NAMES[0]] != py[NAMES[0]]: - # 알려진 차 — 선 csv 원호 점 수만 ±1 · 노드 · 곡선표 · 편집표는 같음 - assert abs(node_sum["vertices"] - py_sum["vertices"]) == 1, name - node, py = ({k: v for k, v in f.items() if k != NAMES[0]} for f in (node, py)) - node_sum, py_sum = ({**s, "vertices": 0} for s in (node_sum, py_sum)) - assert (node_sum, node) == (py_sum, py), name - - -def test_딱_반_R_위반_글은_파이썬_꼴(tmp_path, monkeypatch): - args = dict(_cases())["딱 반 R(위반 글 짝수 쪽)"] - _, files = _files(tmp_path, monkeypatch, True, args) - text = files[NAMES[2]].decode("utf-8") - assert "최소곡선반지름 미달(12.2 < 15.0m)" in text and "(12.8 < 15.0m)" in text - - -@pytest.mark.skipif(not (SUB3 / "planned_route_nodes.csv").is_file(), reason="검증_sub3 노선 없음") -def test_검증_sub3_저장_노선_다시_씀_같음(tmp_path, monkeypatch): - from common_util.common_util_route_geometry import read_planned_route_csv - - table = json.loads((SUB3 / NAMES[2]).read_text(encoding="utf-8")) - nodes = [(v.x, v.y) for v in read_planned_route_csv(SUB3 / NAMES[1]).vertices] - edits = table["edits"] - args = { - "points": nodes, - "radius_m": table["min_radius_m"], - "curve_flags": [e["curve"] for e in edits], - "radii": [e["radius_m"] for e in edits], - "locks": [e["lock"] for e in edits], - "arcs": [e["arc_m"] for e in edits], - "station_snap": table.get("station_snap"), - "station_snap_step": table.get("station_snap_step"), - } - py = _files(tmp_path / "py", monkeypatch, False, args) - node = _files(tmp_path / "node", monkeypatch, True, args) - assert node == py - again = json.loads(node[1][NAMES[2]]) - assert again["curves"] == table["curves"] and again["edits"] == edits - assert node[1][NAMES[1]] == (SUB3 / NAMES[1]).read_bytes() - - -def _line() -> list[list[float]]: - plan = json.loads((FIXTURE / "plan.json").read_text(encoding="utf-8")) - return plan["planned"] - - -def _items(line: list[list[float]]) -> tuple[list[SimpleNamespace], dict]: - on = follow.LineString(line) - - def at(m: float) -> tuple[float, float]: - p = on.interpolate(m) - return round(p.x, 4), round(p.y, 4) - - def s(sid, chainage, x=None, y=None, placement="point", **span): - return SimpleNamespace( - structure_id=sid, - placement=placement, - chainage_m=chainage, - start_m=span.get("start_m"), - end_m=span.get("end_m"), - x=x, - y=y, - ) - - hold = at(150.0) - items = [ - s("bare", 120.0), - s("holds", 150.0, *hold), - s("stale", 210.0, *at(180.0)), - s("kept", 260.0, 1.0, 2.0), - s("moved", 300.0, 3.0, 4.0), - s("site", 340.0, *at(341.3), placement="site"), - s("span", 380.0, start_m=370.0, end_m=395.0), - s("none", None, 5.0, 6.0), - s("beyond", 99999.0), - s("prev-bare", 420.0), - ] - previous = { - "kept": s("kept", 260.0, 208000.5, 367800.25), - "moved": s("moved", 290.0, 9.0, 9.0), - "prev-bare": s("prev-bare", 420.0, 7.0, 8.0), - } - return items, previous - - -@pytest.mark.parametrize("only_missing", [False, True]) -def test_좌표_맞춤_Node_파이썬_같음(only_missing): - line = _line() - results = [] - for fill in (follow.fill_structure_coordinates, follow._fill_structure_coordinates_py): - items, previous = _items(line) - count = fill(items, line, previous=previous, only_missing=only_missing) - results.append((count, [(item.x, item.y) for item in items])) - assert results[0] == results[1] - assert results[0][0] > 0 - - -def test_좌표_맞춤_Node_길이_실제로_탐(monkeypatch): - called = [] - real = follow.run_calc - monkeypatch.setattr(follow, "run_calc", lambda *a: called.append(a[0]) or real(*a)) - items, previous = _items(_line()) - follow.fill_structure_coordinates(items, _line(), previous=previous) - assert called == [["facility.follow"]] - - -def test_측점_계획_Node_파이썬_같음(): - import test_322_3_plan_mirror as plan_mirror - - from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations - from B05_Profile.B05_Profile_Structures_Schema import StructureInstance - from common_util.common_util_drainage_pipes import parse_pipe_points - from common_util.common_util_structure_station import plan_stations - - groups = [ - ( - parse_pipe_points(case["pipes"]), - [StructureInstance.model_validate(s) for s in case["structures"]], - list(case.get("plus", ())), - ) - for case in plan_mirror.CASES.values() - ] - node = node_plan_stations(groups) - assert node is not None - assert node == [plan_stations(*group) for group in groups] - - -# ── 화면 — 계산 자리 route 「서버」 노선 = 화면 셈 노선(검증_sub3 · Playwright · 저장 안 함) ── - -VERIFY = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" # 검증_sub3(OWNERS.md) - -JS_ROUTE = r"""async () => { - const urls = performance.getEntriesByType('resource').map((e) => e.name); - const page = await import(urls.find((n) => /design_pipeline_page\.ts/.test(n))); - const pid = localStorage.getItem('frd_current_project_id'); - const pipe = page.pagePipeline(pid); - await page.reconcilePagePipeline(pid); - await pipe.settled(); - const calls = (window.__aisloCalcServer?.calls ?? []).filter((c) => c.id === 'route.polyline'); - const plain = (v) => JSON.parse(JSON.stringify(v ?? null)); - return { route: plain(pipe.output('route')), stations: plain(pipe.output('stations')), - calls: calls.map((c) => ({ round: c.roundMs, node: c.nodeMs, same: c.same })), - runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome) }; -}""" - - -@pytest.fixture(scope="module") -def screens(): - from test_511_b1_calc_node_stages import _UP, BASE, JS_DRAFT, _started - - if not BASE or _UP is None or _UP != _started(BASE.replace("5178", "8005")): - pytest.skip("이 폴더 서버 없음") - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - shots = {} - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - try: - for mode in ("both", "server"): - context = chrome.new_context(viewport={"width": 1600, "height": 1000}) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post( - f"{BASE}/api/auth/login/request", data=login, timeout=60000 - ) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - if mode == "server": # 판은 안 고침 — 이 창이 받는 판만 route = server - - def board(route): - body = route.fetch().json() - body["sites"]["route"] = "server" - route.fulfill(json=body) - - context.route("**/api/dashboard/calc-sites", board) - page = context.new_page() - page.add_init_script( - f"localStorage.setItem('frd_current_project_id', '{VERIFY}');" - " performance.setResourceTimingBufferSize(20000);" - ) - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - shots[mode] = {"saved": page.evaluate(JS_ROUTE)} - page.evaluate(JS_DRAFT) # 교각점 하나 옮김(세션 초안 · 창과 함께 버림) - page.wait_for_timeout(3000) - shots[mode]["draft"] = page.evaluate(JS_ROUTE) - context.close() - finally: - chrome.close() - (ROOT / "tmp" / "511b5_screens.json").write_text(json.dumps(shots), encoding="utf-8") - return shots - - -@pytest.mark.parametrize("case", ["saved", "draft"]) -def test_화면_서버_노선_화면_셈과_같음(screens, case): - both, server = screens["both"][case], screens["server"][case] - assert both["runs"] == server["runs"] == ["done"] - assert len(both["route"]["line"]) > 10 - assert both["route"] == server["route"] and both["stations"] == server["stations"] - assert both["calls"] == [] - # 저장 노선(초안 없음) = 서버 안 부름 · 초안 = server 판만 서버 노선 - assert bool(server["calls"]) is (case == "draft") - - -# ── follow 「서버」 — 창구 맥락 · Node route · follow 두 단계 ───────────────────────── - - -def test_창구_follow_맥락_브라우저_정본칸_버림(monkeypatch): - from B06_Section import B06_Section_Router_Calc_Context as ctx - - async def context(_pid): - return {"plan": {"planned": "서버"}, "saved_pipes": ["서버"], "longitudinal": None} - - monkeypatch.setattr(ctx, "follow_context", context) - inputs = { - "stages": { - "until": "follow", - "input": {"route": 1}, - "saved_edits": None, - "plan": {"planned": "브라우저"}, - "saved_pipes": ["브라우저"], - "grid": {"path": "C:/x"}, - "detail": {"x": 1}, - } - } - assert asyncio.run(ctx.attach_follow_context("p", inputs)) is None - assert inputs["stages"] == { - "until": "follow", - "input": {"route": 1}, - "saved_edits": None, - "plan": {"planned": "서버"}, - "saved_pipes": ["서버"], - "longitudinal": None, - } - other = {"stages": {"plan": "브라우저"}} # until 없음 = 그대로(B-1 단계 길) - assert asyncio.run(ctx.attach_follow_context("p", other)) is None and other["stages"] == { - "plan": "브라우저" - } - - -def _golden_stages() -> dict: - from common_util.common_util_surface_sampler import dtm_grid_bytes - - def fixture(name): - return json.loads((FIXTURE / name).read_text(encoding="utf-8")) - - plan = fixture("plan.json") - nodes = [[n["x"], n["y"]] for n in plan["nodes"]] - at = len(nodes) // 2 - nodes[at] = [nodes[at][0] + 6, nodes[at][1] + 4] # 교각점 하나 옮긴 노선 초안 - draft = { - "nodes": nodes, - "curveOn": [n["radius_m"] is not None for n in plan["nodes"]], - "curveRadius": [None] * len(nodes), - "curveLock": [None] * len(nodes), - "curveArc": [None] * len(nodes), - "stationSnap": bool(plan.get("station_snap")), - "stationSnapStep": plan.get("station_snap_step"), - } - grid = ROOT / "tmp" / "511b5_grid.bin" - if not grid.is_file(): - grid.write_bytes( - dtm_grid_bytes(FIXTURE / "project/B04_PreProcess/models/dtm_classification_smooth.npz") - ) - return { - "plan": plan, - "detail": fixture("detail.json"), - "types": fixture("structure_types.json")["types"], - "saved_pipes": [ - {k: v for k, v in p.items() if k != "lonlat"} - for p in fixture("pipes.json")["pipe_points"] - ], - "saved_structures": fixture("structures.json")["structures"], - "interval_m": 20, - "grid": {"path": str(grid), "design": "las"}, - "input": {"route": draft, "plusStations": fixture("plus_stations.json")["chainages"]}, - } - - -def test_Node_follow_두_단계_묶음_첫_바퀴와_같음(): - from common_util import common_util_calc_runner as runner - - stages = _golden_stages() - full = runner.run_calc_served(["stage.route", "stage.follow"], {"stages": stages}) - only = runner.run_calc_served( - ["stage.route", "stage.follow"], {"stages": {**stages, "until": "follow", "grid": None}} - ) - assert full and only and "error" not in full and "error" not in only, (full, only) - assert only["results"]["stage.route"] == full["results"]["stage.route"] - got = only["results"]["stage.follow"] - assert got["patch"] == full["results"]["stage.follow"] - assert got["patch"]["pipes"] and got["followed"] # 관 따라감 · 따라가기만 한 자리 곁 자료 - - -# ── 화면 — follow 「서버」 끌기 여러 걸음 뒤 놓음 = 화면 셈(검증_sub3 · Playwright · 저장 안 함) ── - -JS_DRAG = r"""async (steps) => { - const urls = performance.getEntriesByType('resource').map((e) => e.name); - const mod = async (re) => import(urls.find((n) => re.test(n))); - const page = await mod(/design_pipeline_page\.ts/); - const pid = localStorage.getItem('frd_current_project_id'); - const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); - const flat = (await mod(/B05_Profile_UI_RouteEdit_Edits\.ts/)) - .flattenServerPlan(plan.nodes, plan.curves ?? [], plan.node_edits); - const bridge = page.routeDraftBridge(pid, null); - const at = Math.floor(flat.planned.length / 2); - for (let k = 1; k <= steps; k += 1) { // 끌기 걸음 — 걸음마다 묶음이 돎(잠잠 안 기다림) - const nodes = flat.planned.map(([x, y]) => [x, y]); - nodes[at] = [nodes[at][0] + 2 * k, nodes[at][1] + 1.5 * k]; - bridge.edited({ - nodes, - curveOn: flat.curveOn.map((on) => on !== false), - curveRadius: flat.curveRadius.map((r) => r ?? null), - curveLock: flat.curveLock ?? nodes.map(() => null), - curveArc: flat.curveArc ?? nodes.map(() => null), - stationSnap: plan.station_snap ?? false, - stationSnapStep: plan.station_snap_step ?? null, - }); - await new Promise((r) => setTimeout(r, 60)); - } - await page.pagePipeline(pid).settled(); -}""" - -JS_FOLLOW = r"""async () => { - const urls = performance.getEntriesByType('resource').map((e) => e.name); - const page = await import(urls.find((n) => /design_pipeline_page\.ts/.test(n))); - const pid = localStorage.getItem('frd_current_project_id'); - const pipe = page.pagePipeline(pid); - await page.reconcilePagePipeline(pid); - await pipe.settled(); - const plain = (v) => JSON.parse(JSON.stringify(v ?? null)); - const calls = (window.__aisloCalcServer?.calls ?? []).filter((c) => c.id === 'stage.follow'); - const planline = pipe.output('planline'); - return { - follow: plain(pipe.output('follow')), - inputs: plain(['pipes', 'structures', 'plusStations', 'alignment'] - .map((k) => [k, pipe.input(k)])), - base_pvi: plain(planline?.basePvi), pvi: plain(pipe.output('profile')?.pvi), - calls: calls.map((c) => ({ round: c.roundMs, node: c.nodeMs, bytes: c.bytes })), - runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), - }; -}""" - - -@pytest.fixture(scope="module") -def drags(): - from test_511_b1_calc_node_stages import _UP, BASE, _started - - if not BASE or _UP is None or _UP != _started(BASE.replace("5178", "8005")): - pytest.skip("이 폴더 서버 없음") - sync = pytest.importorskip("playwright.sync_api") - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - shots = {} - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - try: - for mode in ("both", "server"): - context = chrome.new_context(viewport={"width": 1600, "height": 1000}) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post( - f"{BASE}/api/auth/login/request", data=login, timeout=60000 - ) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - if mode == "server": # 판은 안 고침 — 이 창이 받는 판만 follow = server - - def board(route): - body = route.fetch().json() - body["sites"]["follow"] = "server" - route.fulfill(json=body) - - context.route("**/api/dashboard/calc-sites", board) - page = context.new_page() - page.add_init_script( - f"localStorage.setItem('frd_current_project_id', '{VERIFY}');" - " performance.setResourceTimingBufferSize(20000);" - ) - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - page.evaluate(JS_DRAG, 4) # 끌기 네 걸음(세션 초안 · 창과 함께 버림) - page.wait_for_timeout(4000) # 잠잠 400 ms · 서버 · 다시 돎 - shots[mode] = page.evaluate(JS_FOLLOW) - context.close() - finally: - chrome.close() - (ROOT / "tmp" / "511b5_drags.json").write_text(json.dumps(shots), encoding="utf-8") - return shots - - -def test_화면_follow_서버_끌기_뒤_화면_셈과_같음(drags): - both, server = drags["both"], drags["server"] - assert both["runs"] == server["runs"] == ["done"] - assert both["calls"] == [] and server["calls"] # server 판만 놓은 뒤 서버 follow - # 계획고 옮김 = 끌기 중 화면 셈이 입력에 옮겨 둔 값 그대로(서버 follow 는 저장분에서 새로 셈 · 옮김 - # 기준(앞 노선 · 앞 첫 계획선) 없음 → patch 에 alignment 안 냄) — 입력 · 종단은 같아야 함 - drop = lambda patch: {k: v for k, v in patch.items() if k != "alignment"} # noqa: E731 - assert drop(server["follow"]) == drop(both["follow"]) - for key in ("inputs", "base_pvi", "pvi"): - assert server[key] == both[key], key diff --git a/resources/tester/test_511_c_haul_browser.py b/resources/tester/test_511_c_haul_browser.py deleted file mode 100644 index 0c295546a..000000000 --- a/resources/tester/test_511_c_haul_browser.py +++ /dev/null @@ -1,157 +0,0 @@ -"""PLAN 511-C 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. - -- 판 haul 기본(server) = B05 계획고 ▲ 뒤 /haul-plan 요청 · 배분 모듈 안 받음(첫 묶음 밖) -- 판 haul both = /haul-plan 0 · 배분 모듈을 그때 받아 셈 · 유토곡선(배분 balloon 포함) 그림 - = server 판과 같음 -- 계산 자리 창 = E2 열린 줄(기본 꺼짐) · 서버만 G3 D1 D2 N1 줄마다 다른 까닭 -- 저장 안 함(세션 초안만) · 판 = 파일 바이트 되돌림 · 시험 브라우저 표식 시스템 로그 행만 지움 - - ./venv/Scripts/python.exe -m pytest resources/tester/test_511_c_haul_browser.py -q -s -""" - -from __future__ import annotations - -import asyncio -import json -import sys -from pathlib import Path - -import pytest - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -if str(HERE) not in sys.path: - sys.path.insert(0, str(HERE)) - -from helper_verify_project import own_ports # noqa: E402 -from test_511_a_calc_sites_browser import ( # noqa: E402, F401 - _UP, - JS_GRADE, # noqa: E402 - _started, -) - -API, BASE = own_ports() -BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" -PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md) -MARK = "AisloTest-511-C" -CARD = "계산 자리" - -pytestmark = pytest.mark.skipif( - _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" -) - -# 그림 글 — 그릴 때마다 새로 짓는 clipPath 이름만 한 글자로(값 아님) -JS_CHART = r"""() => { - const chart = document.querySelector('.b05-profile__masshaul-chart'); - return chart ? chart.innerHTML.replace(/mass-clip-[a-z0-9]+/g, 'mass-clip') : null; -}""" -JS_LOADED = r"""() => performance.getEntriesByType('resource').map((e) => e.name) - .some((n) => /common_util_mass_haul_balance\.ts/.test(n))""" - - -@pytest.fixture(scope="module") -def ctx(): - sync = pytest.importorskip("playwright.sync_api") - committed = BOARD.read_bytes() - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - try: - with sync.sync_playwright() as play: - chrome = play.chromium.launch(channel="msedge", headless=True) - context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) - login = {k: cred[k] for k in ("email", "password")} - r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) - assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 - try: - yield context - finally: - chrome.close() - finally: - BOARD.write_bytes(committed) - _drop_test_logs() - - -def _drop_test_logs() -> None: - from config.config_db import close_db_pool, init_db_pool - - async def run(): - pool = await init_db_pool() - try: - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) - await connection.commit() - finally: - await close_db_pool() - - asyncio.run(run()) - - -def _board(haul: str | None) -> None: - board = json.loads(BOARD.read_text("utf-8")) - board["sites"].pop("haul", None) - if haul: - board["sites"]["haul"] = haul - BOARD.write_text(json.dumps(board, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def _run(context) -> dict: - page = context.new_page() - page.add_init_script( - f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" - " performance.setResourceTimingBufferSize(20000);" - ) - posts: list[dict] = [] - - def on_response(res): - if res.url.endswith("/haul-plan") and res.request.method == "POST": - posts.append(res.json()) - - page.on("response", on_response) - page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") - page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) - page.wait_for_timeout(9000) - page.click(".ui-workflow-overlay__handle--bottom") # 종단 펼침 - page.wait_for_timeout(2000) - if page.evaluate(JS_CHART) is None: - page.click(".b05-profile__masshaul-handle") # 유토곡선 펼침 - page.evaluate(JS_GRADE) # 계획고 ▲ 한 번(가운데 측점 +0.3 m) — 새 누가토량 → 배분 다시 - page.wait_for_timeout(5000) - out = {"posts": posts, "chart": page.evaluate(JS_CHART), "loaded": page.evaluate(JS_LOADED)} - page.close() - return out - - -def test_haul_server_both_그림_같음(ctx): - _board(None) # 판 파일에 haul 없음 = 기본 server - server = _run(ctx) - _board("both") - both = _run(ctx) - _board(None) - assert server["posts"] and all(p["haul_plan"] for p in server["posts"]) - assert any(p["haul_plan"]["transfers"] for p in server["posts"]) - assert not server["loaded"] # 기본값 = 배분 모듈 안 받음 - assert both["posts"] == [] and both["loaded"] - assert server["chart"] and "balloon" in server["chart"] - assert both["chart"] == server["chart"] - print("posts", len(server["posts"]), "chart bytes", len(server["chart"])) - - -def test_계산_자리_창_haul_엶_서버만_까닭(ctx): - page = ctx.new_page() - page.goto(f"{BASE}/#/b01-account", wait_until="domcontentloaded") - page.wait_for_selector(".b01-dashboard__setting-card", timeout=60000) - page.get_by_text(CARD, exact=True).click() - page.wait_for_selector(".b01-dashboard__calc-sites", timeout=30000) - rows = page.eval_on_selector_all( - ".b01-dashboard__calc-sites tbody tr", - "(trs) => trs.map((tr) => ({ name: tr.cells[0].textContent.trim()," - " why: tr.cells[3].textContent.trim()," - " boxes: [...tr.querySelectorAll('input')].map((i) => [i.checked, i.disabled]) }))", - ) - page.close() - calcs = {row["name"].split(" · ")[0]: row for row in rows if " · " in row["name"]} - assert calcs["E2"]["boxes"] == [[False, False], [True, True]] # 열림 · 기본 server(꺼짐) - whys = {no: calcs[no]["why"] for no in ("G3", "D1", "D2", "N1")} - for no, word in {"G3": "DB", "D1": "래스터", "D2": "D1", "N1": "지형"}.items(): - assert word in whys[no] and calcs[no]["boxes"][0] == [False, True], (no, whys[no]) - assert len(set(whys.values())) == 4 - print(whys) diff --git a/resources/tester/test_app_settings.py b/resources/tester/test_app_settings.py deleted file mode 100644 index 071a1f6b1..000000000 --- a/resources/tester/test_app_settings.py +++ /dev/null @@ -1,207 +0,0 @@ -"""PLAN 332-2 — ☰ [설정] 창(`A00_Common/app_settings.ts` · `app_settings_cad.ts` · `app_shell.ts`). - -① CAD 별칭 검사(Node) — 영문 · 숫자만 · 겹침 금지 · 명령 이름 금지 · 칸 글 나눔 -② 실제 브라우저(sub2 화면 서버 5177 · 로그인 없음 = 이 브라우저만 · 새 창이라 남는 것 없음) - ☰ → [설정] = 서랍 닫히고 창 · 탭 셋 전환 · 고쳐도 [저장] 전엔 안 씀 · 닫을 때 물음(취소 = 머묾 · - 버림 = 안 씀 · 저장 = 한 번) · [저장] 한 번 = 한 번 씀 · 저장 뒤 읽기 층 방향 뒤집힘 · 잘못 별칭은 안 씀 -""" - -from __future__ import annotations - -import json -import os -import subprocess -import urllib.request - -import pytest - -HERE = os.path.dirname(__file__) -ROOT = os.path.join(HERE, "..", "..") -# 화면 서버(OWNERS 자기 줄) — 돌리는 창 포트로 바꿈 -from helper_verify_project import own_ports # noqa: E402 - -BASE = os.environ.get("AISLO_UI_BASE", own_ports()[1]) -KEY = "aislo:pref:input-prefs" - -# 앱 스크립트보다 먼저 — 설정 키에 쓰는 횟수를 셈 -COUNT_WRITES = f""" -window.__writes = []; -const set = Storage.prototype.setItem; -Storage.prototype.setItem = function (k, v) {{ - if (k === {json.dumps(KEY)}) window.__writes.push(v); - return set.call(this, k, v); -}}; -""" -# 앱이 불러온 그 읽기 층 사본(vite 가 ?t= 를 붙였을 수 있음) -WHEEL_IN = ( - "async (d) => (await import(performance.getEntriesByType('resource')" - ".map((e) => e.name).find((n) => n.includes('/input_prefs.ts')))).wheelIn(d)" -) - - -@pytest.fixture(scope="module") -def node() -> dict: - proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", os.path.join(HERE, "helper_app_settings.cjs")], - capture_output=True, - text=True, - encoding="utf-8", - timeout=60, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_별칭_검사(node): - assert node["commands"] == 154 # 488-3 IMPORT 더함 - assert node["parse"] == [["LL", "CC", "X1"], [], []] - assert node["ok"] == {} # 남의 기본 별칭(C = CIRCLE)을 내 것으로 덮는 건 됨(acad.pgp 식) - assert node["chars"] == {"LINE": "chars", "CIRCLE": "chars"} - assert node["dup"] == {"LINE": "dup", "CIRCLE": "dup", "ARC": "dup"} - assert node["name"] == {"CIRCLE": "name"} - - -def _up() -> bool: - try: - urllib.request.urlopen(BASE, timeout=3) - return True - except Exception: - return False - - -def _open_settings(page): - page.click(".app-burger") - page.click(".app-drawer__settings") - page.wait_for_selector(".app-settings") - assert not page.evaluate( - "document.querySelector('.app-header, header')?.classList.contains('is-drawer-open')" - ) - - -def _writes(page) -> list: - return page.evaluate("window.__writes") - - -def _visible_panel_has(page, selector: str) -> bool: - return page.evaluate( - "(s) => [...document.querySelectorAll('.app-settings .ui-modal__body > div:not([role]), " - ".app-settings .ui-modal__body > table')].some((p) => !p.hidden && p.querySelector(s))", - selector, - ) - - -def _save(page): - page.click(".app-settings .ui-modal__actions button") - - -def _close(page): - page.click(".app-settings .ui-modal__top > button") - - -def test_다른_곳_단축키_줄마다_동작_있음(): - """364-2 — [다른 곳 단축키] 모든 줄 = 「입력 = 동작」 · ko · en 줄 수 같음.""" - import re - - src = open( - os.path.join(ROOT, "ui_template", "ui_template_locale_common.ts"), encoding="utf8" - ).read() - blocks = re.findall(r'App_Settings_Keys_\w+_Keys: \[\s*"(.*?)",\s*"(.*?)",\s*\]', src, re.S) - assert len(blocks) == 7 - for ko, en in blocks: - ko_lines, en_lines = ko.split("\\n"), en.split("\\n") - assert len(ko_lines) == len(en_lines), ko - for line in ko_lines + en_lines: - assert " = " in line and not line.rstrip().endswith(" ="), line - - -@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") -def test_설정_창_탭_저장_물음(): - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - ctx = browser.new_context( - viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True - ) - ctx.add_init_script(COUNT_WRITES) - page = ctx.new_page() - page.goto(f"{BASE}/#/a01-home") - page.wait_for_selector(".app-burger") - assert page.evaluate(WHEEL_IN, -100) is True # 기본 = 밂(337 · 오토캐드) - - # ☰ → [설정] · 탭 셋 · 첫 탭 = 마우스 - _open_settings(page) - tabs = page.evaluate( - "[...document.querySelectorAll('.app-settings [role=tab]')]" - ".map((b) => [b.dataset.settingsTab, b.getAttribute('aria-selected')])" - ) - assert tabs == [["mouse", "true"], ["cad", "false"], ["keys", "false"]] - assert _visible_panel_has(page, "input[type=radio]") - page.click("[data-settings-tab=cad]") - assert _visible_panel_has(page, "input[data-cad-command]") - assert not _visible_panel_has(page, "input[type=radio]") - assert page.locator(".app-settings input[data-cad-command]").count() == 154 - page.click("[data-settings-tab=keys]") - assert ( - page.evaluate( - "document.querySelector('[data-settings-tab=keys]').getAttribute('aria-selected')" - ) - == "true" - ) - page.click("[data-settings-tab=mouse]") - - # 당김 고름 — [저장] 전엔 안 씀 · 닫으면 물음 · 취소 = 머묾 - page.check("input[name=app-settings-wheel][value=pull]") - assert _writes(page) == [] - page.keyboard.press("Escape") - page.wait_for_selector(".ui-confirm") - page.click(".ui-confirm button >> nth=0") # 취소 - assert page.locator(".app-settings").count() == 1 and _writes(page) == [] - - # [저장] = 한 번 씀 · 바로 닫힘(물음 없음) · 읽기 층 방향 뒤집힘 - _save(page) - assert page.locator(".app-settings").count() == 0 - assert page.locator(".ui-confirm").count() == 0 - assert len(_writes(page)) == 1 - assert json.loads(_writes(page)[0]) == {"wheelZoom": "pull", "cadAliases": {}} - assert page.evaluate(WHEEL_IN, 100) is True and page.evaluate(WHEEL_IN, -100) is False - _open_settings(page) - _save(page) # 고친 것 없음 — 또 안 씀 · 닫힘 - assert len(_writes(page)) == 1 and page.locator(".app-settings").count() == 0 - - # CAD 별칭 — 잘못이면 안 씀 · 칸 표시 - _open_settings(page) - assert page.is_checked("input[name=app-settings-wheel][value=pull]") # 저장값으로 엶 - page.click("[data-settings-tab=cad]") - line = "input[data-cad-command=LINE]" - circle = "input[data-cad-command=CIRCLE]" - for line_v, circle_v in (("L!", ""), ("LL", "ll"), ("", "LINE")): - page.fill(line, line_v) - page.fill(circle, circle_v) - _save(page) - assert page.inner_text(".app-settings__error").strip(), (line_v, circle_v) - assert page.locator(".app-settings input[aria-invalid=true]").count() >= 1 - assert len(_writes(page)) == 1 and page.locator(".app-settings").count() == 1 # 창 남음 - page.fill(line, "ll") - page.fill(circle, "") - _save(page) - assert page.locator(".app-settings").count() == 0 # 저장 성공 = 닫힘 - assert len(_writes(page)) == 2 - assert json.loads(_writes(page)[1]) == {"wheelZoom": "pull", "cadAliases": {"LINE": ["LL"]}} - - # 닫을 때 물음 — 버림 = 안 씀 · 저장 = 한 번 더 - _open_settings(page) - page.click("[data-settings-tab=mouse]") - page.check("input[name=app-settings-wheel][value=push]") - _close(page) - page.click(".ui-confirm button >> nth=1") # 폐기 - assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 2 - assert page.evaluate(WHEEL_IN, 100) is True - _open_settings(page) - page.check("input[name=app-settings-wheel][value=push]") - _close(page) - page.click(".ui-confirm button >> nth=2") # 저장 - assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 3 - assert json.loads(_writes(page)[2])["wheelZoom"] == "push" - assert page.evaluate(WHEEL_IN, 100) is False - browser.close() diff --git a/resources/tester/test_cad_touch.py b/resources/tester/test_cad_touch.py deleted file mode 100644 index 65fcd318a..000000000 --- a/resources/tester/test_cad_touch.py +++ /dev/null @@ -1,83 +0,0 @@ -"""B07 CAD 손가락(PLAN_기기대응 4-1 · `openwebcad/src/inputController/input-touch.ts`) — 빌드된 CAD 화면. - -① 손가락 명령 단추(.cad-touch-bar)는 없음(요구 298-1) — 마우스 · 손가락 화면 모두 DOM 0 -② 확대 막기(user-scalable=no) 없음 -③ 캔버스 touch-action none · 두 손가락 벌림 · 한 손가락 끌기가 페이지를 확대 · 밀지 않음(페이지 오류 0) -""" - -import os -import urllib.request - -import pytest - -from helper_verify_project import own_ports # noqa: E402 - -URL = os.environ.get("CAD_TOUCH_URL", f"{own_ports()[1]}/b07-cad/") # OWNERS.md 이 폴더 줄 - - -def _up() -> bool: - try: - urllib.request.urlopen(URL, timeout=2) - return True - except Exception: - return False - - -def _touch(cdp, kind, points): - cdp.send( - "Input.dispatchTouchEvent", - { - "type": kind, - "touchPoints": [{"x": x, "y": y, "id": i} for i, (x, y) in enumerate(points)], - }, - ) - - -@pytest.mark.skipif(not _up(), reason="sub1 화면 서버(5174) 꺼짐") -def test_cad_no_touch_bar_and_gestures(): - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - mouse = browser.new_page(viewport={"width": 1440, "height": 900}) - mouse.goto(URL) - mouse.wait_for_selector("canvas[data-id=canvas]", timeout=20000) - assert mouse.evaluate("document.querySelector('.cad-touch-bar')") is None - ctx = browser.new_context( - viewport={"width": 1180, "height": 820}, has_touch=True, is_mobile=True - ) - page = ctx.new_page() - errors = [] - page.on("pageerror", lambda e: errors.append(str(e))) - page.goto(URL) - page.wait_for_selector("canvas[data-id=canvas]", timeout=20000) - assert page.evaluate("document.querySelector('.cad-touch-bar')") is None - assert "user-scalable" not in page.evaluate( - "document.querySelector('meta[name=viewport]').content" - ) - assert ( - page.evaluate( - "getComputedStyle(document.querySelector('canvas[data-id=canvas]')).touchAction" - ) - == "none" - ) - - box = page.evaluate( - "(() => { const r = document.querySelector('canvas[data-id=canvas]').getBoundingClientRect();" - " return [r.left + r.width / 2, r.top + r.height / 2]; })()" - ) - cx, cy = box - cdp = ctx.new_cdp_session(page) - _touch(cdp, "touchStart", [(cx, cy)]) # 한 손가락 끌기 = 화면 옮기기 - for i in range(1, 6): - _touch(cdp, "touchMove", [(cx + i * 20, cy + i * 10)]) - _touch(cdp, "touchEnd", []) - _touch(cdp, "touchStart", [(cx - 30, cy), (cx + 30, cy)]) # 벌림 = 도면 확대 - for i in range(1, 8): - _touch(cdp, "touchMove", [(cx - 30 - i * 10, cy), (cx + 30 + i * 10, cy)]) - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(300) - assert page.evaluate("visualViewport.scale") == 1 - assert page.evaluate("[scrollX, scrollY]") == [0, 0] - assert not errors, errors - browser.close() diff --git a/resources/tester/test_device_audit.py b/resources/tester/test_device_audit.py deleted file mode 100644 index c5f3a2023..000000000 --- a/resources/tester/test_device_audit.py +++ /dev/null @@ -1,357 +0,0 @@ -"""기기 대응 자동 점검(요구 193~195 · PLAN_기기대응 1-1 · 5-1). - -sub1 서버(5174)에서 모든 페이지를 네 크기로 열어 문제 수를 셈: -가로 넘침 · 화면 밖(밀어도 못 닿음) · 가려진 단추 · 44px 미만 누를 자리 · 닿을 수 없는 위 메뉴. -데스크탑(1440×900 마우스)은 사진 + 배치 서명(보이는 요소 사각형)을 남겨 전후 대조. - -실행: - python resources/tester/test_device_audit.py before → tmp/device_audit_before.json · tmp/desktop_before/ - python resources/tester/test_device_audit.py after → tmp/device_audit_after.json · tmp/desktop_after/ - pytest — 서버가 떠 있으면 한 바퀴(after) 돌려 페이지가 모두 열리는지 · 데스크탑 배치가 before 와 같은지 봄. -로그인 = tmp/aislo_cred.json(값은 출력하지 않음) · 프로젝트 = 검증_sub1. -""" - -import json -import os -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -# 다른 창 서버로 돌릴 때 — AISLO_UI_BASE · AISLO_UI_PROJECT(OWNERS 자기 줄) -from helper_verify_project import own_ports # noqa: E402 - -BASE = os.environ.get("AISLO_UI_BASE", own_ports()[1]) -PROJECT_NAME = os.environ.get("AISLO_UI_PROJECT", "검증_sub1") -PAGES = [ - "a01-home", - "a02-prog-detail", - "a03-comp-detail", - "a04-news-history", - "a05-edu-detail", - "a06-login", - "a07-register", - "a08-support", - "a09-security", - "b01-account", - "b02-proj-register", - "b03-file-input", - "b04-preprocess", - "b05-profile", - "b06-section", - "b07-design-detail", - "b08-quantity", - "b09-estimation", - "b10-payment", - "b11-status", - "m01-master-data", - "m02-master-template", -] -SIZES = { - "phone_390x844_touch": {"viewport": {"width": 390, "height": 844}, "touch": True}, - "tablet_820x1180_touch": {"viewport": {"width": 820, "height": 1180}, "touch": True}, - "tablet_1180x820_touch": {"viewport": {"width": 1180, "height": 820}, "touch": True}, - "desktop_1440x900_mouse": {"viewport": {"width": 1440, "height": 900}, "touch": False}, -} -DESKTOP = "desktop_1440x900_mouse" - -# 페이지 안에서 셈 — 숫자만 돌려줌(값 · 글은 안 담음) -AUDIT_JS = r""" -() => { - const vw = innerWidth, vh = innerHeight; - const SEL = 'button, a[href], input:not([type=hidden]), select, textarea, summary, ' + - '[role=button], [role=menuitem], [role=tab], [role=checkbox], [tabindex]:not([tabindex="-1"])'; - const vis = (el) => { - const r = el.getBoundingClientRect(); - if (r.width < 1 || r.height < 1) return null; - if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return null; - return r; - }; - const scrollable = (el, axis) => { - const cs = getComputedStyle(el); - const ov = axis === 'x' ? cs.overflowX : cs.overflowY; - if (!/(auto|scroll)/.test(ov)) return false; - return axis === 'x' ? el.scrollWidth > el.clientWidth + 1 : el.scrollHeight > el.clientHeight + 1; - }; - const se = document.scrollingElement; - const rootCs = getComputedStyle(document.documentElement), bodyCs = getComputedStyle(document.body); - const docScroll = { - x: se.scrollWidth > vw + 1 && !/hidden|clip/.test(rootCs.overflowX + bodyCs.overflowX), - y: se.scrollHeight > vh + 1 && !/hidden|clip/.test(rootCs.overflowY + bodyCs.overflowY), - }; - const reachable = (el, axis) => { - for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { - if (scrollable(p, axis)) return true; - if (getComputedStyle(p).position === 'fixed') return false; - } - return docScroll[axis]; - }; - const out = { hscroll_px: Math.max(0, se.scrollWidth - vw), offscreen: 0, covered: 0, small: 0, - targets: 0, menu_unreachable: 0, too_wide: 0, samples: { offscreen: [], covered: [], small: [] } }; - const name = (el) => { - const t = (el.getAttribute('aria-label') || el.title || el.textContent || el.placeholder || '').trim().slice(0, 24); - return `${el.tagName.toLowerCase()}${el.className && typeof el.className === 'string' ? '.' + el.className.split(/\s+/)[0] : ''}:${t}`; - }; - const push = (k, el) => { if (out.samples[k].length < 8) out.samples[k].push(name(el)); }; - for (const el of document.querySelectorAll(SEL)) { - if (el.closest('[aria-hidden=true], [inert]')) continue; - const r = vis(el); - if (!r) continue; - out.targets++; - const offX = r.right > vw + 1 || r.left < -1, offY = r.bottom > vh + 1 || r.top < -1; - if ((offX && !reachable(el, 'x')) || (offY && !reachable(el, 'y'))) { out.offscreen++; push('offscreen', el); continue; } - if (offX || offY) continue; - const cx = Math.min(vw - 1, Math.max(0, r.left + r.width / 2)), cy = Math.min(vh - 1, Math.max(0, r.top + r.height / 2)); - const hit = document.elementFromPoint(cx, cy); - if (hit && hit !== el && !el.contains(hit) && !hit.contains(el) - && !(el.tagName === 'INPUT' && hit.tagName === 'LABEL')) { out.covered++; push('covered', el); } - if (Math.min(r.width, r.height) < 44) { out.small++; push('small', el); } - } - // 위 메뉴 — 숨은 항목 수(☰ 이 보이면 닿음) - const nav = document.querySelector('.app-nav'); - const burger = document.querySelector('.app-burger'); - if (nav && !(burger && vis(burger))) { - for (const b of nav.querySelectorAll('button')) if (!vis(b)) out.menu_unreachable++; - } - // 본문 안 화면보다 넓은 상자(고정 폭) - const outlet = document.getElementById('app-outlet'); - if (outlet) for (const el of outlet.querySelectorAll('*')) { - if (el.offsetWidth > vw + 1 && !reachable(el, 'x')) out.too_wide++; - } - out.pointer = matchMedia('(pointer: coarse)').matches ? 'coarse' : 'fine'; - out.rendered = !!(outlet && outlet.childElementCount); - out.outlet_top = outlet ? Math.round(outlet.getBoundingClientRect().top) : null; // 본문 위 끝(위 줄 걷힘 = 0 · 6-3) - return out; -} -""" - -# ☰ 상자와 겹치는 보이는 글 · 단추(7-3) — 왼쪽 위에 뜬 ☰ 아래 깔린 것만(맨 위에 보이는 것 · 같은 출처 iframe 안 포함) -BURGER_JS = r""" -() => { - const b = document.querySelector('.app-burger'); - if (!b || !b.getClientRects().length) return []; - const B = b.getBoundingClientRect(), hits = []; - const scan = (doc, ox, oy) => { - const L = B.left - ox, T = B.top - oy, R = B.right - ox, D = B.bottom - oy; - const cands = new Set(doc.querySelectorAll('button, a[href], input:not([type=hidden]), select, textarea, [role=button], [role=tab]')); - const w = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); - for (let n = w.nextNode(); n; n = w.nextNode()) if (n.nodeValue.trim()) cands.add(n); - for (const c of cands) { - const el = c.nodeType === 3 ? c.parentElement : c; - if (!el || el.closest('.app-header, .ui-toast')) continue; - if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; - let r = el.getBoundingClientRect(); - if (c.nodeType === 3) { const g = doc.createRange(); g.selectNodeContents(c); r = g.getBoundingClientRect(); } - const l = Math.max(r.left, L), t = Math.max(r.top, T), rr = Math.min(r.right, R), bb = Math.min(r.bottom, D); - if (r.width < 1 || r.height < 1 || rr - l < 1 || bb - t < 1) continue; - const top = doc.elementsFromPoint((l + rr) / 2, (t + bb) / 2).find((e) => !e.closest('.app-header')); - if (top && top !== el && !el.contains(top) && !top.contains(el)) continue; - hits.push(`${el.tagName.toLowerCase()}:${(c.nodeType === 3 ? c.nodeValue : el.textContent).trim().slice(0, 16)}`); - } - for (const f of doc.querySelectorAll('iframe')) { - const fr = f.getBoundingClientRect(); - if (fr.right < L || fr.left > R || fr.bottom < T || fr.top > D) continue; - try { if (f.contentDocument?.body) scan(f.contentDocument, ox + fr.left + f.clientLeft, oy + fr.top + f.clientTop); } catch (e) {} - } - }; - scan(document, 0, 0); - return hits; -} -""" - -# 데스크탑 배치 서명 — 화면 안에 보이는 요소 사각형(정수) · 순서대로. -# 잡음 빼기 = 움직이는 중(CSS 애니메이션 · 로딩 원)인 요소와 그 안 · 화면 밖(스크롤 아래 1px 흔들림) -LAYOUT_JS = r""" -() => { - const rows = []; - const moving = new Set(document.getAnimations().map((a) => a.effect && a.effect.target).filter(Boolean)); - const isMoving = (el) => { for (let p = el; p; p = p.parentElement) if (moving.has(p)) return true; return false; }; - for (const el of document.body.querySelectorAll('*')) { - const r = el.getBoundingClientRect(); - if (r.width < 1 || r.height < 1) continue; - if (r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= innerWidth) continue; - if (isMoving(el)) continue; - if (el.checkVisibility && !el.checkVisibility({ visibilityProperty: true })) continue; - rows.push(`${el.tagName}|${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)}`); - } - return rows; -} -""" - - -def _project_id(ctx): - projects = ctx.request.get(f"{BASE}/api/dashboard/user/projects").json()["projects"] - return next(p["id"] for p in projects if p.get("name") == PROJECT_NAME) - - -def _login(ctx, cred): - for _ in range(3): # 서버가 바쁠 때 한 번씩 30초 넘김 - try: - r = ctx.request.post(f"{BASE}/api/auth/login/request", data=cred, timeout=60000) - break - except Exception: - continue - assert r.ok, f"로그인 실패 {r.status}" - - -def _open(page, route): - page.goto(f"{BASE}/#/{route}", wait_until="domcontentloaded") - try: - page.wait_for_load_state("networkidle", timeout=15000) - except Exception: - pass - try: # 불러오는 중 표시(돌이 · 진행 원)가 사라질 때까지 — 반쯤 그린 화면을 찍지 않게 - page.wait_for_function( - "() => ![...document.querySelectorAll('.ui-loading, .ui-progress-circle')]" - ".some((e) => e.getClientRects().length)", - timeout=45000, - ) - except Exception: - pass - page.wait_for_timeout(1500) - - -def run_audit(tag: str, pages=PAGES, sizes=SIZES) -> dict: - from playwright.sync_api import sync_playwright - - cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - shots = ROOT / "tmp" / f"desktop_{tag}" - shots.mkdir(parents=True, exist_ok=True) - result = {"base": BASE, "sizes": list(sizes), "pages": {}} - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - for size, spec in sizes.items(): - ctx = browser.new_context( - viewport=spec["viewport"], - has_touch=spec["touch"], - is_mobile=spec["touch"], - locale="ko-KR", - ) - _login(ctx, cred) - pid = _project_id(ctx) - ctx.add_init_script( - f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})" - ) - page = ctx.new_page() - errors = [] - page.on("pageerror", lambda e: errors.append(str(e)[:120])) - for route in pages: - errors.clear() - _open(page, route) - row = page.evaluate(AUDIT_JS) - row["errors"] = list(errors) - row["burger_hits"] = page.evaluate(BURGER_JS) - if size == DESKTOP: - page.screenshot(path=str(shots / f"{route}.png")) - (shots / f"{route}.layout.json").write_text( - json.dumps(page.evaluate(LAYOUT_JS)), encoding="utf-8" - ) - result["pages"].setdefault(route, {})[size] = row - print( - f"{route:22} {size:24} hs={row['hscroll_px']:4} off={row['offscreen']:3} " - f"cov={row['covered']:3} small={row['small']:3} menu={row['menu_unreachable']} " - f"top={row['outlet_top']} burger={len(row['burger_hits'])}", - flush=True, - ) - ctx.close() - browser.close() - out = ROOT / "tmp" / f"device_audit_{tag}.json" - out.write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding="utf-8") - return result - - -def _boxes(rows): - """HTML 상자 · svg 틀만 — svg 안 선 · 글(path · g · text 등 소문자 태그)은 자료 · 날짜로 바뀌는 그림이라 뺌.""" - return [r for r in rows if r.split("|")[0].isupper() or r.startswith("svg|")] - - -def layout_diff(route: str, a="before", b="after") -> int: - """데스크탑 배치 서명 차이 줄 수(0 = 같음).""" - ra = _boxes(json.loads((ROOT / "tmp" / f"desktop_{a}" / f"{route}.layout.json").read_text())) - rb = _boxes(json.loads((ROOT / "tmp" / f"desktop_{b}" / f"{route}.layout.json").read_text())) - import difflib - - return sum( - 1 for line in difflib.ndiff(ra, rb) if line.startswith("+ ") or line.startswith("- ") - ) - - -def _rect(row): - tag, xywh = row.split("|") - return tag, tuple(int(v) for v in xywh.split(",")) - - -def bm_moved(route: str, a="before", b="after") -> list: - """로그인 뒤 쪽(B · M) 데스크탑 — 위 줄(64)을 걷어 세로는 올라가도 가로 자리(태그 · left · width)는 같아야 함(7-3). - 원래 상자 가운데 지금 같은 가로 자리가 없는 것. 빼는 것 = 위 줄 · ☰ 띠(위 64 + 60 안 · 들여쓴 첫 줄) · - 없어진 떠 있는 진행 판(ASIDE) 안 · 화면 전체 상자.""" - ra = _boxes(json.loads((ROOT / "tmp" / f"desktop_{a}" / f"{route}.layout.json").read_text())) - rb = _boxes(json.loads((ROOT / "tmp" / f"desktop_{b}" / f"{route}.layout.json").read_text())) - import collections - - left = collections.Counter((t, r[0], r[2]) for t, r in map(_rect, rb)) - missing = [] - for row in ra: - t, r = _rect(row) - if left[(t, r[0], r[2])] > 0: - left[(t, r[0], r[2])] -= 1 - else: - missing.append((t, r)) - gone = [r for t, r in missing if t == "ASIDE"] - - def inside(r, g): - return ( - g[0] <= r[0] - and g[1] <= r[1] - and r[0] + r[2] <= g[0] + g[2] - and r[1] + r[3] <= g[1] + g[3] - ) - - return [ - f"{t}|{r}" - for t, r in missing - if r[1] >= 64 + 60 - and r[2] < SIZES[DESKTOP]["viewport"]["width"] - and not any(inside(r, g) for g in gone) - ] - - -def _server_up() -> bool: - import urllib.request - - try: - urllib.request.urlopen(BASE, timeout=2) - return True - except Exception: - return False - - -@pytest.mark.skipif(not _server_up(), reason="sub1 화면 서버(5174) 꺼짐") -def test_device_audit_all_pages_render_and_desktop_unchanged(): - result = run_audit("after") - for route, sizes in result["pages"].items(): - for size, row in sizes.items(): - assert row["rendered"], f"{route} {size} 빈 화면" - assert row["pointer"] == ("fine" if size == DESKTOP else "coarse"), (route, size) - # 로그인 뒤 쪽 = ☰ 왼쪽 위 · 그 상자와 겹치는 글 · 단추 0 · 본문 위 끝 0(7-3) - if route[0] in "bm": - assert not row["burger_hits"], (route, size, row["burger_hits"]) - assert row["outlet_top"] == (40 if route == "b03-file-input" else 0), (route, size) - if (ROOT / "tmp" / "desktop_before2").exists(): - # 공개 쪽 = 배치 그대로 · 로그인 뒤 쪽 = 가로 자리 그대로 — 같은 원래 코드 두 번 재기(before · before2) 잡음 이하 - def diff(r, b): - return len(bm_moved(r, "before", b)) if r[0] in "bm" else layout_diff(r, "before", b) - - noise = {r: diff(r, "before2") for r in PAGES} - over = [r for r in PAGES if diff(r, "after") > noise[r]] - if over: # 불러오는 때에 따라 한 번 흔들림(B06 종단) — 그 쪽만 한 번 더 잼 - run_audit("after_retry", pages=over, sizes={DESKTOP: SIZES[DESKTOP]}) - over = {r: diff(r, "after_retry") for r in over if diff(r, "after_retry") > noise[r]} - assert not over, over - - -if __name__ == "__main__": - tag = sys.argv[1] if len(sys.argv) > 1 else "after" - only = sys.argv[2].split(",") if len(sys.argv) > 2 else PAGES - run_audit(tag, pages=only) diff --git a/resources/tester/test_device_drawer.py b/resources/tester/test_device_drawer.py deleted file mode 100644 index 790ca8e01..000000000 --- a/resources/tester/test_device_drawer.py +++ /dev/null @@ -1,206 +0,0 @@ -"""☰ 서랍 · 위 줄 걷기(PLAN_기기대응 6 · 7 · `app_shell.ts` · `ui_template_workflow_layout.ts`) — 실제 브라우저. - -① 휴대폰 · 로그인 뒤 B 쪽 = 위 줄 걷힘(본문 위 끝 0) · ☰ 는 위 왼쪽 모서리(폭 36 · 손가락 높이 44) · 떠 있는 진행 판 없음 -② ☰ 서랍 = 왼쪽에서 열림 · ☰ 는 서랍 위에 그대로 · 로고 · 홈 ─ 진행 단계 ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드 차례 - 진행 단계는 진행 판의 **그 요소**(한 벌) · 지금 단계 강조 그대로 · 닫으면 제자리(진행 판)로 돌아감 · 단계 누름 = 이동 + 서랍 닫힘 -③ 휴대폰 · 공개 쪽(A01) = 위 줄 그대로(본문 위 끝 64) · ☰ 는 위 줄 오른쪽 -④ 데스크탑(1440 · 마우스) · 로그인 뒤 B 쪽 = 위 줄 걷힘 · ☰ 왼쪽 위 36×36 · 진행 판 없음 · 위 줄 항목이 모두 서랍에 · Esc 로 닫힘 -⑤ 데스크탑 · 공개 쪽(A01) = 위 줄 그대로 · ☰ 없음 -⑥ (352-4) 좌측 패널이 펼쳐진 동안 ☰ 는 제목 줄 앞 칸에 든 모양(테두리 · 바탕 · 그림자 없음 · 자리 8,8 그대로) - · 접으면 떠 있는 단추 · 서랍 첫 줄 로고 우측 · 프로젝트 이름 우측 끝 — 글로 봄(서버 없이) -""" - -import json -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import test_device_audit as audit # noqa: E402 - -OUTLET_TOP = "Math.round(document.getElementById('app-outlet').getBoundingClientRect().top)" -GROUPS = "[...document.querySelectorAll('.app-drawer > *')].map(e => e.className)" -BURGER = ( - "(() => { const r = document.querySelector('.app-burger').getBoundingClientRect();" - " return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)," - " Math.round(r.right)]; })()" -) -TITLE_ROW_CY = ( - "(() => { const r = document.querySelector(" - "'.ui-workflow-overlay__panel--title > .ui-workflow-overlay__header').getBoundingClientRect();" - " return Math.round(r.top + r.height / 2); })()" -) -PROGRESS_HIDDEN = ( - "!document.querySelector('.ui-workflow-overlay__panel--progress').getClientRects().length" -) -LABELS = ( - "(sel) => [...document.querySelectorAll(sel)].map(b => b.textContent.trim()).filter(Boolean)" -) - - -def _ctx(browser, cred, size): - spec = audit.SIZES[size] - ctx = browser.new_context( - viewport=spec["viewport"], has_touch=spec["touch"], is_mobile=spec["touch"] - ) - audit._login(ctx, cred) - pid = audit._project_id(ctx) - ctx.add_init_script(f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})") - return ctx - - -@pytest.mark.skipif(not audit._server_up(), reason="sub1 화면 서버(5174) 꺼짐") -def test_drawer_holds_steps_and_header_folds(): - from playwright.sync_api import sync_playwright - - cred = json.loads((audit.ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - page = _ctx(browser, cred, "phone_390x844_touch").new_page() - audit._open(page, "b07-design-detail") - - # ① - assert page.evaluate(OUTLET_TOP) == 0 - burger = page.evaluate(BURGER) - assert burger[:4] == [8, 8, 36, 44], burger - assert page.evaluate(PROGRESS_HIDDEN) - - # ② - page.evaluate( - "window.__steps = document.querySelector('.ui-workflow-overlay__panel--progress .ui-workflow-layout__steps')" - ) - page.evaluate("document.querySelector('.app-burger').click()") - assert ( - page.evaluate( - "Math.round(document.querySelector('.app-drawer').getBoundingClientRect().left)" - ) - == 0 - ) - assert page.evaluate( # ☰ 는 서랍 위에서 그대로 눌림(다시 누르면 닫힘) - "document.elementFromPoint(26, 30) === document.querySelector('.app-burger')" - ) - groups = page.evaluate(GROUPS) - order = [ - next((k for k in ("brand", "steps", "site", "touch") if k in c), "settings") - for c in groups - ] - assert order == ["brand", "steps", "site", "settings"], groups - assert page.evaluate( - "document.querySelector('.app-drawer__steps').firstElementChild === window.__steps" - ) - active = page.evaluate( - "document.querySelector('.app-drawer__steps .ui-workflow-layout__step.is-active')?.textContent" - ) - assert active and "상세" in active, active - # 378-1: 홈페이지 메뉴 = 접힘 기본 · summary 누름 = 펼침 + 서랍 열린 채 · 서랍 폭 < 패널 · 바탕 ≠ 패널 - site = "document.querySelector('.app-drawer__site')" - assert page.evaluate(f"!{site}.open") - page.evaluate(f"{site}.querySelector('summary').click()") - assert page.evaluate(f"{site}.open") - assert page.evaluate( - "document.querySelector('.app-header').classList.contains('is-drawer-open')" - ) - page.evaluate(f"{site}.querySelector('summary').click()") - assert page.evaluate(f"!{site}.open") - widths = page.evaluate( - "[document.querySelector('.app-drawer')," - " document.querySelector('.ui-workflow-overlay__panel--title')]" - ".map(e => Math.round(e.getBoundingClientRect().width))" - ) - assert widths[0] <= 240 and widths[0] < widths[1], widths - bgs = page.evaluate( - "[document.querySelector('.app-drawer')," - " document.querySelector('.ui-workflow-overlay__panel--title')]" - ".map(e => getComputedStyle(e).backgroundColor)" - ) - assert bgs[0] != bgs[1], bgs - page.evaluate("document.querySelector('.app-drawer__scrim').click()") # 닫기 = 제자리 - assert page.evaluate( - "window.__steps.closest('.ui-workflow-overlay__panel--progress') !== null" - ) - page.evaluate("document.querySelector('.app-burger').click()") - page.evaluate( - "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step.is-enabled')]" - ".find(b => b.textContent.includes('수량')).click()" - ) - page.wait_for_function("location.hash.includes('b08-quantity')", timeout=10000) - assert not page.evaluate( - "document.querySelector('.app-header')?.classList.contains('is-drawer-open')" - ) - - # ③ - audit._open(page, "a01-home") - assert page.evaluate(OUTLET_TOP) == 64 - assert page.evaluate(BURGER)[4] >= 390 - 16 - - # ④ - desk = _ctx(browser, cred, audit.DESKTOP).new_page() - audit._open(desk, "b07-design-detail") - assert desk.evaluate(OUTLET_TOP) == 0 - assert desk.evaluate(BURGER)[:4] == [8, 8, 36, 36] - assert desk.evaluate(PROGRESS_HIDDEN) - # 389-1: ☰ 중심 = 좌측 패널 제목 줄 중심 - assert desk.evaluate(TITLE_ROW_CY) == 26 - head_nav = desk.evaluate(LABELS, ".app-nav button") - head_actions = desk.evaluate(LABELS, ".app-actions button") - assert head_nav and head_actions - desk.click(".app-burger") - assert desk.evaluate(LABELS, ".app-drawer__nav button") == head_nav - drawer_all = desk.evaluate(LABELS, ".app-drawer button") - assert all(label in drawer_all for label in head_actions), (head_actions, drawer_all) - assert desk.evaluate( - "document.querySelector('.app-drawer__steps .ui-workflow-layout__step.is-active') !== null" - ) - desk.keyboard.press("Escape") - assert not desk.evaluate( - "document.querySelector('.app-header').classList.contains('is-drawer-open')" - ) - - # ⑤ - audit._open(desk, "a01-home") - assert desk.evaluate(OUTLET_TOP) == 64 - assert not desk.evaluate("document.querySelector('.app-burger').getClientRects().length") - browser.close() - - -def test_352_4_burger_in_panel_title_row_text(): - root = Path(__file__).resolve().parents[2] - shell = (root / "A00_Common/app_shell.ts").read_text(encoding="utf-8") - open_panel = ( - "html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger {" - ) - assert open_panel in shell # 패널 펼침 = 제목 줄에 든 모양 - body = shell.split(open_panel, 1)[1].split("}", 1)[0] - for line in ("border-color: transparent;", "background: transparent;", "box-shadow: none;"): - assert line in body - # 자리(8,8 · 36)는 그대로 — 접으면 아래 기본 규칙(떠 있는 단추)으로 돌아감 - base = shell.split("html.is-bm-route .app-burger {", 1)[1].split("}", 1)[0] - assert "position: fixed;" in base and "left: var(--spacing-8);" in base - # 389-1: 마우스 제목 줄 높이 = 8×2 + ☰ 높이(36) = 52 → 중심 26 = ☰ 중심 - overlay_css = (root / "ui_template/ui_template_overlay.css").read_text(encoding="utf-8") - title_row = overlay_css.split( - "html.is-bm-route .ui-workflow-overlay__panel--title > .ui-workflow-overlay__header {", 1 - )[1].split("}", 1)[0] - assert "min-height: calc(var(--spacing-8) * 2 + var(--app-burger-w));" in title_row - # 389-2: 서랍 8/12 체계 — 묶음 사이 12 + 선 + 12 · 로고 줄 = ☰ 높이 가운데 - assert ( - "gap: var(--spacing-12);" in shell.split(".app-header.is-drawer-open .app-drawer {", 1)[1] - ) - assert "padding-top: var(--spacing-12);" in shell.split(".app-drawer__group + ", 1)[1] - assert ( - "min-height: var(--app-burger-w);" - in shell.split("html.is-bm-route .app-drawer__brand {", 1)[1] - ) - assert "align-self: flex-end" not in shell - # 437-1: 진행 단계 단추 = 서랍 안쪽 폭(is-vertical width:100% 를 이기는 선택자) · 긴 글 줄임표 - assert ( - ".app-drawer__steps .ui-workflow-layout__steps.is-vertical .ui-workflow-layout__step {" - in shell - ) - assert "text-overflow: ellipsis;" in shell.split(".app-drawer :is(.ui-btn__label", 1)[1] - # 378-1: 프로젝트 화면 서랍 오른쪽 가장자리 = 강조색 2 px - assert "border-right: 2px solid var(--color-accent);" in shell - overlay = (root / "ui_template/ui_template_overlay.css").read_text(encoding="utf-8") - project = overlay.split(".ui-workflow-overlay__project {", 1)[1].split("}", 1)[0] - assert "margin-left: auto;" in project diff --git a/resources/tester/test_spreadsheet_touch.py b/resources/tester/test_spreadsheet_touch.py deleted file mode 100644 index 7c51e5620..000000000 --- a/resources/tester/test_spreadsheet_touch.py +++ /dev/null @@ -1,128 +0,0 @@ -"""스프레드시트 손가락(PLAN_기기대응 4-2 · `spreadsheet_mouse.ts` · `_editor.ts` · `_grid.ts`) — 실제 브라우저 터치. - -sub1 화면 서버(5174)에 견본 통합문서를 붙여 CDP 터치를 넣음: -① 톡 = 칸 고름 · 화면 자판 안 뜸(inputMode none) ② 고른 칸에서 끌기 = 범위 -③ 안 고른 칸에서 끌기 = 고름 그대로(화면 밀기) ④ 두 번 톡 = 고침 · 자판 뜸(inputMode text) -⑤ ⋯ 단추(터치 모드) = 칸 메뉴 ⑥ 자판 없는 단추 줄 · 🔍 = 찾기 -""" - -import urllib.request - -import pytest - -from helper_verify_project import own_ports # noqa: E402 - -BASE = own_ports()[1] # 화면 서버 = OWNERS.md 이 폴더 줄 - -MOUNT = """ -async () => { - const m = await import('/spreadsheet/spreadsheet.ts'); - const book = await (await fetch('/spreadsheet/spreadsheet_sample.json')).json(); - const host = document.createElement('div'); - Object.assign(host.style, { position: 'fixed', inset: '0', zIndex: 99999, background: '#fff', display: 'flex' }); - document.body.append(host); - window.__sel = null; - window.__ss = m.createSpreadsheet(host, book, { onSelect: (s) => (window.__sel = s) }); - await new Promise((r) => setTimeout(r, 500)); - const g = document.querySelector('.aislo-grid').getBoundingClientRect(); - return [g.left, g.top, g.width, g.height]; -} -""" - - -def _up() -> bool: - try: - urllib.request.urlopen(BASE, timeout=2) - return True - except Exception: - return False - - -def _touch(cdp, kind, points): - cdp.send( - "Input.dispatchTouchEvent", - { - "type": kind, - "touchPoints": [{"x": x, "y": y, "id": i} for i, (x, y) in enumerate(points)], - }, - ) - - -@pytest.mark.skipif(not _up(), reason="sub1 화면 서버(5174) 꺼짐") -def test_spreadsheet_touch_select_range_edit(): - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - ctx = browser.new_context( - viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True - ) - page = ctx.new_page() - page.goto(f"{BASE}/#/a01-home") - page.wait_for_timeout(1000) - gx, gy, gw, gh = page.evaluate(MOUNT) - cdp = ctx.new_cdp_session(page) - sel = lambda: page.evaluate("window.__sel") # noqa: E731 - ime = lambda: page.evaluate("document.activeElement && document.activeElement.inputMode") # noqa: E731 - - def tap(x, y): - _touch(cdp, "touchStart", [(x, y)]) - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(450) - - def drag(x0, y0, x1, y1): - _touch(cdp, "touchStart", [(x0, y0)]) - for i in range(1, 7): - _touch(cdp, "touchMove", [(x0 + (x1 - x0) * i / 6, y0 + (y1 - y0) * i / 6)]) - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(450) - - # ⑥ 자판 없는 단추 줄(4-3) — 6개 보임 · 🔍 = 찾기 칸 열림 - n = page.evaluate( - "[...document.querySelectorAll('.ss-touch-keys > button')].filter(b => b.getClientRects().length).length" - ) - assert n == 6, n - page.evaluate("document.querySelector('.ss-touch-keys > button').click()") - page.wait_for_function( # 찾기 칸은 늦게 불러옴(크기 예산) - "() => [...document.querySelectorAll('.ss-find__input')].some(e => e.getClientRects().length)", - timeout=10000, - ) - page.keyboard.press("Escape") - page.evaluate("document.activeElement.blur()") - gx, gy, gw, gh = page.evaluate( # 찾기 칸이 자리를 먹어 격자가 옮겨졌을 수 있음 - "(() => { const g = document.querySelector('.aislo-grid').getBoundingClientRect();" - " return [g.left, g.top, g.width, g.height]; })()" - ) - - a = (gx + 90, gy + 60) - tap(*a) # ① 고름 - first = sel() - assert first and ":" not in first["범위"], first - assert ime() == "none", ime() - - drag(*a, a[0] + 120, a[1] + 60) # ② 고른 칸에서 끌기 = 범위 - ranged = sel() - assert ":" in ranged["범위"] and ranged["범위"].startswith(first["칸"]), (first, ranged) - - tap(gx + 90, gy + 260) # 다른 칸 고름 - before = sel() - drag(gx + 200, gy + 330, gx + 200, gy + 200) # ③ 안 고른 칸 끌기 = 고름 그대로 - assert sel() == before, (before, sel()) - - b = (gx + 90, gy + 260) - _touch(cdp, "touchStart", [b]) # ④ 두 번 톡 = 고침 - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(120) - _touch(cdp, "touchStart", [b]) - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(500) - assert ime() == "text", ime() - page.evaluate("document.activeElement.blur()") - - page.evaluate("document.querySelector('.ss-toolbar__more').click()") # ⑤ ⋯ = 칸 메뉴 - page.wait_for_timeout(300) - assert page.evaluate( - "[...document.querySelectorAll('.ui-ctx-menu')].some(m => !m.hidden && m.getClientRects().length)" - ) - - browser.close() diff --git a/resources/tester/test_touch_bridge.py b/resources/tester/test_touch_bridge.py deleted file mode 100644 index 98a5bd50d..000000000 --- a/resources/tester/test_touch_bridge.py +++ /dev/null @@ -1,154 +0,0 @@ -"""손가락 → 마우스 번역(`A00_Common/touch_bridge.ts` · PLAN_기기대응 3-1) — 실제 브라우저 터치. - -sub1 화면 서버(5174) 첫 화면을 터치 기기로 열고 시험용 상자에 CDP 터치를 넣어 봄: -① 길게 누름 → contextmenu 한 번 · 뒤따르는 click 없음 ② 짧은 톡 → contextmenu 없음 -③ 두 번 톡 → dblclick 한 번 ④ 두 손가락 벌림 → Ctrl+휠(deltaY < 0) · 오므림 → deltaY > 0 -⑤ [data-touch-own] 상자는 번역 안 함 ⑥ 마우스 우클릭 · 더블클릭은 그대로(번역이 끼지 않음) -⑦ 고름 반경 `pickPx` — 손가락이면 24px 이상 · 마우스면 그대로 -""" - -import json -import os -import urllib.request - -import pytest - -from helper_verify_project import own_ports # noqa: E402 - -BASE = os.environ.get("AISLO_UI_BASE", own_ports()[1]) # 다른 창 서버 = 환경값 - - -def _up() -> bool: - try: - urllib.request.urlopen(BASE, timeout=2) - return True - except Exception: - return False - - -SETUP = """ -() => { - window.__log = []; - for (const id of ['tb-box', 'tb-own']) { - const b = document.createElement('div'); - b.id = id; - Object.assign(b.style, { position: 'fixed', left: id === 'tb-box' ? '20px' : '220px', top: '120px', - width: '180px', height: '180px', background: '#eee', zIndex: 99999, touchAction: 'none' }); - if (id === 'tb-own') b.dataset.touchOwn = 'x'; - for (const t of ['contextmenu', 'click', 'dblclick', 'wheel']) - b.addEventListener(t, (e) => { - window.__log.push({ id, t, ctrl: !!e.ctrlKey, dy: e.deltaY || 0, trusted: e.isTrusted }); - if (t === 'contextmenu' || t === 'wheel') e.preventDefault(); - }, { passive: false }); - document.body.append(b); - } -} -""" - - -def _touch(cdp, kind, points): - cdp.send( - "Input.dispatchTouchEvent", - { - "type": kind, - "touchPoints": [{"x": x, "y": y, "id": i} for i, (x, y) in enumerate(points)], - }, - ) - - -def _log(page): - out = page.evaluate("() => window.__log.splice(0)") - return [(e["id"], e["t"]) for e in out], out - - -@pytest.mark.skipif(not _up(), reason="sub1 화면 서버(5174) 꺼짐") -def test_touch_bridge_translates_finger_gestures(): - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - browser = p.chromium.launch(channel="msedge", headless=True) - ctx = browser.new_context( - viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True - ) - page = ctx.new_page() - page.goto(f"{BASE}/#/a01-home") - page.wait_for_timeout(1500) - assert page.evaluate("() => document.documentElement.dataset.touch") == "on" - page.evaluate(SETUP) - cdp = ctx.new_cdp_session(page) - - # ① 길게 누름 - _touch(cdp, "touchStart", [(100, 200)]) - page.wait_for_timeout(700) - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(500) - names, _ = _log(page) - assert names.count(("tb-box", "contextmenu")) == 1, names - assert ("tb-box", "click") not in names, names - - # ② 짧은 톡 - page.wait_for_timeout(400) - _touch(cdp, "touchStart", [(100, 200)]) - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(700) - names, _ = _log(page) - assert ("tb-box", "contextmenu") not in names, names - assert ("tb-box", "click") in names, names - - # ③ 두 번 톡 - page.wait_for_timeout(400) - for _ in range(2): - _touch(cdp, "touchStart", [(100, 200)]) - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(80) - page.wait_for_timeout(600) - names, _ = _log(page) - assert names.count(("tb-box", "dblclick")) == 1, names - - # ④ 두 손가락 벌림 · 오므림 - def pinch(box_x, start, end): - _touch(cdp, "touchStart", [(box_x - start, 210), (box_x + start, 210)]) - steps = 8 - for i in range(1, steps + 1): - d = start + (end - start) * i / steps - _touch(cdp, "touchMove", [(box_x - d, 210), (box_x + d, 210)]) - _touch(cdp, "touchEnd", []) - page.wait_for_timeout(200) - return _log(page) - - names, raw = pinch(110, 20, 80) - wheels = [e for e in raw if e["t"] == "wheel" and e["id"] == "tb-box"] - assert wheels and all(e["ctrl"] and e["dy"] < 0 for e in wheels), raw - names, raw = pinch(110, 80, 20) - wheels = [e for e in raw if e["t"] == "wheel" and e["id"] == "tb-box"] - assert wheels and all(e["ctrl"] and e["dy"] > 0 for e in wheels), raw - - # ⑤ 자체 처리 상자 — 길게 누름 · 벌림 번역 없음 - page.wait_for_timeout(400) - _touch(cdp, "touchStart", [(300, 200)]) - page.wait_for_timeout(700) - _touch(cdp, "touchEnd", []) - names, raw = pinch(310, 20, 70) - assert not [e for e in raw if not e["trusted"] and e["id"] == "tb-own"], raw - - # ⑦ 고름 반경(4-4) — 손가락으로 누른 뒤 = 접촉 크기(최소 24) · 마우스로 누른 뒤 = 그대로 - # 앱이 불러온 그 주소(vite 가 ?t= 를 붙였을 수 있음) — 다른 사본이면 상태가 따로 - pick = ( - "async () => (await import(performance.getEntriesByType('resource')" - ".map((e) => e.name).find((n) => n.includes('/touch_bridge.ts')))).pickPx(4)" - ) - _touch(cdp, "touchStart", [(100, 200)]) - _touch(cdp, "touchEnd", []) - assert page.evaluate(pick) >= 24 - page.mouse.click(100, 400) - assert page.evaluate(pick) == 4 - page.wait_for_timeout(400) - _log(page) - - # ⑥ 마우스 — 우클릭 · 더블클릭은 브라우저 것 하나씩만 - page.mouse.click(100, 200, button="right") - page.mouse.dblclick(100, 200) - page.wait_for_timeout(500) - _, raw = _log(page) - assert all(e["trusted"] for e in raw), json.dumps(raw) - browser.close()