feat(508-3b · c B07 도면 지문 · 미리받기 · 평면도 저장본): 도면마다 입력 지문(목록 API fingerprint · 횡단 장 측점마다 stations) · 미리받기 = 페이지 밖 모듈(가벼운 것 먼저 · 평면도 끝 · 연 도면 맨 앞 · 페이지 옮겨도 이어 감 · 돌아오면 지문 바뀐 도면만 · 실패 한 번 다시 → 「다시 받기」 · 담는 장 40) · 고른 도면 받는 동안 다른 탭 · 페이지로 옮김(받는 중 CAD 시계 없음 · 고침 안 묻힘) · 평면도 응답 = _derived/b07_plan_<도면>/<지문>.json 그대로(최근 2벌 · 확정 저장본 · 지문 못 셈 = 안 씀) · 평면도 등고선 · 계류 0.1 mm 솎음 + 배경 좌표 1 µm · 값 꼴 · 종단 경고 글은 지문에서 뺌 — 요구 325

검증_sub2: B06 측점 하나 [저장] 뒤 다시 받음 = 바뀐 6 장(횡단 그 장 · 측점 하나) · 평면도 지형 첫 열기 1.3~4.4 s → 저장본 0.2~0.4 s · 안 받은 유역도 = 받는 중 → 4.4 s 뒤 보임

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 17:50:59 +09:00
co-authored by Claude Opus 5.5
parent 80020c26c5
commit 9027903b5a
14 changed files with 1370 additions and 107 deletions
@@ -25,6 +25,10 @@ export interface DesignDrawingItem {
/** 계획평면도만 — 종류 id · 이름(PLAN 60장). */
plan_kind?: string | null;
plan_kind_name?: string | null;
/** 입력 지문(508-3b) — 같으면 같은 그림 · 미리받기가 바뀐 도면만 다시 받음 */
fingerprint?: string | null;
/** 횡단 장 — 측점마다 지문 */
stations?: Record<string, string> | null;
}
export interface CadDrawing {
@@ -117,10 +117,12 @@ def map_area_mm() -> tuple[float, float]:
def _thinned(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""종이 mm 점열에서 `_BACKGROUND_SIMPLIFY_MM` 아래 굴곡을 뺀다(양 끝점은 남는다)."""
if len(points) < 3:
return points
return list(LineString(points).simplify(_BACKGROUND_SIMPLIFY_MM).coords)
"""종이 mm 점열에서 `_BACKGROUND_SIMPLIFY_MM` 아래 굴곡을 뺀다(양 끝점은 남는다).
좌표는 1 µm 로 끊음 — 응답 글자 수만 줄고 그림은 같음(508-3c)."""
if len(points) >= 3:
points = list(LineString(points).simplify(_BACKGROUND_SIMPLIFY_MM).coords)
return [(round(x, 3), round(y, 3)) for x, y in points]
def _number(value: Any, fallback: float = 0.0) -> float:
@@ -25,6 +25,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import (
BASIN_COLORS,
_hatch_entity,
_number_badge_entities,
_thinned,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size
@@ -249,10 +250,13 @@ def _piece_entities(
out: list[dict[str, Any]] = []
labels = labels if labels is not None else []
def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None:
def line(
points: Any, suffix: str, width: int = 1, dash: list[int] | None = None, thin: bool = False
) -> None:
on_paper = [paper(p) for p in points]
entity = polyline_entity(
drawing_id,
[paper(p) for p in points],
_thinned(on_paper) if thin else on_paper,
layer,
color,
suffix=suffix,
@@ -301,7 +305,11 @@ def _piece_entities(
},
}
)
elif piece in ("contour", "stream", *_OUTLINE):
elif piece in ("contour", "stream"):
# 배경선 = 종이 해상도로 솎음(유역도와 같은 0.1 mm · 508-3c)
for index, points in enumerate(data.get("lines") or []):
line(points, f":{index}", thin=True)
elif piece in _OUTLINE:
for index, points in enumerate(data.get("lines") or []):
line(points, f":{index}")
elif piece == "centerline":
@@ -0,0 +1,197 @@
/* =============================================================================
* B07_DesignDetail_Prefetch.ts
* 도면 미리받기 — **페이지 밖** 모듈(508-3b · 요구 325).
*
* 페이지를 옮겨도 받기는 이어 감 · B07 로 돌아오면 목록 지문(서버 `fingerprint`)이 바뀐 도면만
* 버리고 다시 받음 · 차례 = 목록 차례 가벼운 것 → 무거운 것(평면도) 끝 · 사용자가 연 도면이 맨 앞 ·
* B07 [저장] 중 쉼 · 실패 = 한 번 다시 → 그래도면 「다시 받기」(누르면 다시) ·
* 담는 장 수 상한(`LIMIT` · 가까이 쓴 차례로 버림 — 버린 장은 열 때 다시 받음).
* 화면 · CAD 는 만지지 않음 — 상태 바뀜은 `onFetchState` 한 칸으로만 알림(페이지가 다시 세우면 갈아 낌).
* ========================================================================== */
import {
fetchDesignDrawing,
type DesignDrawingItem,
type DesignDrawingResponse,
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { isSaving } from "./B07_DesignDetail_UI_Save";
/** 담는 장 수 상한 — 유역도 한 장이 수십 MB 라 메모리를 막음. */
export const LIMIT = 40;
/** 무거운 도면(평면도 · 유역도) — 차례 끝 */
const HEAVY = new Set<DesignDrawingItem["kind"]>(["plan"]);
export type FetchState = "none" | "loading" | "ready" | "error";
interface Slot {
fingerprint: string | null | undefined;
request?: Promise<DesignDrawingResponse>;
response?: DesignDrawingResponse;
/** 두 번 다 못 받음 — 미리받기는 건너뜀 · 열면 다시 */
failed?: boolean;
/** 상한으로 버림 — 미리받기는 다시 안 받음 · 열면 다시 */
evicted?: boolean;
usedAt: number;
}
let project: string | undefined;
let items: DesignDrawingItem[] = [];
const slots = new Map<string, Slot>();
/** 사용자가 연 도면을 받는 중 — 미리받기가 쉼 */
let urgent = 0;
let pumping = false;
let tick = 0;
let notify: ((id: string, state: FetchState) => void) | undefined;
const wait = (ms: number) => new Promise((done) => window.setTimeout(done, ms));
/** 상태 바뀜을 받을 곳 하나(페이지가 세울 때마다 갈아 낌). */
export function onFetchState(listener: ((id: string, state: FetchState) => void) | undefined) {
notify = listener;
}
export function fetchState(id: string): FetchState {
const slot = slots.get(id);
if (!slot) return "none";
if (slot.response) return "ready";
if (slot.request) return "loading";
return slot.failed ? "error" : "none";
}
/**
* 목록을 맞춤 — 다른 프로젝트면 모두 버림 · 지문이 바뀐(또는 목록에서 빠진) 도면만 버림 ·
* 돌리기는 `startPrefetch`(페이지가 첫 장을 고른 뒤).
*/
export function syncDrawings(projectId: string, list: DesignDrawingItem[]): void {
if (project !== projectId) {
slots.clear();
project = projectId;
}
items = list;
const now = new Map(list.map((item) => [item.id, item]));
for (const [id, slot] of slots) {
const item = now.get(id);
// 지문 없음(서버가 못 셈) = 늘 다시 받음
if (!item?.fingerprint || item.fingerprint !== slot.fingerprint) slots.delete(id);
}
}
/** 미리받기를 (다시) 돌림 — 이미 돌면 그대로(맞춘 목록을 이어 받음). */
export function startPrefetch(): void {
void pump();
}
/** 저장 · 다시 그리기 뒤 — 다음 열기는 서버에서 */
export function forgetDrawing(id: string): void {
slots.delete(id);
}
/** 도각을 고친 뒤 — 모두 버리고 다시 받음 */
export function forgetAll(): void {
slots.clear();
void pump();
}
/** 도면 하나(구조물까지 얹은 응답) — 겹쳐 부르면 같은 Promise · 실패한 장은 다시 받음. */
function request(item: DesignDrawingItem, retries: number): Promise<DesignDrawingResponse> {
const known = slots.get(item.id);
if (known?.response) {
known.usedAt = ++tick;
return Promise.resolve(known.response);
}
if (known?.request) return known.request;
const projectId = project as string;
const slot: Slot = { fingerprint: item.fingerprint, usedAt: ++tick };
slots.set(item.id, slot);
const once = async (): Promise<DesignDrawingResponse> => {
const response = await fetchDesignDrawing(projectId, item.id);
// 구조물(배수관 · 기슭막이 · 세월교 · BOX · 물넘이포장)은 B06 산식이 화면에 있어 여기서 얹음
// — 저장본(확정)은 이미 담겨 있어 그대로
if (item.kind === "cross" && !response.confirmed) {
await appendStructureEntities(projectId, response.route_id, response.drawing);
}
return response;
};
slot.request = (async () => {
for (let attempt = 0; ; attempt++) {
try {
return await once();
} catch (error) {
if (attempt >= retries) throw error;
}
}
})();
notify?.(item.id, "loading");
slot.request.then(
(response) => {
if (slots.get(item.id) !== slot) return; // 그 사이 지문이 바뀌어 버림
slot.response = response;
slot.request = undefined;
trim(item.id);
notify?.(item.id, "ready");
},
() => {
if (slots.get(item.id) !== slot) return;
slot.request = undefined;
slot.failed = true;
notify?.(item.id, "error");
},
);
return slot.request;
}
/** 담은 장이 상한을 넘으면 가장 오래 안 쓴 것부터 버림(방금 받은 장 · 받는 중은 남김). */
function trim(keep: string): void {
const held = [...slots].filter(([, slot]) => slot.response);
let excess = held.length - LIMIT;
for (const [id, slot] of held.sort((a, b) => a[1].usedAt - b[1].usedAt)) {
if (excess <= 0) break;
if (id === keep) continue;
slots.set(id, { fingerprint: slot.fingerprint, evicted: true, usedAt: slot.usedAt });
notify?.(id, "none");
excess--;
}
}
/**
* 사용자가 연 도면 — 미리받기 차례를 기다리지 않고 바로 받음(미리받기는 그동안 쉼) ·
* 못 받았던 장 · 버린 장도 다시 받음(「다시 받기」).
*/
export async function openDrawing(item: DesignDrawingItem): Promise<DesignDrawingResponse> {
const known = slots.get(item.id);
if (known && !known.response && !known.request) slots.delete(item.id);
urgent++;
try {
return await request(item, 1);
} finally {
urgent--;
}
}
/** 다음에 미리 받을 도면 — 가벼운 것(목록 차례) 먼저 · 무거운 것 끝 · 이미 받음 · 실패 · 버림은 건너뜀. */
function nextItem(): DesignDrawingItem | undefined {
const free = (item: DesignDrawingItem) => !slots.has(item.id);
return items.find((item) => !HEAVY.has(item.kind) && free(item)) ?? items.find(free);
}
/** 한 번에 하나씩 · 사용자가 연 도면 · [저장]이 도는 동안 쉼 · 상한이 차면 멈춤. */
async function pump(): Promise<void> {
if (pumping) return;
pumping = true;
try {
for (;;) {
while (urgent > 0 || isSaving()) await wait(120);
const held = [...slots.values()].filter((slot) => slot.response || slot.request).length;
const item = held < LIMIT ? nextItem() : undefined;
if (!item || !project) return;
try {
await request(item, 1);
} catch {
/* 두 번 다 못 받음 — 「다시 받기」로 남기고 다음 장 */
}
}
} finally {
pumping = false;
}
}
+45 -17
View File
@@ -10,7 +10,7 @@ from uuid import UUID
from aiomysql import DictCursor
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -24,7 +24,6 @@ from B06_Section.B06_Section_Repository import (
get_cross_section_design,
get_cross_section_designs,
get_longitudinal_section,
get_project_standard_cross_section,
merge_cross_section_design_by_round,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID
@@ -51,6 +50,13 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
read_cross_layout,
write_cross_layout,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Fingerprint import (
drawing_fingerprints,
plan_copy_key,
read_plan_copy,
standard_cross_source,
write_plan_copy,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import STRUCTURE_FRAME_LAYER
@@ -284,6 +290,23 @@ async def get_design_drawing_list(
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
# 도면마다 입력 지문(508-3b) — 화면 미리받기가 바뀐 도면만 다시 받음
use_project_templates(project_root)
try:
prints = await drawing_fingerprints(
project_id,
route_id,
project_root,
longitudinal_path,
drawings,
await title_block_fields(project_id),
)
except Exception: # 지문은 덤 — 못 세면 빈 지문(화면은 늘 다시 받음) · 목록은 그대로
logger.warning("B07 도면 지문 실패: project_id=%s", project_id, exc_info=True)
prints = {}
for item in drawings:
item.fingerprint = prints.get(item.id, {}).get("fingerprint")
item.stations = prints.get(item.id, {}).get("stations")
return DesignDrawingListResponse(
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
)
@@ -309,11 +332,13 @@ async def get_design_drawing(
# 프로젝트 작업본 도각이 있으면 그것으로 그린다(없으면 시스템 양식 — 옛 프로젝트).
use_project_templates(project_root)
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
use_title_fields(await title_block_fields(project_id))
title_fields = await title_block_fields(project_id)
use_title_fields(title_fields)
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
design: dict[str, Any] | None = None
source_design: Any = None
overlays: dict[Any, Any] | None = None
plan_key: str | None = None
cross_match = _CROSS_ID.fullmatch(drawing_id)
if cross_match:
pool = get_db_pool()
@@ -334,19 +359,7 @@ async def get_design_drawing(
source_design = ((section or {}).get("data") or {}).get("mass_haul")
elif drawing_id == CROSS_STANDARD_ID:
# 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값).
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL",
(str(project_id),),
)
row = await cursor.fetchone()
source_design = (
await get_project_standard_cross_section(connection, int(row[0]), project_id)
if row
else None
)
source_design = await standard_cross_source(project_id)
elif DEVELOPMENT_ID.fullmatch(drawing_id):
# 전개도 — 추가 흙막이 단 수 · 포장 빈 폭을 측점별 설계에서 읽음(377)
source_design = await _designs_by_chainage(route_id)
@@ -357,6 +370,16 @@ async def get_design_drawing(
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
designs = await _designs_by_chainage(route_id)
# 파생 저장본(508-3c) — 지문이 같으면 그린 응답 그대로(확정 저장본은 manifest 길)
plan_key = await plan_copy_key(
project_id, route_id, project_root, longitudinal_path, drawing_id,
title_fields, context, designs,
) # fmt: skip
stored = plan_key and await asyncio.to_thread(
read_plan_copy, project_root, drawing_id, plan_key
)
if stored:
return Response(content=stored, media_type="application/json")
source_design = await asyncio.to_thread(
plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id
)
@@ -368,7 +391,7 @@ async def get_design_drawing(
source_design,
overlays,
)
return DesignDrawingResponse(
response = DesignDrawingResponse(
project_id=str(project_id),
route_id=route_id,
id=drawing_id,
@@ -380,6 +403,11 @@ async def get_design_drawing(
design=design,
guide=_drawing_guide(drawing),
)
if plan_key and not confirmed:
body = response.model_dump_json().encode("utf-8")
await asyncio.to_thread(write_plan_copy, project_root, drawing_id, plan_key, body)
return Response(content=body, media_type="application/json")
return response
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except FileNotFoundError as exc:
@@ -0,0 +1,467 @@
"""B07 도면 지문(508-3b) — 도면마다 입력 지문을 **그리기 없이** 싸게 센다.
지문이 같으면 같은 그림 — 화면 미리받기가 바뀐 도면만 다시 받고(횡단 장 = 측점 지문 모음),
평면도 GET 이 서버 파생 저장본(`_derived/b07_plan_<도면 id>/`)을 그대로 낸다.
⚠ 입력 하나라도 빠지면 옛 그림 — 도면 조립(`_Router_Support._read_drawing`)이 읽는 것을
고치면 여기도.
파일 도장 = 작은 파일은 **내용** 해시(B06 [저장]이 같은 내용으로 다시 쓰는 종단 · 횡단 파일) ·
큰 파일(지형 · 모델)은 시각:크기.
"""
from __future__ import annotations
import hashlib
import json
import logging
from functools import cache, lru_cache
from pathlib import Path
from typing import Any
from uuid import UUID
from B05_Profile.B05_Profile_Engine_Sections import cross_station, find_cross_file
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import LEGACY_PIECES
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import CROSS_SHEET_ID
from B07_DesignDetail.B07_DesignDetail_Engine_Template import system_template_path
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_design_root,
_read_json,
_read_manifest,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
PLAN_KIND_ID,
WATERSHED_LAYOUT,
list_kinds,
)
from common_util.common_util_derived_store import (
derived_dir,
derived_key,
file_stamp,
prune_derived,
)
from common_util.common_util_drainage_pipes import (
detail_basins_path,
pipe_points_path_in,
route_signature,
)
from M02_MasterTemplete.M02_Template_Layers import project_dir
logger = logging.getLogger(__name__)
#: 판 번호 — 지문 짜임을 바꾸면 올림(옛 저장본 · 화면 기억을 한 번에 버림)
FINGERPRINT_VERSION = 1
_CONTENT_MAX = 8 * 1024 * 1024
_SLOPE_PIECES = {*LEGACY_PIECES["slope_outline"], "slope_ticks"}
_MASS_HAUL_ID = "mass_haul"
_STANDARD_ID = "cross_standard"
@lru_cache(maxsize=4096)
def _content(path_str: str, mtime_ns: int, size: int) -> str:
return hashlib.blake2b(Path(path_str).read_bytes(), digest_size=12).hexdigest()
def _canon(value: Any) -> Any:
"""값 꼴만 다른 것은 같게 — 4.0 = 4(브라우저를 거친 저장이 정수로 바꿈) · 열쇠는 글."""
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, dict):
return {str(_canon(key)): _canon(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_canon(item) for item in value]
return value
def _key(*parts: Any) -> str:
return derived_key(*_canon(list(parts)))
def _without_warnings(value: Any) -> Any:
"""경고 글(`warnings`)은 B07 이 안 그림 — 다시 셈 · 글 꼴만 바뀌어도 종단 지문이 안 흔들리게."""
if isinstance(value, dict):
return {k: _without_warnings(v) for k, v in value.items() if k != "warnings"}
if isinstance(value, list):
return [_without_warnings(item) for item in value]
return value
@lru_cache(maxsize=64)
def _longitudinal(path_str: str, mtime_ns: int, size: int) -> str:
data = json.loads(Path(path_str).read_text(encoding="utf-8"))
return _key(_without_warnings(data))
def longitudinal_stamp_of(path: Path) -> str | None:
"""종단 파일 지문 — 내용(경고 글 뺌 · 값 꼴 맞춤) · 없거나 깨졌으면 None."""
try:
info = path.stat()
return _longitudinal(str(path), info.st_mtime_ns, info.st_size)
except (OSError, ValueError):
return None
def stamp(path: Path | str | None) -> str | None:
"""파일 도장 — 8 MB 이하 = 내용 해시(시각 · 크기로 기억) · 넘으면 시각:크기 · 없으면 None."""
if path is None:
return None
try:
info = Path(path).stat()
except OSError:
return None
if info.st_size > _CONTENT_MAX:
return file_stamp(Path(path))
try:
return _content(str(path), info.st_mtime_ns, info.st_size)
except OSError:
return None
def tree_stamp(folder: Path, skip: tuple[str, ...] = ()) -> dict[str, str | None]:
"""폴더 아래 파일 전부 {상대 경로: 도장}(없으면 빈 dict)."""
if not folder.is_dir():
return {}
return {
path.relative_to(folder).as_posix(): stamp(path)
for path in sorted(folder.rglob("*"))
if path.is_file() and not path.name.endswith(".tmp") and path.name not in skip
}
@cache
def code_stamp() -> str:
"""B07 엔진 코드 도장 — 배포 · 고침이 옛 저장본을 그대로 내지 않게(프로세스마다 한 번)."""
folder = Path(__file__).resolve().parent
return derived_key({path.name: file_stamp(path) for path in sorted(folder.glob("*.py"))})
def _settings(project_root: Path) -> dict[str, Any]:
"""B07 설정 파일(축척 · 장 배치 · 표식 · 평면도 종류) — manifest · 그린 결과 폴더는 뺌."""
root = _design_root(project_root)
files = sorted(root.glob("*.json")) if root.is_dir() else []
return {
"files": {path.name: stamp(path) for path in files if path.name != "manifest.json"},
"plan_kinds": tree_stamp(root / "plan_kinds"),
}
def _facilities(project_root: Path) -> dict[str, str | None]:
"""시설 정본 — 구조물 · 관 지점."""
return {
"structures": stamp(structures_file_path(str(project_root))),
"pipes": stamp(pipe_points_path_in(project_root)),
}
def _plan_world(project_root: Path, context: Any, longitudinal_stamp: str | None) -> str:
"""평면도 · 유역도가 함께 기대는 노선 · 지형 · 배경 — 종류 · 장마다 같음."""
return _key(
route_signature(list(context.vertices)),
context.crs,
context.surface_params,
tree_stamp(project_root / "B03_FileInput" / "input"),
tree_stamp(project_root / "B04_PreProcess" / "processed"),
tree_stamp(project_root / "B04_PreProcess" / "models"),
tree_stamp(project_root / "B05_Profile" / "route"),
stamp(detail_basins_path(context.stored_path)),
_facilities(project_root),
longitudinal_stamp,
)
def _neighbors(chainages: list[int | float], start: float, end: float) -> list[int | float]:
"""구간 안 측점 + 앞뒤 하나(사면 선이 이웃 측점까지 읽음)."""
inside = [index for index, value in enumerate(chainages) if start <= value <= end]
if not inside:
return []
return chainages[max(inside[0] - 1, 0) : inside[-1] + 2]
def compute_fingerprints(
project_root: Path,
longitudinal_path: Path,
drawings: list[Any],
sources: dict[str, Any],
) -> dict[str, dict[str, Any]]:
"""{도면 id: {"fingerprint", ["stations"]}} — 동기 · 파일만 읽음(DB 값은 `sources`).
`sources` = 라우터가 모은 DB · 문맥 값 — title(표제란) · designs({측점: 설계}) ·
detail({측점: 종횡단 상세 칸}) · overlays({측점: 덧그림 범위}) · context(배수 문맥) ·
mass_haul · standard · sheets(횡단 장 계획) · elevations({측점: 계획고}) ·
cross_inputs(측점 → 장 입력 조각 함수).
고르지 않은 종류에 쓰는 값은 없어도 됨.
"""
manifest = _read_manifest(project_root)["drawings"]
longitudinal_stamp = longitudinal_stamp_of(longitudinal_path)
templates = {
"project": tree_stamp(project_dir(project_root)), # 도각 · 구조물 사본
"system": tree_stamp(system_template_path().parent),
}
common = _key(
FINGERPRINT_VERSION,
code_stamp(),
templates,
_settings(project_root),
sources.get("title") or {},
)
designs = sources.get("designs") or {}
out: dict[str, dict[str, Any]] = {}
sheets: dict[str, dict[str, Any]] = {
sheet["id"]: sheet for sheet in sources.get("sheets") or []
}
kinds: dict[str, dict[str, Any]] | None = None
world: str | None = None
for item in drawings:
drawing_id = item.id
entry = manifest.get(drawing_id) or {}
saved = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
head = (common, entry, stamp(saved) if entry.get("confirmed") else None)
extra: Any = None
stations: dict[str, str] | None = None
if drawing_id == _MASS_HAUL_ID:
extra = (longitudinal_stamp, sources.get("mass_haul"))
elif drawing_id == _STANDARD_ID:
extra = sources.get("standard")
elif item.kind == "longitudinal":
extra = longitudinal_stamp
elif item.kind == "structure":
extra = _facilities(project_root)
elif DEVELOPMENT_ID.fullmatch(drawing_id):
extra = (longitudinal_stamp, designs, _facilities(project_root))
elif CROSS_SHEET_ID.fullmatch(drawing_id) and drawing_id in sheets:
sheet = sheets[drawing_id]
stations = {
str(chainage): _station_fingerprint(sources, chainage)
for chainage in sheet["chainages"]
}
extra = (sheet, sources.get("elevations"), stations)
elif PLAN_KIND_ID.fullmatch(drawing_id) and sources.get("context") is not None:
if kinds is None:
kinds = {kind["id"]: kind for kind in list_kinds(project_root)}
world = _plan_world(project_root, sources["context"], longitudinal_stamp)
extra = _plan_extra(project_root, longitudinal_path, drawing_id, kinds, world, designs)
out[drawing_id] = {"fingerprint": _key(*head, item.kind, extra)[:16]}
if stations is not None:
out[drawing_id]["stations"] = stations
return out
def _station_fingerprint(sources: dict[str, Any], chainage: int | float) -> str:
"""측점 하나 — 장 입력 조각(원본 · 설계 · 계획선 · 표 · 글) · 상세 칸(화면 구조물) · 덧그림."""
return _key(
sources["cross_inputs"](chainage),
(sources.get("detail") or {}).get(chainage),
(sources.get("overlays") or {}).get(chainage),
)[:16]
def _plan_extra(
project_root: Path,
longitudinal_path: Path,
drawing_id: str,
kinds: dict[str, dict[str, Any]],
world: str | None,
designs: dict[int | float, Any],
) -> Any:
"""평면도 한 장 — 종류 정의 · 장 번호 + 노선 세계 + (사면 조각이면) 그 장 측점 설계 · 원본."""
match = PLAN_KIND_ID.fullmatch(drawing_id)
kind = kinds.get(match.group(1)) if match else None
if kind is None:
return (world, None)
number = int(match.group(2)) if match.group(2) else 1
if kind["layout"] == WATERSHED_LAYOUT or not _SLOPE_PIECES & set(kind["pieces"]):
return (world, kind, number)
longitudinal = _read_json(longitudinal_path)
stations = [
(float(row["chainage_m"]), float(row.get("center_x", 0)), float(row.get("center_y", 0)))
for row in longitudinal.get("stations") or []
if isinstance(row, dict) and isinstance(row.get("chainage_m"), (int, float))
]
chunk = next(
(item for item in plan_chunks(stations, kind["scale"]) if item["number"] == number), None
)
if chunk is None:
return (world, kind, number)
chainages = sorted({cross_station(value) for value, _x, _y in stations})
near = _neighbors(chainages, float(chunk["start_m"]), float(chunk["end_m"]))
cross_dir = longitudinal_path.parent.parent / "cross_sections"
return (
world,
kind,
number,
{str(c): (designs.get(c), stamp(find_cross_file(cross_dir, c))) for c in near},
)
#: 평면도 파생 저장본 종류 접두 — `_derived/b07_plan_<도면 id>/<지문>.json`(응답 그대로 · 최근 2벌)
PLAN_DERIVED = "b07_plan_"
def read_plan_copy(project_root: Path, drawing_id: str, key: str) -> bytes | None:
"""지문이 같은 평면도 응답(JSON 글 그대로) — 없으면 None · 읽기만(지우지 않음)."""
path = derived_dir(project_root, PLAN_DERIVED + drawing_id) / f"{key}.json"
try:
body = path.read_bytes()
path.touch() # 최근에 쓴 것 = 정리 때 남김
except OSError:
return None
return body or None
def write_plan_copy(project_root: Path, drawing_id: str, key: str, body: bytes) -> None:
"""원자 쓰기 뒤 그 도면의 옛 지문을 정리(최근 2벌)."""
folder = derived_dir(project_root, PLAN_DERIVED + drawing_id)
folder.mkdir(parents=True, exist_ok=True)
temporary = folder / f".{key}.tmp"
temporary.write_bytes(body)
temporary.replace(folder / f"{key}.json")
prune_derived(project_root, PLAN_DERIVED + drawing_id)
async def plan_copy_key(
project_id: UUID,
route_id: int,
project_root: Path,
longitudinal_path: Path,
drawing_id: str,
title: dict[str, str],
context: Any,
designs: dict[int | float, Any],
) -> str | None:
"""평면도 저장본 열쇠 = 그 장 지문 — 확정 저장본(manifest 길) · 못 셈 = None(저장본 안 씀)."""
from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingItem
if _read_manifest(project_root)["drawings"].get(drawing_id, {}).get("confirmed"):
return None
item = DesignDrawingItem(id=drawing_id, kind="plan", label=drawing_id)
try:
prints = await drawing_fingerprints(
project_id, route_id, project_root, longitudinal_path, [item], title,
context=context, designs=designs,
) # fmt: skip
except Exception:
logger.warning("B07 평면도 지문 실패 — 저장본 없이 그림: %s", drawing_id, exc_info=True)
return None
return prints[drawing_id]["fingerprint"]
async def standard_cross_source(project_id: UUID) -> Any:
"""표준 횡단면 설정(B06) — GET 과 지문이 같은 값을 씀."""
from B06_Section.B06_Section_Repository import get_project_standard_cross_section
from config.config_db import get_db_pool
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL",
(str(project_id),),
)
row = await cursor.fetchone()
if not row:
return None
return await get_project_standard_cross_section(connection, int(row[0]), project_id)
async def drawing_fingerprints(
project_id: UUID,
route_id: int,
project_root: Path,
longitudinal_path: Path,
drawings: list[Any],
title: dict[str, str],
context: Any = None,
designs: dict[int | float, Any] | None = None,
) -> dict[str, dict[str, Any]]:
"""고른 도면만 지문 — 쓰는 종류의 DB · 문맥 값만 모아 `compute_fingerprints` 로.
`context` · `designs` = 단건 GET 이 이미 읽은 값(다시 안 읽음).
"""
import asyncio
from B06_Section.B06_Section_Repository import get_longitudinal_section
from B07_DesignDetail.B07_DesignDetail_Router import _designs_by_chainage
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
_cross_section_input,
_cross_sheet_plan,
load_note_sources,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
from common_util.common_util_drainage_context import load_drainage_context
from config.config_db import get_db_pool
ids = {item.id for item in drawings}
has_sheet = any(CROSS_SHEET_ID.fullmatch(drawing_id) for drawing_id in ids)
has_plan = any(PLAN_KIND_ID.fullmatch(drawing_id) for drawing_id in ids)
sources: dict[str, Any] = {"title": title, "designs": designs or {}}
# DB 가 원격 — 서로 안 기다리는 것끼리 겹쳐 보냄(값은 같고 왕복만 겹침)
async def read_designs() -> None:
if designs is None and (
has_sheet or has_plan or any(DEVELOPMENT_ID.fullmatch(i) for i in ids)
):
sources["designs"] = await _designs_by_chainage(route_id)
async def read_context() -> None:
if has_plan:
sources["context"] = context or (await load_drainage_context(project_id))[0]
async def read_mass_haul() -> None:
if _MASS_HAUL_ID in ids:
pool = get_db_pool()
async with pool.acquire() as connection:
section = await get_longitudinal_section(connection, project_id, route_id)
sources["mass_haul"] = ((section or {}).get("data") or {}).get("mass_haul")
async def read_standard() -> None:
if _STANDARD_ID in ids:
sources["standard"] = await standard_cross_source(project_id)
async def read_cross() -> None:
if not has_sheet:
return
from B06_Section.B06_Section_Router import get_section_detail
response = await get_section_detail(project_id, route_id)
dump = getattr(response, "model_dump", None)
rows = dump(mode="json").get("cross_sections") or [] if dump else []
detail = {cross_station(row["chainage_m"]): row for row in rows if "chainage_m" in row}
sources["detail"] = detail
sources["overlays"] = await cross_overlay_extents(
project_id, route_id, response if dump else None
)
sources["elevations"] = {
str(chainage): (row.get("design") or {}).get("design_elevation_m")
for chainage, row in detail.items()
}
async def read_files() -> None:
if has_sheet:
sources["sheets"], sources["longitudinal"], sources["pipes"] = await asyncio.gather(
asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path),
asyncio.to_thread(_read_json, longitudinal_path),
asyncio.to_thread(load_note_sources, project_root),
)
await asyncio.gather(
read_designs(),
read_context(),
read_mass_haul(),
read_standard(),
read_cross(),
read_files(),
)
if has_sheet:
sources["cross_inputs"] = lambda chainage: _cross_section_input(
longitudinal_path,
sources["longitudinal"],
chainage,
sources["designs"].get(chainage),
sources["overlays"].get(chainage),
sources["pipes"],
)
return await asyncio.to_thread(
compute_fingerprints, project_root, longitudinal_path, drawings, sources
)
@@ -52,12 +52,17 @@ def _extents_from(output: Any) -> dict[int | float, Extent]:
return extents
async def cross_overlay_extents(project_id: UUID | str, route_id: int) -> dict[int | float, Extent]:
"""{측점: (오프셋 최소, 최대, 표고 최소, 최대)} — 덧그림이 없는 측점은 빠진다."""
async def cross_overlay_extents(
project_id: UUID | str, route_id: int, response: Any = None
) -> dict[int | float, Extent]:
"""{측점: (오프셋 최소, 최대, 표고 최소, 최대)} — 덧그림이 없는 측점은 빠진다.
`response` = 이미 받은 종횡단 상세(지문 · 508-3b) — 없으면 여기서 받음."""
from B06_Section.B06_Section_Router import get_section_detail
try:
response = await get_section_detail(UUID(str(project_id)), route_id)
if response is None:
response = await get_section_detail(UUID(str(project_id)), route_id)
dump = getattr(response, "model_dump", None)
if dump is None: # JSONResponse = 실패
return {}
@@ -31,6 +31,9 @@ class DesignDrawingItem(BaseModel):
# 계획평면도만 — 그 도면의 종류 id · 이름(PLAN 60장). 화면이 종류별로 묶는다.
plan_kind: str | None = None
plan_kind_name: str | None = None
# 입력 지문(508-3b) — 같으면 같은 그림 · 횡단 장은 측점마다(`_Router_Support_Fingerprint`)
fingerprint: str | None = None
stations: dict[str, str] | None = None
class DesignDrawingListResponse(BaseModel):
+56 -74
View File
@@ -39,7 +39,6 @@ import {
completeDesignStage,
exportDrawing,
fetchCrossLayout,
fetchDesignDrawing,
fetchDesignDrawingList,
fetchFrameTemplate,
importFrameTemplate,
@@ -52,11 +51,18 @@ import {
type DesignDrawingResponse,
type QuantityTable,
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import {
fetchState,
forgetAll,
forgetDrawing,
onFetchState,
openDrawing,
startPrefetch,
syncDrawings,
} from "./B07_DesignDetail_Prefetch";
import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle";
import {
hasRedrawable,
isSaving,
recoveryScopeOf,
redrawFromSource,
resetDrawings,
@@ -125,7 +131,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
title: L("B07_Design_Title"),
// 편집 통지는 **미저장 표시**(탭 ●)만 세운다 — 저장은 페이지를 옮길 때(요구 281).
onChanged: (dirty) => {
if (frameEditor.isEditing()) return;
// 받는 중(508-3b) = CAD 엔 앞 도면이 남아 있음 — 그 고침을 고른 탭에 묻히지 않음
if (frameEditor.isEditing() || pending) return;
cadDirty = dirty;
if (dirty) tabs.markDirty();
},
@@ -209,57 +216,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
quantityTable: response.quantity_table ?? null,
});
/**
* 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시).
*
* 서버가 도면을 요청마다 새로 조립해(측점 설계선·장 배치·표) 클릭 한 번에 0.3~0.6초,
* 구조물 얹기까지 더하면 0.8~1.0초가 걸렸다. 미리 받아 두면 클릭은 캐시에서 즉시 꺼낸다.
* sessionStorage가 아니라 **모듈 메모리**에 둔다 — 유역도 한 장이 엔티티 1,581개라
* 세션 저장 한도(5MB)를 넘길 위험이 있고, 새로고침이면 정본에서 다시 받는 편이 맞다.
*/
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
const requestDrawing = (drawing: DesignDrawingItem): Promise<DesignDrawingResponse> => {
const cached = drawingCache.get(drawing.id);
if (cached) return cached;
const request = (async () => {
const response = await fetchDesignDrawing(projectId as string, drawing.id);
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
if (drawing.kind === "cross" && !response.confirmed) {
await appendStructureEntities(projectId as string, response.route_id, response.drawing);
}
return response;
})().catch((error) => {
drawingCache.delete(drawing.id); // 실패는 캐시에 남기지 않는다 — 다음 클릭에 다시 받는다.
throw error;
});
drawingCache.set(drawing.id, request);
return request;
};
/** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다.
*
* ⚠ **사용자가 누른 도면에 자리를 내준다** (2026-09-09 — 무한 로딩으로 보이던 자리).
* 도면이 31 장이고 한 장에 0.3~1.0초라, 미리 받기가 도는 동안 클릭하면 그 요청이
* **앞의 여러 장 뒤에 줄을 서서** 30초 넘게 안 열렸다. 화면은 「불러오는 중」 그대로였다.
* 한 장 받을 때마다 **클릭이 끼어들 틈**을 주고, 클릭이 도는 동안은 쉬어 준다.
*/
const prefetchAllDrawings = async (): Promise<void> => {
if (!projectId) return;
for (const item of drawings) {
// 클릭 · [저장]이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다.
while (loadInFlight || isSaving()) await new Promise((done) => window.setTimeout(done, 120));
if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다
try {
await requestDrawing(item);
} catch {
/* 한 장을 못 받아도 나머지는 계속 받는다 — 클릭 때 다시 시도한다. */
}
}
};
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
const mayDiscardEdits = (): boolean => {
if (!cadDirty && !tabs.anyDirty()) return true;
@@ -270,19 +226,29 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
);
};
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
let loadInFlight = false;
/**
* 고른 도면을 받는 중(508-3b) — 받는 동안 다른 도면 · 페이지로 옮길 수 있음 · 늦게 온 응답은
* 그 도면이 아직 고른 것일 때만 실음. 못 받으면 그대로 남김(CAD 엔 앞 도면 · 고침 안 묻힘).
*/
let pending: { id: string; stash?: CadDrawing } | undefined;
/** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */
const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => {
if (!projectId || loadInFlight) return;
loadInFlight = true;
if (!projectId) return;
const mine = { id: drawing.id, stash };
pending = mine;
currentDrawing = undefined; // 저장 · 다시 그리기가 CAD 의 앞 도면을 이 장으로 안 씀
highlightActive(drawing.id);
const button = findButton(drawing.id);
if (button) button.dataset.loading = "true";
cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다
const ready = fetchState(drawing.id) === "ready";
cad.beginLoading(ready); // 받는 중엔 시계 없음 — 서버가 오래 걸려도 CAD 탓으로 안 끊음
if (!ready) showToast("도면을 받는 중입니다 — 다 받으면 바로 보여 줍니다.", "info");
try {
const response = await requestDrawing(drawing);
const response = await openDrawing(drawing);
if (pending !== mine) return; // 그 사이 다른 도면을 고름
pending = undefined;
if (!ready) cad.beginLoading(); // 이제 CAD 가 여는 몫 — 시계를 검
currentDrawing = drawing;
currentIndex = index;
cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
@@ -312,10 +278,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
);
}
} catch (error) {
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
if (currentDrawing) highlightActive(currentDrawing.id);
if (pending !== mine) return;
const reason = error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
cad.fail(`${reason} — 목록의 「다시 받기」를 누르면 다시 받습니다.`);
} finally {
loadInFlight = false;
if (button) button.dataset.loading = "false";
}
};
@@ -326,16 +292,20 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
*/
const tabs = createCadTabs<CadDrawing>({
features: { close: true },
capture: async () => ({ drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }),
// 받는 중이면 CAD 엔 앞 도면 — 고른 탭엔 제 편집본(없으면 빈 장 · 미저장 아님)을 둠
capture: async () =>
pending
? { drawing: pending.stash ?? { entities: [], layers: [] }, dirty: false }
: { drawing: (await requestCadDrawing()).drawing, dirty: cadDirty },
canLeave: () => {
if (!frameEditor.isEditing()) return !loadInFlight;
if (!frameEditor.isEditing()) return true;
showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning");
return false;
},
show: async (tab: CadTab<CadDrawing>) => {
const index = drawings.findIndex((item) => item.id === tab.id);
if (index >= 0)
await loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined);
// 기다리지 않음 — 받는 동안 다른 탭을 고를 수 있게(탭 줄은 show 동안 잠김)
if (index >= 0) void loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined);
},
});
const openTab = (drawing: DesignDrawingItem): Promise<boolean> =>
@@ -343,6 +313,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const selectDrawing = (drawing: DesignDrawingItem) => {
if (drawing === currentDrawing) return;
// 지금 탭이 못 받은 도면 = 「다시 받기」(탭은 그대로라 탭 열기로는 안 다시 받음)
if (pending?.id === drawing.id && fetchState(drawing.id) === "error") {
void loadDrawing(drawing, drawings.indexOf(drawing), pending.stash);
return;
}
void openTab(drawing);
};
@@ -358,7 +333,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
setCadClean: () => void (cadDirty = false),
requestCad: requestCadDrawing,
isFrameEditing: () => frameEditor.isEditing(),
forget: (id: string) => void drawingCache.delete(id),
forget: forgetDrawing,
reload: (drawing: DesignDrawingItem) => loadDrawing(drawing, drawings.indexOf(drawing)),
};
@@ -407,7 +382,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
restoreDrawing: () => {
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
},
onSaved: () => drawingCache.clear(),
onSaved: forgetAll,
currentDrawingInfo: () =>
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
@@ -449,6 +424,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
folded,
);
drawingListEl = drawingPanel;
// 미리받기 상태(508-3b) — 못 받은 장 = 「다시 받기」 표시(누르면 다시 받음)
const markFetch = (id: string, state: string): void => {
const button = findButton(id);
if (button) button.dataset.fetch = state;
};
drawings.forEach((item) => markFetch(item.id, fetchState(item.id)));
onFetchState(markFetch);
const actions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
@@ -544,6 +526,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지.
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
// 목록 지문으로 받아 둔 도면을 맞춤 — 바뀐 도면만 버림(다른 페이지에서 돌던 받기는 이어 감)
if (projectId) syncDrawings(projectId, drawings);
if (drawings.length > 0) {
const crossSheets = drawings.filter(isCrossSheet);
const reopenId = reopenCrossSheet
@@ -555,9 +539,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// 311 기억한 장이 없으면 표지
const cover = drawings.findIndex((item) => item.kind === "cover");
const first = reopen >= 0 ? reopen : Math.max(cover, 0);
void openTab(drawings[first]).then(() => {
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
void prefetchAllDrawings();
});
// 첫 장을 고른 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다(고른 장을 받는 동안 쉼).
void openTab(drawings[first]).then(startPrefetch);
}
}
@@ -542,3 +542,13 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 받지 못한 도면(508-3b) — 미리받기 · 열기가 두 번 다 실패 · 누르면 다시 받음 */
.b07-drawing-button[data-fetch="error"] {
border-left-color: var(--color-danger);
}
.b07-drawing-button[data-fetch="error"] .b07-drawing-button__name::after {
content: " · 다시 받기";
color: var(--color-danger);
}
+3 -2
View File
@@ -56,8 +56,9 @@ def test_1a_한도():
def test_1a_미리받기_쉼():
page = _read("B07_DesignDetail_UI_Page.ts")
assert "while (loadInFlight || isSaving())" in page
# 508-3b 미리받기 = 페이지 밖 모듈 · 연 도면 받는 중(urgent) · [저장] 중 쉼
prefetch = _read("B07_DesignDetail_Prefetch.ts")
assert "while (urgent > 0 || isSaving())" in prefetch
def test_1a_저장_판정(out):
@@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""508-3b · c B07 도면 지문 · 평면도 파생 저장본(임시 폴더 · DB · 실제 마스터 안 씀).
- 지문 = 입력이 바뀔 때만 바뀜(같은 내용으로 다시 쓴 종단 = 그대로)
- 횡단 측점 하나 바꾸면 그 장만 · 그 장 측점 지문도 그 측점만
- B07 설정 · 도각 · 그 도면 manifest 칸 = 그 범위만 바뀜
- 평면도 저장본 열쇠 = 지문 · 읽기는 안 지움 · 최근 2벌 · 해상도(종이 0.1 mm 솎음 · 1 µm 끊음)
./venv/Scripts/python.exe -m pytest resources/tester/test_508_3_b07_fingerprint.py -q
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B07_DesignDetail import B07_DesignDetail_Router_Support_Fingerprint as fp # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _thinned # noqa: E402
SHEETS = [
{"id": "cross_s00000m", "number": 1, "chainages": [0, 20], "grid": [4, 4]},
{"id": "cross_s00040m", "number": 2, "chainages": [40, 60], "grid": [4, 4]},
]
def _item(drawing_id: str, kind: str) -> SimpleNamespace:
return SimpleNamespace(id=drawing_id, kind=kind)
ITEMS = [
_item("cover", "cover"),
_item("longitudinal_1", "longitudinal"),
_item("cross_s00000m", "cross"),
_item("cross_s00040m", "cross"),
_item("mass_haul", "mass_haul"),
]
@pytest.fixture()
def project(tmp_path: Path) -> dict:
root = tmp_path / "proj"
longitudinal = root / "B06_Section" / "longitudinal" / "longitudinal.json"
longitudinal.parent.mkdir(parents=True)
longitudinal.write_text(json.dumps({"stations": [{"chainage_m": 0}]}), encoding="utf-8")
(root / "B07_DesignDetail").mkdir()
(root / "B07_DesignDetail" / "drawing_scales.json").write_text("{}", encoding="utf-8")
(root / "templates" / "drawing").mkdir(parents=True)
(root / "templates" / "drawing" / "00_template_A1.json").write_text("{}", encoding="utf-8")
designs = {c: {"cut_slope_ratio": 0.4, "at": c} for c in (0, 20, 40, 60)}
sources = {
"title": {"공사명": "시험"},
"designs": designs,
"detail": {c: {"chainage_m": c, "design": designs[c]} for c in designs},
"overlays": {},
"elevations": {str(c): 100.0 for c in designs},
"sheets": SHEETS,
"mass_haul": {"points": [1, 2]},
"cross_inputs": lambda c: {"chainage": c, "design": sources["designs"].get(c)},
}
return {"root": root, "longitudinal": longitudinal, "sources": sources}
def _prints(project: dict) -> dict:
return fp.compute_fingerprints(
project["root"], project["longitudinal"], ITEMS, project["sources"]
)
def _changed(before: dict, after: dict) -> set[str]:
return {key for key in before if before[key]["fingerprint"] != after[key]["fingerprint"]}
def test_같은_입력_같은_지문(project):
first = _prints(project)
assert first == _prints(project)
assert all(len(value["fingerprint"]) == 16 for value in first.values())
assert set(first["cross_s00000m"]["stations"]) == {"0", "20"}
def test_같은_내용으로_다시_쓴_종단은_그대로(project):
before = _prints(project)
path: Path = project["longitudinal"]
text = path.read_text(encoding="utf-8")
time.sleep(0.02)
path.write_text(text, encoding="utf-8") # B06 [저장]이 같은 내용으로 다시 씀
os.utime(path, None)
assert _changed(before, _prints(project)) == set()
path.write_text(json.dumps({"stations": [{"chainage_m": 0}, {"chainage_m": 20}]}))
assert _changed(before, _prints(project)) == {"longitudinal_1", "mass_haul"}
def test_횡단_측점_하나_바꾸면_그_장만(project):
before = _prints(project)
sources = project["sources"]
sources["designs"] = {**sources["designs"], 40: {"cut_slope_ratio": 0.5, "at": 40}}
sources["detail"] = {**sources["detail"], 40: {"chainage_m": 40, "design": {"x": 1}}}
after = _prints(project)
assert _changed(before, after) == {"cross_s00040m"}
stations_before = before["cross_s00040m"]["stations"]
stations_after = after["cross_s00040m"]["stations"]
assert stations_before["60"] == stations_after["60"]
assert stations_before["40"] != stations_after["40"]
def test_덧그림_범위만_바뀌어도_그_장(project):
before = _prints(project)
project["sources"]["overlays"] = {20: (-5.0, 5.0, 90.0, 110.0)}
assert _changed(before, _prints(project)) == {"cross_s00000m"}
def test_설정_도각_manifest_범위(project):
root: Path = project["root"]
before = _prints(project)
(root / "B07_DesignDetail" / "drawing_scales.json").write_text('{"cross": {"x": 200}}')
after = _prints(project)
assert _changed(before, after) == {item.id for item in ITEMS} # 설정 = 모든 도면
(root / "templates" / "drawing" / "00_template_A1.json").write_text('{"a": 1}')
again = _prints(project)
assert _changed(after, again) == {item.id for item in ITEMS} # 도각 = 모든 도면
manifest = {"drawings": {"cover": {"number": 1}}}
(root / "B07_DesignDetail" / "manifest.json").write_text(json.dumps(manifest))
last = _prints(project)
assert _changed(again, last) == {"cover"} # 그 도면 칸만 · manifest 파일은 설정이 아님
# 그린 결과 폴더(plan_pieces)는 입력 아님
(root / "B07_DesignDetail" / "plan_pieces" / "1").mkdir(parents=True)
(root / "B07_DesignDetail" / "plan_pieces" / "1" / "contour.json").write_text("[]")
assert _changed(last, _prints(project)) == set()
def test_DB_유토만_바뀌면_토적도만(project):
before = _prints(project)
project["sources"]["mass_haul"] = {"points": [1, 3]}
assert _changed(before, _prints(project)) == {"mass_haul"}
def test_파일_도장_내용_대_시각(tmp_path, monkeypatch):
small = tmp_path / "a.json"
small.write_text("1")
first = fp.stamp(small)
os.utime(small, (1, 1))
assert fp.stamp(small) == first # 작은 파일 = 내용
monkeypatch.setattr(fp, "_CONTENT_MAX", 0)
assert fp.stamp(small) == "1000000000:1" # 큰 파일 = 시각:크기
assert fp.stamp(tmp_path / "없음") is None
def test_평면도_저장본_열쇠_읽기는_안_지움(tmp_path):
root = tmp_path / "proj"
assert fp.read_plan_copy(root, "plan_terrain", "k1") is None
fp.write_plan_copy(root, "plan_terrain", "k1", b'{"a":1}')
fp.write_plan_copy(root, "plan_route", "r1", b'{"r":1}')
folder = root / "_derived" / "b07_plan_plan_terrain"
assert fp.read_plan_copy(root, "plan_terrain", "k1") == b'{"a":1}'
assert fp.read_plan_copy(root, "plan_terrain", "다른") is None # 지문 다름 = 없음
assert (folder / "k1.json").is_file() # 읽기 · 빗나감 모두 안 지움
time.sleep(0.02)
fp.write_plan_copy(root, "plan_terrain", "k2", b'{"a":2}')
time.sleep(0.02)
fp.read_plan_copy(root, "plan_terrain", "k1") # 꺼낸 것 = 최근
time.sleep(0.02)
fp.write_plan_copy(root, "plan_terrain", "k3", b'{"a":3}')
assert sorted(p.name for p in folder.glob("*.json")) == ["k1.json", "k3.json"] # 최근 2벌
assert (root / "_derived" / "b07_plan_plan_route" / "r1.json").is_file() # 다른 도면 그대로
assert not list(folder.glob("*.tmp"))
def test_해상도_종이_솎음(tmp_path):
# 종이 mm 직선 위 점 100 개 + 0.05 mm 흔들림 = 양 끝만 · 좌표 1 µm
points = [(i * 0.5 + 0.0000123, (0.05 if i % 2 else 0.0)) for i in range(100)]
thinned = _thinned(points)
assert len(thinned) == 2
assert all(round(v, 3) == v for xy in thinned for v in xy)
bend = [(0.0, 0.0), (10.0, 0.5), (20.0, 0.0)] # 0.5 mm 꺾임은 남김
assert len(_thinned(bend)) == 3
def test_평면도_등고선_솎음_적용():
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
scale = 1200
line = [(i * 0.6, 0.00001 * (i % 2)) for i in range(200)] # 실좌표 m — 종이에선 곧은 선
box = (0.0, -10.0, 120.0, 10.0)
pieces = {"contour": {"lines": [line]}, "centerline": {"lines": [line]}}
drawing = build_plan_kind_drawing("plan_t", "지형", box, pieces, scale)
lines = {
entity["layerId"]: entity
for entity in drawing["entities"]
if entity.get("type") == "PolyLine"
}
contour = next(value for key, value in lines.items() if "contour" in key)
center = next(value for key, value in lines.items() if "center" in key)
assert len(contour["children"]) == 1 # 배경선 = 솎음
assert len(center["children"]) == 199 # 주제선(중심선)은 그대로
def test_값_꼴_경고_글만_바뀐_저장은_그대로(project):
# 옛 판 자료의 첫 B06 [저장] = 4.0 → 4 · 경고 글 꼴만 바뀜(2026-10-22 실측) — 그림은 같음
path: Path = project["longitudinal"]
path.write_text(json.dumps({"stations": [{"chainage_m": 0.0}], "warnings": ["a"]}))
sources = project["sources"]
sources["designs"] = {c: {**d, "width": 4.0} for c, d in sources["designs"].items()}
sources["detail"] = {c: {"chainage_m": c, "design": d} for c, d in sources["designs"].items()}
before = _prints(project)
path.write_text(json.dumps({"stations": [{"chainage_m": 0}], "warnings": ["a", "b"]}))
sources["designs"] = {c: {**d, "width": 4} for c, d in sources["designs"].items()}
sources["detail"] = {
c: {"chainage_m": float(c), "design": d} for c, d in sources["designs"].items()
}
assert _changed(before, _prints(project)) == set()
@@ -0,0 +1,332 @@
# -*- 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()
+7 -4
View File
@@ -105,8 +105,9 @@ export interface CadLoadExtra {
export interface CadHost<D extends CadHostDrawing, M, Q> {
/** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */
element: HTMLElement;
/** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다. */
beginLoading: () => void;
/** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다.
* `watch` 거짓 = 아직 서버에서 받는 중 — 표시만 · 시계 없음(받은 뒤 다시 부름). */
beginLoading: (watch?: boolean) => void;
/** 불러오기 실패 — 시계를 끊고 실패 표시 · 토스트. */
fail: (detail: string) => void;
/** 도면을 싣는다 — CAD 가 아직 안 떴으면 ready 때 보낸다.
@@ -353,10 +354,12 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
return {
element,
beginLoading: () => {
beginLoading: (watch = true) => {
element.dataset.loading = "true";
element.dataset.error = ""; // 앞선 실패 표시를 지운다
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
// 저쪽이 말이 없으면 여기서 끊는다 · 거짓 = 아직 서버에서 받는 중(시계 없음 · 508-3b)
if (watch) startLoadWatchdog();
else stopLoadWatchdog();
},
fail: (detail) => {
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다