From 9027903b5a368b2a32a882c9fb8b8d62e4a63e26 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 17:50:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(508-3b=20=C2=B7=20c=20B07=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=EC=A7=80=EB=AC=B8=20=C2=B7=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=C2=B7=20=ED=8F=89=EB=A9=B4=EB=8F=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=B3=B8):=20=EB=8F=84=EB=A9=B4=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EC=9E=85=EB=A0=A5=20=EC=A7=80=EB=AC=B8(=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20API=20fingerprint=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=9E=A5=20=EC=B8=A1=EC=A0=90=EB=A7=88=EB=8B=A4=20stations)=20?= =?UTF-8?q?=C2=B7=20=EB=AF=B8=EB=A6=AC=EB=B0=9B=EA=B8=B0=20=3D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=B0=96=20=EB=AA=A8=EB=93=88(=EA=B0=80?= =?UTF-8?q?=EB=B2=BC=EC=9A=B4=20=EA=B2=83=20=EB=A8=BC=EC=A0=80=20=C2=B7=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=EB=8F=84=20=EB=81=9D=20=C2=B7=20=EC=97=B0=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=20=EB=A7=A8=20=EC=95=9E=20=C2=B7=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=98=AE=EA=B2=A8=EB=8F=84=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EA=B0=90=20=C2=B7=20=EB=8F=8C=EC=95=84=EC=98=A4?= =?UTF-8?q?=EB=A9=B4=20=EC=A7=80=EB=AC=B8=20=EB=B0=94=EB=80=90=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=EB=A7=8C=20=C2=B7=20=EC=8B=A4=ED=8C=A8=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=20=EB=8B=A4=EC=8B=9C=20=E2=86=92=20=E3=80=8C=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9B=EA=B8=B0=E3=80=8D=20=C2=B7=20=EB=8B=B4?= =?UTF-8?q?=EB=8A=94=20=EC=9E=A5=2040)=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=20=EB=B0=9B=EB=8A=94=20=EB=8F=99=EC=95=88=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=ED=83=AD=20=C2=B7=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=98=AE=EA=B9=80(=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=A4=91=20CAD=20=EC=8B=9C=EA=B3=84=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EC=B9=A8=20=EC=95=88=20=EB=AC=BB=ED=9E=98)?= =?UTF-8?q?=20=C2=B7=20=ED=8F=89=EB=A9=B4=EB=8F=84=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=3D=20=5Fderived/b07=5Fplan=5F<=EB=8F=84=EB=A9=B4>/<=EC=A7=80?= =?UTF-8?q?=EB=AC=B8>.json=20=EA=B7=B8=EB=8C=80=EB=A1=9C(=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=202=EB=B2=8C=20=C2=B7=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=B3=B8=20=C2=B7=20=EC=A7=80=EB=AC=B8=20?= =?UTF-8?q?=EB=AA=BB=20=EC=85=88=20=3D=20=EC=95=88=20=EC=94=80)=20=C2=B7?= =?UTF-8?q?=20=ED=8F=89=EB=A9=B4=EB=8F=84=20=EB=93=B1=EA=B3=A0=EC=84=A0=20?= =?UTF-8?q?=C2=B7=20=EA=B3=84=EB=A5=98=200.1=20mm=20=EC=86=8E=EC=9D=8C=20+?= =?UTF-8?q?=20=EB=B0=B0=EA=B2=BD=20=EC=A2=8C=ED=91=9C=201=20=C2=B5m=20?= =?UTF-8?q?=C2=B7=20=EA=B0=92=20=EA=BC=B4=20=C2=B7=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=EA=B8=80=EC=9D=80=20=EC=A7=80=EB=AC=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=BA=8C=20=E2=80=94=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20325?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검증_sub2: B06 측점 하나 [저장] 뒤 다시 받음 = 바뀐 6 장(횡단 그 장 · 측점 하나) · 평면도 지형 첫 열기 1.3~4.4 s → 저장본 0.2~0.4 s · 안 받은 유역도 = 받는 중 → 4.4 s 뒤 보임 Co-Authored-By: Claude Opus 5.5 --- .../B07_DesignDetail_Api_Fetch.ts | 4 + .../B07_DesignDetail_Engine_Cad_Basin.py | 10 +- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 14 +- B07_DesignDetail/B07_DesignDetail_Prefetch.ts | 197 ++++++++ B07_DesignDetail/B07_DesignDetail_Router.py | 62 ++- ...DesignDetail_Router_Support_Fingerprint.py | 467 ++++++++++++++++++ ...B07_DesignDetail_Router_Support_Overlay.py | 11 +- B07_DesignDetail/B07_DesignDetail_Schema.py | 3 + B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 130 +++-- .../B07_DesignDetail_UI_Style.css | 10 + resources/tester/test_506_3_b07_save_path.py | 5 +- .../tester/test_508_3_b07_fingerprint.py | 221 +++++++++ .../tester/test_508_3_b07_prefetch_browser.py | 332 +++++++++++++ ui_template/cad_host/cad_host.ts | 11 +- 14 files changed, 1370 insertions(+), 107 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Prefetch.ts create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_Support_Fingerprint.py create mode 100644 resources/tester/test_508_3_b07_fingerprint.py create mode 100644 resources/tester/test_508_3_b07_prefetch_browser.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index ba4a78ba4..4dd96d0e0 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -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 | null; } export interface CadDrawing { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index d1d53ed05..96aca087e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -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: diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 25d6bbc54..5994e55d2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -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": diff --git a/B07_DesignDetail/B07_DesignDetail_Prefetch.ts b/B07_DesignDetail/B07_DesignDetail_Prefetch.ts new file mode 100644 index 000000000..b2955f80f --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Prefetch.ts @@ -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(["plan"]); + +export type FetchState = "none" | "loading" | "ready" | "error"; + +interface Slot { + fingerprint: string | null | undefined; + request?: Promise; + response?: DesignDrawingResponse; + /** 두 번 다 못 받음 — 미리받기는 건너뜀 · 열면 다시 */ + failed?: boolean; + /** 상한으로 버림 — 미리받기는 다시 안 받음 · 열면 다시 */ + evicted?: boolean; + usedAt: number; +} + +let project: string | undefined; +let items: DesignDrawingItem[] = []; +const slots = new Map(); +/** 사용자가 연 도면을 받는 중 — 미리받기가 쉼 */ +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 { + 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 => { + 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 { + 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 { + 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; + } +} diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index f1960af31..5234dfcfb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -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: diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Fingerprint.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Fingerprint.py new file mode 100644 index 000000000..592e62666 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Fingerprint.py @@ -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 + ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py index 88fa28340..a5dc2713d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py @@ -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 {} diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index de5f4376e..f5838ff9a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -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): diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 23f7a7fb2..b7d7a6199 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -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 { 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 { quantityTable: response.quantity_table ?? null, }); - /** - * 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시). - * - * 서버가 도면을 요청마다 새로 조립해(측점 설계선·장 배치·표) 클릭 한 번에 0.3~0.6초, - * 구조물 얹기까지 더하면 0.8~1.0초가 걸렸다. 미리 받아 두면 클릭은 캐시에서 즉시 꺼낸다. - * sessionStorage가 아니라 **모듈 메모리**에 둔다 — 유역도 한 장이 엔티티 1,581개라 - * 세션 저장 한도(5MB)를 넘길 위험이 있고, 새로고침이면 정본에서 다시 받는 편이 맞다. - */ - const drawingCache = new Map>(); - - /** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */ - const requestDrawing = (drawing: DesignDrawingItem): Promise => { - 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 => { - 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 { ); }; - /** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */ - 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 { ); } } 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 { */ const tabs = createCadTabs({ 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) => { 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 => @@ -343,6 +313,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { 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 { 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 { 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 { 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 { // 페이지 진입 시 첫 도면(표지 · 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 { // 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); } } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 552deb3d5..34b77dd09 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -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); +} diff --git a/resources/tester/test_506_3_b07_save_path.py b/resources/tester/test_506_3_b07_save_path.py index 540eb301d..507957121 100644 --- a/resources/tester/test_506_3_b07_save_path.py +++ b/resources/tester/test_506_3_b07_save_path.py @@ -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): diff --git a/resources/tester/test_508_3_b07_fingerprint.py b/resources/tester/test_508_3_b07_fingerprint.py new file mode 100644 index 000000000..177c7c7d6 --- /dev/null +++ b/resources/tester/test_508_3_b07_fingerprint.py @@ -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() diff --git a/resources/tester/test_508_3_b07_prefetch_browser.py b/resources/tester/test_508_3_b07_prefetch_browser.py new file mode 100644 index 000000000..e10943b2c --- /dev/null +++ b/resources/tester/test_508_3_b07_prefetch_browser.py @@ -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() diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 70e00ff94..d4b2a19f8 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -105,8 +105,9 @@ export interface CadLoadExtra { export interface CadHost { /** 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 { + beginLoading: (watch = true) => { element.dataset.loading = "true"; element.dataset.error = ""; // 앞선 실패 표시를 지운다 - startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다 + // 저쪽이 말이 없으면 여기서 끊는다 · 거짓 = 아직 서버에서 받는 중(시계 없음 · 508-3b) + if (watch) startLoadWatchdog(); + else stopLoadWatchdog(); }, fail: (detail) => { stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다