"""PLAN 504-2 (요구 309-2 · 310) — 계산 자리 판 · 시스템 관리자 창구 · G1 · D3 연결. 못박는 것 - 묶음 12 줄 = 등록부 # 전부(45 · 겹침 없음) · 바꿀 수 있는 줄(G1 · D3) = browser_now · 고정 줄 = 그 자리. - 커밋한 판 = 기본값(전부 both = 등록부 그대로) · 판 없음 · 깨짐 = 기본값. - GET = 로그인 · PUT = 시스템 관리자만(ADMIN · USER 403) · 잠금 · 모르는 줄 · 값 = 400 · 바뀐 줄마다 시스템 로그 CALC_SITE_CHANGE 한 줄 · 같은 값 = 판 · 로그 그대로. - ground 단계 = 판(serverOnly) 따라 격자 · 서버 길 · cross 의 격자는 그대로 · D3 = 워커 안 띄움. - 카드 = 시스템 관리자 갈래에만. ./venv/Scripts/python.exe -m pytest resources/tester/test_309_2_calc_sites.py -q """ from __future__ import annotations import json import re import shutil import subprocess import sys from pathlib import Path import pytest 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 B01_Dashboard import B01_Dashboard_Router as dash # noqa: E402 from common_util import common_util_calc_sites as calc_sites # noqa: E402 from common_util.common_util_auth import verify_session # noqa: E402 REGISTRY = ( ROOT / "A00_Common/design/logic/design_calc_registry.ts", ROOT / "A00_Common/design/logic/design_calc_registry_facility.ts", ) TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" def _text(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") def _registry_sites() -> dict[str, str]: pairs: dict[str, str] = {} for path in REGISTRY: src = path.read_text(encoding="utf-8") for no, site in re.findall(r'no: "(\w+)",[\s\S]*?site: "(\w+)"', src): pairs[no] = site return pairs def test_묶음_여덟_줄_등록부_전부(): sites = _registry_sites() assert len(sites) == 45 nos = [no for group in calc_sites.GROUPS for no in group["nos"]] assert len(calc_sites.GROUPS) == 13 # 511-B 단계 다섯 · F9 폼 기본값 · 511-C haul assert len(nos) == len(set(nos)) and set(nos) == set(sites) assert calc_sites.SWITCHABLE == { "ground_sample", "corridor_mesh", "quantity", "carry", "route", "follow", "stations", "profile", # 511-B B-3 엶 "cross", # 511-B B-4 끝에 엶 "haul", # 511-C E2 엶(기본 server) "fixed_browser", } # 511-A 셋 더 · 511-B B-2 stations · B-5 route for group in calc_sites.GROUPS: got = {sites[no] for no in group["nos"]} if group["fixed"] == "browser": assert got == {"browser_only"}, group["key"] elif group["fixed"] == "server" or group.get("default") == "server": assert got <= {"server_canon", "server_only"}, group["key"] else: assert got == {"browser_now"}, group["key"] by_key = {g["key"]: g["nos"] for g in calc_sites.GROUPS} assert by_key["ground_sample"] == ["G1"] and by_key["corridor_mesh"] == ["D3"] def test_커밋한_판_기본값_없음_깨짐(tmp_path, monkeypatch): committed = json.loads(calc_sites.PATH.read_text(encoding="utf-8")) assert calc_sites.DEFAULTS == {**dict.fromkeys(calc_sites.SWITCHABLE, "both"), "haul": "server"} assert set(committed["sites"].items()) <= set(calc_sites.DEFAULTS.items()) assert calc_sites.read_sites()["sites"] == calc_sites.DEFAULTS # 판 파일 haul 없음 = server monkeypatch.setattr(calc_sites, "PATH", tmp_path / "none.json") assert calc_sites.read_sites() == {"version": 0, "sites": calc_sites.DEFAULTS} broken = tmp_path / "broken.json" broken.write_text('{"sites": {"ground_sample": "server", "corridor_mesh": "x"', "utf-8") monkeypatch.setattr(calc_sites, "PATH", broken) assert calc_sites.read_sites()["sites"] == calc_sites.DEFAULTS odd = tmp_path / "odd.json" odd.write_text('{"sites": {"haul": "x", "quantity": "server"}}', "utf-8") monkeypatch.setattr(calc_sites, "PATH", odd) assert calc_sites.read_sites()["sites"] == {**calc_sites.DEFAULTS, "quantity": "server"} class _Cursor: def __init__(self, log: list): self.log = log async def execute(self, sql, args=None): self.log.append((sql, args)) async def __aenter__(self): return self async def __aexit__(self, *exc): return False class _Conn(_Cursor): def cursor(self): return _Cursor(self.log) async def commit(self): self.log.append(("COMMIT", None)) class _Pool: def __init__(self, log: list): self.log = log def acquire(self): return _Conn(self.log) @pytest.fixture def client(tmp_path, monkeypatch): board = tmp_path / "calc_sites.json" board.write_text(calc_sites.PATH.read_text(encoding="utf-8"), "utf-8") monkeypatch.setattr(calc_sites, "PATH", board) log: list = [] monkeypatch.setattr(dash, "get_db_pool", lambda: _Pool(log)) app = FastAPI() app.include_router(dash.router) role = {"role": "SYSTEM_ADMIN"} app.dependency_overrides[verify_session] = lambda: {"role": role["role"], "user_id": 7} return TestClient(app), role, board, log def test_읽기_로그인_쓰기_시스템_관리자만(client): http, role, board, log = client before = board.read_text("utf-8") for name in ("USER", "ADMIN"): role["role"] = name got = http.get("/api/dashboard/calc-sites") assert got.status_code == 200 assert got.json()["sites"]["ground_sample"] == "both" assert [g["key"] for g in got.json()["groups"]][:2] == ["ground_sample", "corridor_mesh"] put = http.put( "/api/dashboard/admin/calc-sites", json={"sites": {"ground_sample": "server"}} ) assert put.status_code == 403, name assert board.read_text("utf-8") == before and log == [] def test_잠금_모르는_줄_400_감사_한_줄(client): http, _role, board, log = client url = "/api/dashboard/admin/calc-sites" for bad in ({"form_default": "server"}, {"fixed_server": "both"}, {"nope": "server"}): assert http.put(url, json={"sites": bad}).status_code == 400, bad assert http.put(url, json={"sites": {"ground_sample": "browser"}}).status_code == 400 assert log == [] done = http.put(url, json={"sites": {"ground_sample": "server"}}) assert done.status_code == 200 and done.json()["changed"] == ["ground_sample"] saved = json.loads(board.read_text("utf-8")) sites = {**calc_sites.DEFAULTS, "ground_sample": "server"} assert saved == {"version": 2, "sites": sites} inserts = [args for sql, args in log if "system_audit_logs" in sql] assert len(inserts) == 1 assert inserts[0][:5] == (7, "CALC_SITE_CHANGE", "calc_site", None, "ground_sample=server") assert log[-1][0] == "COMMIT" # 같은 값 = 판 번호 · 로그 그대로 again = http.put(url, json={"sites": {"ground_sample": "server"}}) assert again.json()["changed"] == [] and json.loads(board.read_text("utf-8"))["version"] == 2 assert len([1 for sql, _ in log if "system_audit_logs" in sql]) == 1 # 되돌림 http.put(url, json={"sites": {"ground_sample": "both"}}) assert json.loads(board.read_text("utf-8"))["sites"]["ground_sample"] == "both" _RUNNER = """ import { createGroundStage } from "./B05_Profile/logic/B05_Profile_Pipeline_RouteGround.js"; const axis = Float64Array.from({ length: 61 }, (_, i) => i); const grid = { x: axis, y: axis, z: new Float32Array(61 * 61).fill(5), validBits: new Uint8Array(Math.ceil((61 * 61) / 8)).fill(255) }; const run = async (serverOnly) => { const calls = []; const stage = createGroundStage({ grid: () => ({ grid, design: "las" }), remote: async (points) => { calls.push(points.length); return points.map(() => 9); }, ...(serverOnly === undefined ? {} : { serverOnly }), }); const station = { key: "k0", chainageM: 0, x: 30, y: 30, azimuthDeg: 0, left: [0, 1], kind: "grid", id: null, ground: null }; const out = await stage({ generation: 1, range: null, signal: new AbortController().signal, inputs: { route: null }, out: { stations: [station], route: null }, before: {}, prev: null, }); return { calls: calls.length, center: out.stations[0].ground.centerZ }; }; console.log(JSON.stringify([ await run(undefined), await run(() => false), await run(async () => true), await run(() => true), ])); """ @pytest.mark.skipif(shutil.which("node") is None or not TSC.exists(), reason="node · tsc 없음") def test_ground_판_따라_길_고름(tmp_path): out = tmp_path / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(TSC), str(ROOT / "B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts"), "--outDir", str(out), "--rootDir", str(ROOT), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig"], cwd=str(ROOT), check=True, capture_output=True, ) # fmt: skip for emitted in out.rglob("*.js"): emitted.write_text( re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), encoding="utf-8", ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") result = subprocess.run( # noqa: S603 ["node", str(out / "runner.mjs")], capture_output=True, text=True, timeout=60 ) assert result.returncode == 0, result.stderr grid, off, server_async, server = json.loads(result.stdout) assert grid == off == {"calls": 0, "center": 5} # 판 없음 · both = 격자 assert server_async == server and server["calls"] == 1 and server["center"] == 9 def test_연결_자리_cross_격자_그대로(): deps = _text("B05_Profile/B05_Profile_Api_TerrainGrid.ts") assert "grid: () => designTerrainGridNow(projectId)," in deps # cross 가 같이 쓰는 격자 assert 'serverOnly: () => calcOnServer("ground_sample"),' in deps stage = _text("B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts") assert "const local = (await deps.serverOnly?.()) ? null : deps.grid();" in stage page = _text("A00_Common/design/design_pipeline_page.ts") assert len(page.splitlines()) <= 699 assert "createCrossStage({ grid: groundDeps.grid })" in page assert "calc_sites" not in page and "calcOnServer" not in page worker = _text("B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts") body = worker.split("export async function buildCorridorOffThread(")[1] # 508-2 — D3 = 서버면 워커 대신 서버 창구가 지음(옛 기대 = 안 지음 null) assert 'if (await calcOnServer("corridor_mesh"))' in body assert body.index("return buildOnServer(") < body.index("getWorker()") assert "void openCalcSites(projectId);" in _text("A00_Common/design/design_page.ts") # 저장 길은 판을 안 읽음 for rel in ("A00_Common/design/design_save.ts", "A00_Common/design/design_save_api.ts"): assert "calc_sites" not in _text(rel) and "calcOnServer" not in _text(rel) def test_카드_시스템_관리자만_글_다_있음(): nav = _text("A00_Common/m_settings_nav.ts") links = nav.split("export function mountSettingsDrawer")[0] admin, rest = links.split('if (user.role === "SYSTEM_ADMIN") {')[1].split("// 회사 수정 =") assert 'key: "calcSites"' in admin and "calcSites" not in rest assert 'settingLinks(user, companyId).filter((link) => link.key !== "calcSites")' in nav page = _text("B01_Dashboard/B01_Dashboard_UI_Page.ts") assert 'link.key === "calcSites" ? void openCalcSitesDialog()' in page locale = _text("ui_template/ui_template_locale_m1.ts") for group in calc_sites.GROUPS: assert f"B01_CalcSites_{group['key']}:" in locale, group["key"] assert f"B01_CalcSites_Why_{group['why'] or group['key']}:" in locale, group["key"] ui = _text("B01_Dashboard/B01_Dashboard_UI_CalcSites.ts") assert 'check(group.fixed !== "browser", true)' in ui # 서버 정본 칸 = 늘 잠금 assert "saveCalcSites(changes)" in ui and "dirty()" in ui # 바뀐 줄만 PUT assert "CALC_REGISTRY" in ui and "group.nos.map" in ui # 묶음 머리 아래 계산 줄(등록부 label)