chore(시험정리): 1 차 66 파일 지움 — 목록 docs/raw/조사/2026-10-23_시험정리_1차_목록.md
This commit is contained in:
@@ -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)}")
|
||||
@@ -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())
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"] }),
|
||||
}),
|
||||
);
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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 안
|
||||
@@ -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 # 바로 바깥은 남는다
|
||||
@@ -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 <name>(` 부터 다음 최상위 `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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -1,57 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""346-3 노선 저장 실경로 시간 — 검증 프로젝트의 지금 노드 · 곡선 그대로 저장 한 번(수동 실행).
|
||||
|
||||
같은 노선이라 값은 그대로(새 노선 행 번호만 바뀜) · 초기값은 안 건드림(`as_initial` 끔).
|
||||
쓰는 법: `./venv/Scripts/python.exe resources/tester/run_346_3_route_save_timing.py <project_id>`
|
||||
"""
|
||||
|
||||
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])))
|
||||
@@ -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 <id> [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))
|
||||
@@ -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())
|
||||
@@ -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<string>(); 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()
|
||||
@@ -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
|
||||
@@ -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 = (
|
||||
'<!doctype html><body style="margin:0"><div id="m" style="width:1200px;height:800px;'
|
||||
'background:#E60046"></div><script>window.__done = true</script></body>'
|
||||
)
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 = (
|
||||
"<div style='width:320px'>"
|
||||
"<section class='b05-route__panel-section b05-structure-section ui-collapsible"
|
||||
" ui-sidebar-section ui-panel-form'><div class='b05-route__irregular-actions'>"
|
||||
"<button class='b05-route__irregular-btn is-primary'>추가</button>"
|
||||
"<button class='b05-route__irregular-btn is-danger' id='top'>삭제</button>"
|
||||
"<button class='b05-route__irregular-btn'>리셋</button></div></section>"
|
||||
"<div class='b05-route__dock ui-sidebar-actions'><ul class='b05-route__irregular-list'>"
|
||||
"<li class='b05-route__irregular-item'><strong>NO.1</strong><span>배수관</span></li>"
|
||||
"<li class='b05-route__irregular-clear'><button type='button'"
|
||||
" class='b05-route__irregular-btn is-danger' id='clear'>구조물 전체 삭제</button></li>"
|
||||
"</ul></div></div>"
|
||||
)
|
||||
|
||||
|
||||
@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"<!doctype html><html data-theme='{theme}'><head><style>{css}</style></head>"
|
||||
f"<body>{MARKUP}</body></html>"
|
||||
)
|
||||
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]
|
||||
@@ -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] # 관 수 다르면 그대로
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 = """
|
||||
<div class="design-page__main" data-view="cross" style="height:900px">
|
||||
<div class="design-page__center"><div class="design-page__view">
|
||||
<div class="b06-section"><div class="b06-section__cards">
|
||||
<div class="b06-cross-card" style="height:2000px"></div>
|
||||
</div></div>
|
||||
</div></div>
|
||||
<div class="design-page__profile">
|
||||
<section class="b05-route-profile" style="--b05-profile-height:600px">
|
||||
<div class="b05-route-profile__content">
|
||||
<div class="b05-route-profile__body-wrap"><div class="b05-route-profile__body"></div></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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"<style>body{{margin:0}}{css}</style>{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
|
||||
@@ -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 = """<div class="b05-profile__{k}-handle" style="bottom:280px">
|
||||
<button class="ui-workflow-overlay__handle ui-workflow-overlay__handle--bottom is-labeled">
|
||||
<span class="ui-workflow-overlay__handle-icon"></span>
|
||||
<span class="ui-workflow-overlay__handle-label">{label}</span></button></div>"""
|
||||
HTML = (
|
||||
"""
|
||||
<div class="b05-route-profile__body-wrap" style="display:flex;width:{w}px;height:520px">
|
||||
<div class="b05-profile__masshaul-overlay">
|
||||
<div class="b05-profile__masshaul-scroll"></div>
|
||||
<div class="b05-profile__masshaul-legend"><div class="b06-masshaul__legend">
|
||||
<button class="b06-masshaul__legend-item b06-masshaul__legend-item--reset">도형 위치 초기화</button>
|
||||
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>횡단 기준</span></button>
|
||||
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>토량 분배</span></button>
|
||||
</div></div>
|
||||
</div>
|
||||
"""
|
||||
+ HANDLE.replace("{k}", "masshaul").replace("{label}", "유토곡선")
|
||||
+ HANDLE.replace("{k}", "table").replace("{label}", "테이블")
|
||||
+ "\n</div>"
|
||||
)
|
||||
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"<style>body{{margin:0}}{css}</style>{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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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) 아님
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"]) # 측점 열쇠는 같음
|
||||
@@ -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 판만 서버를 부름
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user