feat(B01): 프로젝트 카드 요약 함수 · API — 단계 · 썸네일 선 · 담당 · 멈춘 날 · 연장 · 측점 · 구조물 (PLAN 547-1)
- B01_Dashboard_Summary.project_summaries(ids) — 쿼리 다섯 번(개수 무관) · 넣은 차례 · 없는 값은 칸 뺌 - 썸네일 = 최신 노선 GeoJSON 중심선 100 점 안쪽 · 0~1 칸(가로세로 비 유지 · y 아래로) - GET /api/dashboard/projects/summary?ids= — 보이는 프로젝트만(시스템 관리자 전부 · 회사 · 내 것) - 실측 21 개 0.14 s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""547-1 프로젝트 카드 요약 — 썸네일 줄임 · 단계 · 없는 값 빼기 · 보이는 프로젝트만."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import B01_Dashboard.B01_Dashboard_Router_Home as home
|
||||
import B01_Dashboard.B01_Dashboard_Summary as summary
|
||||
|
||||
ROUTE = "B05_Profile/route/r.geojson"
|
||||
|
||||
|
||||
def test_thumb_line_fits_unit_box_and_caps_points():
|
||||
line = [[1000.0 + i, 2000.0 + i * 0.5] for i in range(500)]
|
||||
thumb = summary.thumb_line(line)
|
||||
assert len(thumb) == 100
|
||||
assert thumb[0] == [0.0, 0.75] and thumb[-1] == [1.0, 0.25] # y 아래로 · 세로 가운데
|
||||
assert all(0 <= x <= 1 and 0 <= y <= 1 for x, y in thumb)
|
||||
assert summary.thumb_line([[0.0, 0.0]]) is None
|
||||
|
||||
|
||||
def test_stage_names_follow_step_bar():
|
||||
assert len(summary.stage_names("ROAD")) == 7
|
||||
assert summary.stage_names("PLAN")[0] == "기준 지정"
|
||||
assert summary.stage_names("EROSION") == summary.stage_names("ROAD")
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, db):
|
||||
self.db, self.rows = db, []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, args=()):
|
||||
self.db["queries"] += 1
|
||||
key = next(k for k in self.db["answers"] if k in sql)
|
||||
self.rows = self.db["answers"][key]
|
||||
|
||||
async def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class _Pool:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def acquire(self):
|
||||
pool = self
|
||||
|
||||
class _Conn:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
def cursor(self, *_):
|
||||
return _Cursor(pool.db)
|
||||
|
||||
return _Conn()
|
||||
|
||||
|
||||
def test_summaries_in_batch(monkeypatch, tmp_path):
|
||||
now = datetime.now()
|
||||
route_dir = tmp_path / "B05_Profile" / "route"
|
||||
route_dir.mkdir(parents=True)
|
||||
coords = [[0, 0, 1], [10, 0, 1], [10, 5, 1]]
|
||||
(route_dir / "r.geojson").write_text(json.dumps({"geometry": {"coordinates": coords}}))
|
||||
(route_dir / "structures.json").write_text(json.dumps({"structures": [{}, {}]}))
|
||||
db = {
|
||||
"queries": 0,
|
||||
"answers": {
|
||||
"FROM projects p LEFT JOIN users": [
|
||||
{
|
||||
"id": "a",
|
||||
"name": "가",
|
||||
"project_kind": "ROAD",
|
||||
"region": "강원",
|
||||
"updated_at": now - timedelta(days=20),
|
||||
"storage_path": "storage/a",
|
||||
"company_id": 1,
|
||||
"user_id": 7,
|
||||
"owner_name": "엄",
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"name": "나",
|
||||
"project_kind": "PLAN",
|
||||
"region": None,
|
||||
"updated_at": now,
|
||||
"storage_path": None,
|
||||
"company_id": 1,
|
||||
"user_id": 7,
|
||||
"owner_name": "엄",
|
||||
},
|
||||
],
|
||||
"project_workflow_stages": [
|
||||
{"project_id": "a", "stage_no": n, "stage_key": "", "state": s}
|
||||
| {"progress_percent": 0, "params": None, "message": None}
|
||||
| {"started_at": None, "completed_at": None}
|
||||
for n, s in enumerate(["COMPLETE", "COMPLETE", "IN_PROGRESS"] + ["NOT"] * 4)
|
||||
],
|
||||
"project_members": [{"project_id": "a", "name": "박"}],
|
||||
"FROM routes": [
|
||||
{"id": 9, "project_id": "a", "total_length_m": 15.04, "route_data_path": ROUTE},
|
||||
{"id": 5, "project_id": "a", "total_length_m": 1.0, "route_data_path": "old"},
|
||||
],
|
||||
"cross_sections": [{"route_id": 9, "n": 3}],
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(summary, "get_db_pool", lambda: _Pool(db))
|
||||
monkeypatch.setattr(summary, "project_root", lambda p: tmp_path if p else None)
|
||||
out = asyncio.run(summary.project_summaries(["b", "a", "zz"]))
|
||||
assert [s["id"] for s in out] == ["b", "a"] # 넣은 차례 · 없는 id 빠짐
|
||||
b, a = out
|
||||
assert (a["stage_index"], a["stage_total"], a["stage_name"]) == (2, 7, "노선설계")
|
||||
assert a["stale_days"] == 20 and a["owner_names"] == ["엄", "박"]
|
||||
assert a["metrics"] == {"length_m": 15.0, "stations": 3, "structures": 2}
|
||||
assert len(a["thumb"]) == 3 and a["done"] is False
|
||||
assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "기준 지정"
|
||||
assert db["queries"] == 5
|
||||
|
||||
|
||||
def test_summary_api_drops_invisible_ids(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_visible(session):
|
||||
return ["a", "b"]
|
||||
|
||||
async def fake_summaries(ids):
|
||||
seen["ids"] = ids
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(home, "visible_project_ids", fake_visible)
|
||||
monkeypatch.setattr(home, "project_summaries", fake_summaries)
|
||||
asyncio.run(home.projects_summary(ids="b, x,a,b", session={}))
|
||||
assert seen["ids"] == ["b", "a"]
|
||||
asyncio.run(home.projects_summary(ids=None, session={}))
|
||||
assert seen["ids"] == ["a", "b"]
|
||||
Reference in New Issue
Block a user