- 저장 모듈 D03_PlanAnalysis_Store — 읽기 · 원자 쓰기 · 판 · 옛 DB 칸 읽을 때 옮김(지점 셋 꼴 · 연도 행 접기 · 옛 연도 노선) · plan_state(조각 · 연도 지문 · 확정 = 지문 같을 때만) - D03 [저장] · [초기화] · D02 지점 = 파일만 + 연도 상태 줄(지문 · 확정) · 프로젝트 잠금 · 파일 쓰기 실패 = DB 되돌림 · 빈 연도 줄 지움 - D04 요약 · 업체 지정 · 확정 메일 · 내보내기 · 대시보드 [연결] · 받은 예정노선 · 산림청 홈 · 개요 연결 = 파일에서 · 업체 줄 연도 칸 안 읽음(⑤) - 산림청 홈 「분석 전」 = 노선 안 그은 예정노선 프로젝트 수 · plan-source 지운 원본 거름 - 옛 연도 단위 지정 · 옛 연도 행 노선 길 걷음(DB 실측 0) · 연결 때 설계속도 씨앗 걷음(591-1 이음) - SQL 023 = 업체 줄 year NULL 허용(옛 칸은 590-3 에서 지움) - 시험: 새 지킴 590_1(옮김 · 원자 쓰기 · 연도 읽기 · 잠금) · 낡은 시험 고침 — 529_3 · 530_2 · 531_3 · 531_d04 · 547_2 · 563_2(DB 조각 · 연도 행 흉내 → 파일 흉내 · 옛 연도 지정 시험 · 설계속도 씨앗 시험 지움 = 그 길 걷음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
7.2 KiB
Python
190 lines
7.2 KiB
Python
"""547-2 대시보드 홈 — 개수별 꼴 고르기(TS logic) · 역할 가르기 · 역할별 홈 API 칸."""
|
|
|
|
import asyncio
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import B01_Dashboard.B01_Dashboard_Router_Home as home
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
|
LOGIC = ROOT / "B01_Dashboard" / "logic" / "B01_Dashboard_Home_Layout.ts"
|
|
|
|
_RUNNER = """
|
|
import { writeFileSync } from "node:fs";
|
|
import * as m from "./B01_Dashboard_Home_Layout.js";
|
|
const counts = [0, 1, 2, 3, 4, 8, 9, 40];
|
|
const out = {
|
|
auto: counts.map((n) => m.homeLayout(n)),
|
|
small: counts.map((n) => m.homeLayout(n, "small")),
|
|
large: counts.map((n) => m.homeLayout(n, "large")),
|
|
buckets: m.stageBuckets([
|
|
{ stage_index: 0, stage_total: 7 }, { stage_index: 3, stage_total: 7 },
|
|
{ stage_index: 6, stage_total: 7 }, { stage_index: 2, stage_total: 3 },
|
|
]),
|
|
stale: [m.isStale({ stage_index: 1, stage_total: 7, stale_days: 13 }),
|
|
m.isStale({ stage_index: 1, stage_total: 7, stale_days: 14 })],
|
|
count: m.countBy(["나", "가", "나"], (x) => x),
|
|
rest: m.topWithRest([5, 4, 3, 2, 1, 1].map((c, i) => ({ label: "c" + i, count: c })), 5),
|
|
donut: m.donutArcs([1, 1, 2]),
|
|
};
|
|
writeFileSync(process.argv[2], JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def ts(tmp_path_factory):
|
|
if not TSC.is_file():
|
|
pytest.skip("프론트엔드 의존성 미설치")
|
|
tmp = tmp_path_factory.mktemp("home")
|
|
out = tmp / "js"
|
|
args = ["--outDir", str(out), "--module", "esnext", "--target", "es2022", "--ignoreConfig"]
|
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
["node", str(TSC), str(LOGIC), *args], cwd=str(ROOT), check=True, capture_output=True
|
|
)
|
|
for emitted in out.glob("*.js"):
|
|
text = emitted.read_text(encoding="utf-8")
|
|
emitted.write_text(re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', text), encoding="utf-8")
|
|
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
|
dst = tmp / "out.json"
|
|
subprocess.run(["node", str(out / "runner.mjs"), str(dst)], check=True, capture_output=True) # noqa: S603, S607
|
|
return json.loads(dst.read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_layout_by_count(ts):
|
|
# 개수 0 1 2 3 4 8 9 40
|
|
assert ts["auto"] == ["empty", "one", "few", "few", "list", "list", "many", "many"]
|
|
assert ts["small"] == ["empty", "list", "list", "list", "list", "list", "many", "many"]
|
|
assert ts["large"] == ["empty", "one", "few", "few", "few", "few", "few", "few"]
|
|
|
|
|
|
def test_counts_and_charts(ts):
|
|
assert ts["buckets"] == {"start": 1, "working": 1, "last": 2}
|
|
assert ts["stale"] == [False, True]
|
|
assert ts["count"] == [{"label": "나", "count": 2}, {"label": "가", "count": 1}]
|
|
assert ts["rest"][-1] == {"label": "", "count": 2, "rest": 2} and len(ts["rest"]) == 5
|
|
assert [a["dash"] for a in ts["donut"]] == ["25 75", "25 75", "50 50"]
|
|
assert [a["offset"] for a in ts["donut"]] == [25, 0, -25]
|
|
|
|
|
|
def test_home_role_order_matches_shell():
|
|
s = {"email": "a@forest.go.kr"}
|
|
assert home.home_role({**s, "role": "SYSTEM_ADMIN"}) == "SYSTEM_ADMIN"
|
|
assert home.home_role({**s, "role": "ADMIN"}) == "ADMIN"
|
|
assert home.home_role({**s, "role": "USER"}) == "FOREST"
|
|
assert home.home_role({"email": "a@b.com", "role": "USER"}) == "USER"
|
|
|
|
|
|
class _Cursor:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_):
|
|
return False
|
|
|
|
async def execute(self, sql, args=()):
|
|
self.sql = sql
|
|
|
|
async def fetchone(self):
|
|
return {"n": 2, "name": "회사", "member_count": 3, "companies": 4, "users": 9}
|
|
|
|
async def fetchall(self):
|
|
if "temp_upload_batches" in self.sql or "plan_route_assignees" in self.sql:
|
|
return []
|
|
return [{"id": 1, "name": "회사"}]
|
|
|
|
|
|
class _Pool:
|
|
def acquire(self):
|
|
class _Conn:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_):
|
|
return False
|
|
|
|
def cursor(self, *_):
|
|
return _Cursor()
|
|
|
|
return _Conn()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("session", "keys", "join"),
|
|
[
|
|
({"role": "SYSTEM_ADMIN", "user_id": 1, "company_id": 1}, {"system"}, True),
|
|
({"role": "ADMIN", "user_id": 1, "company_id": 1}, {"company"}, True),
|
|
({"role": "USER", "user_id": 1, "company_id": 4, "email": "x@a.go.kr"}, {"forest"}, False),
|
|
({"role": "USER", "user_id": 1, "company_id": 1, "email": "x@a.com"}, set(), False),
|
|
],
|
|
)
|
|
def test_home_blocks_by_role(monkeypatch, session, keys, join):
|
|
async def fake_ids(_session, _role):
|
|
return ["a", "b"]
|
|
|
|
async def fake_summaries(ids):
|
|
return [
|
|
{"id": i, "done": i == "b", "thumb": [[0, 0], [1, 1]]} for i in ids
|
|
] # b = 완료 → 빠짐
|
|
|
|
async def fake_plans(_session):
|
|
return {"routes": []}
|
|
|
|
async def fake_resources(days):
|
|
return {"current": {"cpu_usage_percent": 1, "memory_usage_percent": 2}}
|
|
|
|
import D01_PlanRegister.D01_PlanRegister_Router as d01
|
|
|
|
monkeypatch.setattr(home, "home_project_ids", fake_ids)
|
|
monkeypatch.setattr(home, "project_summaries", fake_summaries)
|
|
monkeypatch.setattr(home, "get_db_pool", lambda: _Pool())
|
|
monkeypatch.setattr(home, "get_system_resources", fake_resources)
|
|
monkeypatch.setattr(d01, "assigned_plan_routes", fake_plans)
|
|
body = asyncio.run(home.dashboard_home(session=session))
|
|
assert {"system", "company", "forest"} & body.keys() == keys
|
|
assert [p["id"] for p in body["projects"]] == ["a"] and body["project_total"] == 2
|
|
assert ("join_requests" in body["todos"]) is join
|
|
if "system" in body:
|
|
assert body["system"]["server"] == {"cpu": 1, "memory": 2, "disk": None}
|
|
|
|
|
|
def test_forest_counts_from_plan_file(monkeypatch):
|
|
"""산림청 홈(PLAN 590-1) — 분석 전 = 노선 안 그은 프로젝트 수 · 분석됨 · 확정 = 파일 연도 ·
|
|
지정 대기 = 확정 연도 중 그 연도 조각에 업체 없음."""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from helper_plan_file import year_line_plan
|
|
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
|
|
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
|
|
|
drawn = year_line_plan({2026: [[0.0, 0.0], [100.0, 0.0]], 2027: [[100.0, 0.0], [100.0, 50.0]]})
|
|
plans = {"빈": store.empty_plan(), "그음": drawn, "그음2": drawn}
|
|
prints = network.year_rows(store.plan_units(drawn))
|
|
|
|
async def load(pid):
|
|
return Path("."), plans[pid]
|
|
|
|
async def status(pid):
|
|
return {2026: {"fingerprint": prints[2026]["fingerprint"], "confirmed_at": "x"}}
|
|
|
|
monkeypatch.setattr(store, "load_plan", load)
|
|
monkeypatch.setattr(store, "year_status", status)
|
|
|
|
class Cursor(_Cursor):
|
|
async def fetchall(self):
|
|
return [
|
|
{"id": "빈", "piece_id": None},
|
|
{"id": "그음", "piece_id": None},
|
|
{"id": "그음2", "piece_id": "p26"}, # 확정 2026 에 업체 있음
|
|
]
|
|
|
|
out = asyncio.run(home._forest(Cursor(), 4))
|
|
assert out == {"plan_status": {"draft": 1, "analyzed": 2, "confirmed": 2}, "assign_waiting": 1}
|