diff --git a/A00_Common/design/design_pipeline_probe.ts b/A00_Common/design/design_pipeline_probe.ts index daa0da8cf..83e22c5dc 100644 --- a/A00_Common/design/design_pipeline_probe.ts +++ b/A00_Common/design/design_pipeline_probe.ts @@ -6,6 +6,7 @@ * `crossWithUphill` · `stationSection`)를 그 자리 측점 하나로 다시 부름 — [측점 추가] 뒤 묶음이 낼 측점과 * 같은 틀 · 반경 · 확폭(기존 측점 + 그 자리로 셈). 파생 저장 · 캐시 · 묶음 상태는 안 건드림. * 지형 격자가 없거나 분석 지형 ≠ 설계 지형이면 null(부르는 쪽이 서버 미리 뜨기로). + * 단면(`section`) = 그 측점에 지금 현황 형상 · 이름을 얹은 것 — 창이 「횡단」 판과 같은 B06 카드로 그림(505-3). * ========================================================================== */ import { pagePipeline } from "./design_pipeline_page"; @@ -19,27 +20,31 @@ import { probeStation } from "./logic/design_pipeline_probe_station"; import { groundStageDeps } from "../../B05_Profile/B05_Profile_Api_TerrainGrid"; import { createGroundStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; import { + attachStatus, crossWithShapes, crossAtSeat, crossWithUphill, shapeStatus, + stationSection, type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage"; import { pipelineCrossAt } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Cross"; import type { CrossPreviewResponse } from "../../B05_Profile/B05_Profile_Api_Replan"; +import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; const SAME_M = 1e-6; /** - * 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단 — 묶음이 아직 안 셌거나 격자 없음 · 분석 지형 ≠ 설계 지형이면 null. + * 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단(선 그림 재료 · 형상 얹은 단면) — 묶음이 아직 안 셌거나 + * 격자 없음 · 분석 지형 ≠ 설계 지형이면 null. */ export async function pipelineCrossAtChainage( projectId: string, chainageM: number, intervalM: number, -): Promise { +): Promise<{ preview: CrossPreviewResponse; section: CrossSection } | null> { const pipe = pagePipeline(projectId); const route = pipe?.output("route"); const profile = pipe?.output("profile"); @@ -105,5 +110,9 @@ export async function pipelineCrossAtChainage( output: (name: StageName) => name === "ground" ? { stations: [station] } : name === "cross" ? cross : null, } as unknown as Parameters[0]; - return pipelineCrossAt(one, chainageM, intervalM, undefined); + const preview = pipelineCrossAt(one, chainageM, intervalM, undefined); + if (!preview) return null; + const section = stationSection(station, cross, {}, intervalM); + attachStatus([section], status); + return { preview, section }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css index 471b4f913..4f7cff4dc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css @@ -43,6 +43,14 @@ background: var(--color-surface); } +/* B06 카드 읽기 전용(505-3) — 칸 테두리 안 · 고름 강조 · 손가락 없음 */ +.b05-crossmodal__box > .b06-cross-card { + border: 0; + border-radius: 0; + box-shadow: none; + cursor: default; +} + .b05-crossmodal__foot { color: var(--color-text-secondary); font-size: var(--text-caption); diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index aff64491e..3f76da8ac 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -5,7 +5,8 @@ * 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침 * (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산 * `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` · - * 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시. + * 저장 계획선 기준 · 글 한 줄) · 그림 = 평면 「횡단」 판과 같은 B06 카드(구조물 형상 · 포장 · 면적 · 505-3 · + * 서버 미리 뜬 것만 선 그림 `buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시. * [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가 * 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1). * ========================================================================== */ @@ -20,7 +21,7 @@ import { } from "../A00_Common/design/logic/design_profile_free"; import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../B06_Section/B06_Section_UI_Longitudinal"; import { previewStructureSections } from "../B06_Section/B06_Section_Api_Fetch"; -import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { pagePipeline, pipelineDetail } from "../A00_Common/design/design_pipeline_page"; import { pipelineCrossAtChainage } from "../A00_Common/design/design_pipeline_probe"; import { stationLabel } from "../common_util/common_util_station"; import { plusStationsOf, writePlusDraft } from "./B05_Profile_Api_PlusStations"; @@ -30,6 +31,7 @@ import { CROSS_PLOT_PAD, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; +import { crossSourceOf, routeCrossCard } from "./B05_Profile_UI_RouteEdit_Cross_Card"; import { crossPickAt, groundElevationAt, @@ -196,9 +198,9 @@ function createCrossModal(host: HTMLElement): CrossModal { 임시 횡단
- - - + + +
@@ -241,7 +243,7 @@ function createCrossModal(host: HTMLElement): CrossModal { const now = await pipelineCrossAtChainage(projectId(), m, current?.intervalM ?? 20).catch( () => null, ); - const [cross] = now ? [now] : await previewStructureSections(projectId(), [m]); + const [cross] = now ? [now.preview] : await previewStructureSections(projectId(), [m]); if (mine !== turn) return; if (!cross) { foot.textContent = "이 자리의 횡단을 뜨지 못했습니다."; @@ -249,21 +251,37 @@ function createCrossModal(host: HTMLElement): CrossModal { } const preview = cross as unknown as CrossPreviewResponse; const rect = box.getBoundingClientRect(); - const plot = buildCrossSvg( - preview, - Math.round(rect.width) || 320, - Math.round(rect.height) || 180, - ); - box.replaceChildren(plot.svg); - attachSvgZoomPan({ - svg: plot.svg, - layer: plot.layer, - widthPx: plot.width, - heightPx: plot.height, - pad: CROSS_PLOT_PAD, - content: plot.content, - wheelZoom: true, - }); + const [width, height] = [Math.round(rect.width) || 320, Math.round(rect.height) || 180]; + if (now) { + // 「횡단」 판과 같은 B06 카드(505-3) — 그림 밖 높이만큼 줄여 한 번 더 지어 칸에 맞춤 + const detail = pipelineDetail(projectId()); + const source = crossSourceOf( + projectId(), + now.section, + detail?.cross_sections ?? [now.section], + detail?.longitudinal.design_profiles, + "modal", + ); + const build = (plotHeight: number): HTMLElement => { + const card = routeCrossCard(source, current?.intervalM ?? 20, width, plotHeight); + box.replaceChildren(card); + return card; + }; + const extra = build(height).offsetHeight - height; + if (extra > 0) build(Math.max(120, height - extra)); + } else { + const plot = buildCrossSvg(preview, width, height); + box.replaceChildren(plot.svg); + attachSvgZoomPan({ + svg: plot.svg, + layer: plot.layer, + widthPx: plot.width, + heightPx: plot.height, + pad: CROSS_PLOT_PAD, + content: plot.content, + wheelZoom: true, + }); + } const planZ = Number( (preview.design as { design_elevation_m?: unknown } | null)?.design_elevation_m, ); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts index f68879dce..f99e4050e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts @@ -15,7 +15,7 @@ import "../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; import { pipelineDetail } from "../A00_Common/design/design_pipeline_page"; import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; import { nearestWithin } from "../A00_Common/design/design_selection"; -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import type { CrossSection, DesignProfile } from "../B06_Section/B06_Section_Api_Fetch"; import { peekSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { displayHalfWidth } from "../B06_Section/B06_Section_Display_Width"; import { pageRouteId } from "../A00_Common/design/design_page_route"; @@ -74,7 +74,17 @@ export function routeCrossSource( const sections = detail?.cross_sections ?? []; const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m); if (!detail || !found) return null; - const profiles = detail.longitudinal.design_profiles; + return crossSourceOf(projectId, found, sections, detail.longitudinal.design_profiles, pane); +} + +/** 단면 하나 → 판 재료(사본) — 이웃 단면 · 계획선은 연동 관 · 반폭 · 계획고 재료(종단 임시 횡단 창도 씀 · 505-3). */ +export function crossSourceOf( + projectId: string, + found: CrossSection, + sections: readonly CrossSection[], + profiles: DesignProfile[] | undefined, + pane: string, +): RouteCrossSource { const planZ = designElevationAt(profiles, found.chainage_m); return { section: { @@ -146,6 +156,14 @@ export function routeCrossCard( box, ); // 그래프만(393-3) — 카드 머리 · 줌 단추 · 발(암 경계 · 요약) 걷음 · 벽 · 선 · 면적 고름 막음 + // 경고 칩(빈칸 · 기본값 · 사면 열림)만 그래프 오른쪽 위에 겹쳐 남김(505-3) + const chips = card.querySelectorAll(":scope > header .b06-cross-card__warning"); + if (chips.length) { + const box = document.createElement("div"); + box.className = "b05-cross-chips"; + box.append(...chips); + card.append(box); + } card .querySelectorAll(":scope > header, :scope > footer, .b06-cross-card__zoom") .forEach((el) => el.remove()); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 42ab19b74..a2ed117c6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -180,6 +180,13 @@ export function createCurveBar(params: CurveBarParams): CurveBar { ); } + // 가둠 판(도구줄 · 횡단 판 · 종단 판)이 열리고 닫히고 크기가 바뀌면 다시 가둠(505-3) · 편집 창이 닫히면 끊음 + const watched = new Set(); + const refit = new ResizeObserver(() => { + if (!params.canvas.isConnected) return refit.disconnect(); + sync(); + }); + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */ function sync(): void { const { @@ -199,11 +206,25 @@ export function createCurveBar(params: CurveBarParams): CurveBar { return; } const rect = params.canvas.getBoundingClientRect(); + // 가둠 = 도구줄 아래 · 열린 횡단 판 왼쪽 · 뜬 종단 판 위(505-3 · 판 밑에 깔려 가리지 않게) + const main = params.canvas.closest(".design-page__main") ?? params.canvas.parentElement; + const box = (selector: string): DOMRect | null => { + const element = main?.querySelector(selector); + if (element && !watched.has(element)) { + watched.add(element); + refit.observe(element); + } + const found = element?.getBoundingClientRect(); + return found && found.width > 0 && found.height > 0 ? found : null; + }; + const tools = box(".b05-routeedit__tools"); + const side = box(".b05-routeedit__side-wrap"); + const profile = box(".b05-route-profile"); const bounds = { left: rect.left + 8, - top: rect.top + 8, - right: rect.right - 8, - bottom: rect.bottom - 8, + top: Math.max(rect.top, tools?.bottom ?? rect.top) + 8, + right: Math.min(rect.right, side?.left ?? rect.right) - 8, + bottom: Math.min(rect.bottom, profile?.top ?? rect.bottom) - 8, }; const last = planned.length - 1; if (picked === 0 || picked === last) { diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 4d4584636..4326777db 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -288,6 +288,32 @@ cursor: default; } +/* 카드 경고 칩(빈칸 · 기본값 · 사면 열림) — 머리를 걷어도 그래프 오른쪽 위에 겹쳐 남김(505-3) · 판 · 임시 횡단 창 같이 씀 */ +.b06-cross-card:has(> .b05-cross-chips) { + position: relative; +} + +.b05-cross-chips { + position: absolute; + top: 4px; + right: 4px; + z-index: 3; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + max-width: 70%; + pointer-events: none; +} + +.b05-cross-chips > .b06-cross-card__warning { + padding: 0 4px; + border-radius: var(--radius-4, 4px); + background: color-mix(in srgb, var(--color-surface-raised) 85%, transparent); + font-size: var(--text-caption); + pointer-events: auto; +} + .b05-routeedit__cross-foot:empty { display: none; } diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index b7ed1724d..68b790170 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -231,7 +231,7 @@ export function stationSection( /** 측점마다 그림 자료 · 카드 이름 = 지금 현황을 그 자리에 다시 얹음(캐시 `withDraftShapes` · 이름 = 측점 계획 * 제목 · 457) — 옛 측점 그림을 옮기지 않음. */ -function attachStatus(sections: CrossSection[], status: ShapeStatus): void { +export function attachStatus(sections: CrossSection[], status: ShapeStatus): void { const { pipes, structures, types } = status; attachShapes(sections as unknown as Array>, pipes, structures, types); const planned = planStations(pipes, structures, types, registryDefaults(types)); diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 7b5c83036..bf1a2099b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -436,6 +436,12 @@ html.is-bm-route .b06-section__notice-bar { stroke-width: 2.5; } +/* 지반선 · 계획선은 누름을 통과 — 겹친 측점선 누름이 측점 고름 · 임시 횡단으로 감(505-3) */ +.b06-chart__profile, +.b06-chart__design-profile { + pointer-events: none; +} + .b06-chart__cross-profile { fill: none; stroke: var(--color-chart-1); diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 75b378f9a..ba4a78ba4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -1,6 +1,6 @@ /* B07 상세 설계 도면 목록·단건 API 클라이언트. */ -import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import type { CadGuide } from "@ui/cad_host/cad_host"; export interface DesignDrawingItem { @@ -138,9 +138,14 @@ export interface DesignDrawingConfirmResponse { design?: CrossDesignInfo | null; } -async function requestJson(path: string, init: RequestInit = {}): Promise { +/** `timeoutMs` = 끊는 한도 — 도면 GET · 저장 PUT 은 `API_ANALYSIS_TIMEOUT_MS`(평면도 작도 · 횡단 읽기가 30 s 넘음). */ +async function requestJson( + path: string, + init: RequestInit = {}, + timeoutMs: number = API_TIMEOUT_MS, +): Promise { const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, @@ -164,7 +169,11 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); + return requestJson( + `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`, + {}, + API_ANALYSIS_TIMEOUT_MS, + ); } export function confirmDesignDrawing( @@ -179,6 +188,7 @@ export function confirmDesignDrawing( method: "PUT", body: JSON.stringify({ drawing, quantity_table: quantityTable ?? null }), }, + API_ANALYSIS_TIMEOUT_MS, ); } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index cfb99a5fa..f1960af31 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -64,7 +64,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( from common_util.common_util_dev_unlock import is_dev_environment, unlock_status from common_util.common_util_drainage_context import load_drainage_context from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path -from common_util.common_util_workflow_state import mark_stage, start_stage +from common_util.common_util_workflow_state import mark_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -533,7 +533,7 @@ async def invalidate_design_drawing( patch={"status": "provisional"}, ) async with connection.cursor() as cursor: - await start_stage(cursor, str(project_id), 4) + await mark_stage(str(project_id), 4, "save", cursor) await connection.commit() except Exception: await connection.rollback() @@ -623,7 +623,7 @@ async def put_cross_layout(project_id: UUID, request: CrossLayoutRequest) -> Any patch={"status": "provisional"}, ) async with connection.cursor() as cursor: - await start_stage(cursor, str(project_id), 4) + await mark_stage(str(project_id), 4, "save", cursor) await connection.commit() except Exception: await connection.rollback() diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Reset.py b/B07_DesignDetail/B07_DesignDetail_Router_Reset.py index bf448ede1..358db1e1e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Reset.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Reset.py @@ -3,7 +3,7 @@ POST /api/projects/{id}/design-drawings/reset 본문 없음 기준 = 저장본 없이 지금 B06 정본으로 그린 원본(떠 두지 않음 · 앞 단계 저장 = 저절로 새 기준). -지움 = 모든 저장본 확정 풂(`confirmed=False` · 도면번호 · json 남김 = [원본에서 다시 그리기] 꼴) · +지움 = 모든 저장본 확정 풂(`confirmed=False` · 도면번호 남김 · json 걷음 = [원본에서 다시 그리기] 꼴) · B07 이 확정으로 올린 측점 설계 → 잠정 · 설정 파일(축척 · 횡단 배치 · 표 배율 · 평면도 종류 · 표식). M02 층(도각 · 표지)은 안 건드림. 단계 = [저장]과 같음(`mark_stage` save). """ diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py index c7b847d3f..d1936097b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py @@ -67,8 +67,15 @@ def _read_manifest(project_root: Path) -> dict[str, Any]: def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: + """쓰기 한 곳 — 풀린 장(`confirmed` 거짓)의 저장본 json · 수량표 찌꺼기를 함께 걷음.""" stage_root = _design_root(project_root) stage_root.mkdir(parents=True, exist_ok=True) + for drawing_id, entry in manifest["drawings"].items(): + if entry.get("confirmed") or "file" not in entry: + continue + (stage_root / "drawings" / f"{drawing_id}.json").unlink(missing_ok=True) + for key in ("file", "quantity_table", "quantity_tables"): + entry.pop(key, None) path = stage_root / "manifest.json" temporary = path.with_suffix(".tmp") temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py index f7e1ee090..f2e971a90 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py @@ -29,7 +29,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _write_manifest, ) from common_util.common_util_json import atomic_write_json -from common_util.common_util_workflow_state import start_stage +from common_util.common_util_workflow_state import mark_stage from config.config_db import get_db_pool router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) @@ -176,7 +176,7 @@ async def put_drawing_scales(project_id: UUID, request: DrawingScalesRequest) -> patch={"status": "provisional"}, ) async with connection.cursor() as cursor: - await start_stage(cursor, str(project_id), 4) + await mark_stage(str(project_id), 4, "save", cursor) await connection.commit() except Exception: await connection.rollback() diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 5a6d8292b..23f7a7fb2 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -56,6 +56,8 @@ import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle"; import { hasRedrawable, + isSaving, + recoveryScopeOf, redrawFromSource, resetDrawings, saveDrawingsNow, @@ -77,8 +79,6 @@ interface DesignMeta { /** 참이면 CAD 읽기 전용 — 잠금 없음(요구 281)이라 늘 거짓 · 저장본도 그대로 고침. */ confirmed: boolean; quantityTable: QuantityTable | null; - hasPrev: boolean; - hasNext: boolean; } /** CAD 저장 응답 (도면 + 편집된 수량표). */ @@ -129,7 +129,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { cadDirty = dirty; if (dirty) tabs.markDirty(); }, - onNavigate: (direction) => navigateDrawing(direction), onExport: (fileFormat) => void exportDrawingFile(fileFormat), onGuideChange: (area) => frameEditor.guideChanged(area), }); @@ -201,19 +200,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }); }; - const buildMeta = ( - drawing: DesignDrawingItem, - response: DesignDrawingResponse, - index: number, - ): DesignMeta => ({ + const buildMeta = (drawing: DesignDrawingItem, response: DesignDrawingResponse): DesignMeta => ({ kind: drawing.kind, drawingId: drawing.id, title: drawing.label, info: drawing.kind === "cross" ? drawing.label : "", confirmed: false, // 잠금 없음(요구 281) quantityTable: response.quantity_table ?? null, - hasPrev: index > 0, - hasNext: index < drawings.length - 1, }); /** @@ -256,8 +249,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const prefetchAllDrawings = async (): Promise => { if (!projectId) return; for (const item of drawings) { - // 클릭이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다. - while (loadInFlight) await new Promise((done) => window.setTimeout(done, 120)); + // 클릭 · [저장]이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다. + while (loadInFlight || isSaving()) await new Promise((done) => window.setTimeout(done, 120)); if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다 try { await requestDrawing(item); @@ -296,8 +289,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { updateInfoPanel(drawing, response); // 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만) const guide = response.guide ?? null; - const meta = buildMeta(drawing, response, index); - cad.load(stash ?? response.drawing, meta, false, {}, { guide }); + const meta = buildMeta(drawing, response); + const recoveryScope = recoveryScopeOf(projectId, drawing.id); + cad.load(stash ?? response.drawing, meta, false, {}, { guide, recoveryScope }); if (guideShown() && guide?.marks?.length) { showToast(`${guide.marks.length} 칸 넘침 — 열 · 행을 줄일 것`, "warning"); } @@ -352,13 +346,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { void openTab(drawing); }; - const navigateDrawing = (direction: "prev" | "next") => { - if (currentIndex < 0) return; - const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1; - if (target < 0 || target >= drawings.length) return; - void openTab(drawings[target]); - }; - const requestCadDrawing = (): Promise => cad.requestSave(); /** 저장 · 원본 되돌림 묶음(`_UI_Save`)이 쓰는 상태 길. */ @@ -411,7 +398,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { reset: () => resetFrameTemplate(projectId as string), }, sendLoad: (drawing, meta, frameEdit, frameFields, extra) => - cad.load(drawing, meta, frameEdit, frameFields, extra), + cad.load(drawing, meta, frameEdit, frameFields, { + ...extra, + recoveryScope: `b07-frame:${projectId}`, + }), setGuideArea: (area) => cad.setGuideArea(area), requestCadDrawing: async () => (await requestCadDrawing()).drawing, restoreDrawing: () => { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Save.ts b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts index 14baf3598..b33365c5c 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Save.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts @@ -20,6 +20,7 @@ import type { CadSaveResult } from "@ui/cad_host/cad_host"; import type { CadTabs } from "@ui/cad_host/cad_host_tabs"; import { confirmDesignDrawing, + fetchDesignDrawingList, invalidateDesignDrawing, resetDesignDrawings, type CadDrawing, @@ -44,6 +45,24 @@ export interface DrawingStore { reload: (drawing: DesignDrawingItem) => Promise; } +/** 저장 PUT 이 도는 중인가 — 미리받기(`_UI_Page`)가 이 동안 쉼. */ +let saving = false; +export const isSaving = (): boolean => saving; + +/** CAD 자동백업 칸 이름 — 프로젝트 · 도면마다(같은 도면 id 의 남의 프로젝트 백업과 안 겹침). */ +export const recoveryScopeOf = (projectId: string, drawingId: string): string => + `b07:${projectId}:${drawingId}`; + +/** 끊긴(abort) 저장 — 서버는 끝까지 저장하므로 목록의 `confirmed` 로 판정 · 못 읽으면 거짓. */ +async function savedOnServer(projectId: string, id: string): Promise { + try { + const list = await fetchDesignDrawingList(projectId); + return list.drawings.some((item) => item.id === id && item.confirmed); + } catch { + return false; + } +} + /** 좌측 단추 저장본 띠(초록) — 저장본 = 서버 `confirmed`. */ function markButton(id: string, saved: boolean): void { document @@ -77,12 +96,18 @@ export async function saveEditedDrawings(store: DrawingStore): Promise drawing: CadDrawing, table: QuantityTable | null, ) => { - await confirmDesignDrawing(store.projectId, item.id, drawing, table); + try { + await confirmDesignDrawing(store.projectId, item.id, drawing, table); + } catch (error) { + const aborted = error instanceof DOMException && error.name === "AbortError"; + if (!aborted || !(await savedOnServer(store.projectId, item.id))) throw error; + } item.confirmed = true; markButton(item.id, true); store.forget(item.id); // 저장본이 정본 — 다음 열기는 서버에서 }; showLoadingOverlay(); + saving = true; try { if (currentEdited && current) { const saved = await store.requestCad(); @@ -107,6 +132,7 @@ export async function saveEditedDrawings(store: DrawingStore): Promise ); return false; } finally { + saving = false; hideLoadingOverlay(); } } @@ -160,7 +186,7 @@ export async function saveDrawingsNow(store: DrawingStore, onSaved: () => void): onSaved(); } -/** CAD 자동백업 칸(`openwebcad/src/helpers/autosave.ts`) — 칸 이름 = 도면 id. */ +/** CAD 자동백업 칸(`openwebcad/src/helpers/autosave.ts`) — 칸 이름 = `recoveryScopeOf`. */ const CAD_RECOVERY_KEY = "OPEN_WEB_CAD__RECOVERY"; /** @@ -174,14 +200,17 @@ export async function resetDrawings(store: DrawingStore): Promise { } const ok = await showConfirmDialog( "이 페이지에서 저장한 도면 · 고친 것 · 설정(축척 · 횡단 배치 · 평면도 종류 · 표식)을 모두 지우고 " + - "지금 앞 단계 값으로 다시 그릴까요?", + "지금 앞 단계 값으로 다시 그릴까요? " + + "(프로젝트 도각은 그대로 — 되돌리려면 [도각 편집] → [기본 도각 복원])", "초기화", ); if (!ok) return; showLoadingOverlay(); try { await resetDesignDrawings(store.projectId); - store.drawings.forEach((item) => localStorage.removeItem(`${CAD_RECOVERY_KEY}__${item.id}`)); + store.drawings.forEach((item) => + localStorage.removeItem(`${CAD_RECOVERY_KEY}__${recoveryScopeOf(store.projectId, item.id)}`), + ); showToast("초기화했습니다.", "success"); location.reload(); } catch (error) { diff --git a/B07_DesignDetail/openwebcad/src/App.types.ts b/B07_DesignDetail/openwebcad/src/App.types.ts index 124c4f0a8..db75fc6de 100644 --- a/B07_DesignDetail/openwebcad/src/App.types.ts +++ b/B07_DesignDetail/openwebcad/src/App.types.ts @@ -51,9 +51,6 @@ export interface DesignMeta { confirmed: boolean; /** 수량 산출표 값 (횡단도만). 미산정 항목은 null. */ quantityTable: Record | null; - /** 이전/다음 도면 존재 여부 (경계에서 버튼 비활성화) */ - hasPrev: boolean; - hasNext: boolean; } export interface StateMetaData { diff --git a/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx b/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx index ae9191758..a1ee871b3 100644 --- a/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx +++ b/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx @@ -6,6 +6,7 @@ import { commonValue, LINE_TYPES, LINE_WIDTHS, + paintNowCommitLater, restyle, selectionLineStyle, styleTargets, @@ -47,11 +48,13 @@ export const PropertiesEditor: FC = ({ compact = false }) type="color" value={view.color || '#000000'} data-mixed={view.color ? undefined : 'true'} - onChange={(event) => - applyToLines((entity) => { - entity.lineColor = event.target.value; - }) - } + onChange={(event) => { + // 끄는 동안 = 화면만 · 멎은 뒤 되돌리기 한 칸(리본 색 칸과 같은 길) + const color = event.target.value; + paintNowCommitLater(styleTargets(getSelectedEntities()), (entity) => { + entity.lineColor = color; + }); + }} /> diff --git a/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx b/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx index 4b77e6555..11555c878 100644 --- a/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx +++ b/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx @@ -7,7 +7,7 @@ import type { TextEntity } from '../entities/TextEntity'; import { LINE_TYPES, LINE_WIDTHS, - repaint, + paintNowCommitLater, restyle, selectionLineStyle, styleTargets, @@ -15,7 +15,6 @@ import { import { getActiveLayerId, getActiveTextStyle, - getEntities, getLayers, getSelectedEntities, setActiveLayerId, @@ -23,29 +22,10 @@ import { setActiveLineDash, setActiveLineWidth, setActiveTextStyle, - setEntities, } from '../state'; const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace']; -/** - * 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면 - * 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 첫 변경에 사본을 끼우고 끄는 동안에는 - * 그 사본만 칠하고, 멎은 뒤 한 번만 되돌리기·통지에 싣는다(488 — Ctrl+Z 한 번에 옛 색). - */ -const COLOR_COMMIT_DELAY_MS = 350; -let colorCommitTimer: ReturnType | undefined; -let painting: Entity[] | null = null; -function paintNowCommitLater(targets: Entity[], change: (entity: Entity) => void): void { - if (painting) repaint(painting, change); - else painting = restyle(targets, change, false); // 화면만 - clearTimeout(colorCommitTimer); - colorCommitTimer = setTimeout(() => { - painting = null; - setEntities([...getEntities()], true); // 멎었다 — 이번 한 번만 실어 보낸다 - }, COLOR_COMMIT_DELAY_MS); -} - /** 고른 선 있으면 그것만 바꿈(488 — 그리기 기본값 그대로) · 없으면 그리기 기본값만 */ const selectedLines = () => styleTargets(getSelectedEntities()); diff --git a/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts b/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts index fa9b60bcd..cc5e06fa0 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts @@ -106,3 +106,22 @@ export function repaint(copies: Entity[], change: (entity: Entity) => void): voi setEntities([...getEntities()], false); notifyWindow(HtmlEvent.UPDATE_STATE); // 견본 색도 따라감 } + +/** + * 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면 + * 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 첫 변경에 사본을 끼우고 끄는 동안에는 + * 그 사본만 칠하고, 멎은 뒤 한 번만 되돌리기·통지에 싣는다(488 — Ctrl+Z 한 번에 옛 색). + * 리본 특성 · 특성 판 색 칸이 같이 씀(506-3). + */ +const COLOR_COMMIT_DELAY_MS = 350; +let colorCommitTimer: ReturnType | undefined; +let painting: Entity[] | null = null; +export function paintNowCommitLater(targets: Entity[], change: (entity: Entity) => void): void { + if (painting) repaint(painting, change); + else painting = restyle(targets, change, false); // 화면만 + clearTimeout(colorCommitTimer); + colorCommitTimer = setTimeout(() => { + painting = null; + setEntities([...getEntities()], true); // 멎었다 — 이번 한 번만 실어 보낸다 + }, COLOR_COMMIT_DELAY_MS); +} diff --git a/B07_DesignDetail/openwebcad/src/state.test.ts b/B07_DesignDetail/openwebcad/src/state.test.ts index 5a1351e4d..3c5548867 100644 --- a/B07_DesignDetail/openwebcad/src/state.test.ts +++ b/B07_DesignDetail/openwebcad/src/state.test.ts @@ -40,8 +40,6 @@ const meta = (confirmed: boolean): DesignMeta => ({ info: '', confirmed, quantityTable: null, - hasPrev: false, - hasNext: false, }); describe('setActiveLayerId', () => { diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index f243d5d1e..ad64ff267 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -523,8 +523,6 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => { export const isHostSave = (): boolean => hostSave; export const setHostSave = (enabled: boolean) => { hostSave = enabled; - // 제목 줄이 없어지면 그 높이만큼 위로 당긴다(App.css `.cad-host-save`). - document.documentElement.classList.toggle('cad-host-save', enabled); }; export const isBare = (): boolean => bare; export const setBare = (enabled: boolean) => { diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts index 9d54f5c6c..e80a22ae0 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts @@ -16,7 +16,6 @@ import { getEntities, getLayers, setActiveLayerId, - setEntities, setLayers, setSelectedEntityIds, } from '../../state'; @@ -223,10 +222,13 @@ export const layerMergeToolStateMachine = createSequenceTool({ toast.warn('같은 레이어입니다.'); return; } - for (const entity of getEntities()) { - if (entity.layerId === sourceId) entity.layerId = targetId; - } - setEntities([...getEntities()], true); + // 사본으로 갈아 끼움(제자리 고침 = 되돌리기 칸이 같은 참조 → Ctrl+Z 해도 그대로) · 층 목록 = LAYERP + restyle( + getEntities().filter((entity) => entity.layerId === sourceId), + (entity) => { + entity.layerId = targetId; + } + ); updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId)); if (getActiveLayerId() === sourceId) setActiveLayerId(targetId); toast.success(`레이어를 ${layerNameOf(targetId)}(으)로 병합했습니다.`); diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 83559a3cd..e9dc4b02d 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -309,6 +309,7 @@ export function renderB09Estimation(root: HTMLElement): void { payload && projectId && readState("b09-estimation", projectId) ? run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }) : Promise.resolve(true); + DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3) const refresh = createButton({ label: "업데이트", variant: "ghost", diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index e5f21b5ac..1adb10aad 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -132,6 +132,8 @@ export const DRAFT: { panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; actions: Array<() => HTMLElement>; next?: () => void; + /** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */ + save?: () => Promise; } = { builders: {}, extend: [], panels: [], actions: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ diff --git a/B09_Estimation/draft/B09_Draft_Print_UI.ts b/B09_Estimation/draft/B09_Draft_Print_UI.ts index e495bfaf0..10508c02b 100644 --- a/B09_Estimation/draft/B09_Draft_Print_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Print_UI.ts @@ -1,7 +1,8 @@ /* ============================================================================= * B09_Draft_Print_UI.ts * 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 - * 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → #/b09-print(`…_Print_Route`) · + * 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → 저장(`DRAFT.save`) 뒤 #/b09-print(`…_Print_Route`) + * · 단계 그대로 · 저장 실패면 머묾 · * [>] 다음 길(`DRAFT.next` · 저장 · 단계 6 완료 뒤 · 요구 298-2) * draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐 * ========================================================================== */ @@ -18,7 +19,10 @@ DRAFT.actions.push(() => { const button = createButton({ label: "인쇄 미리보기", variant: "ghost", - onClick: () => navigateTo(ROUTE.path as RoutePath), + onClick: () => + void (DRAFT.save?.() ?? Promise.resolve(true)).then( + (ok) => ok && navigateTo(ROUTE.path as RoutePath), + ), }); button.dataset.b09Print = ""; return button; diff --git a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts index 099adde23..f8e920923 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts @@ -31,6 +31,7 @@ export const DRAWING_KEYS: [string, string][] = [ ["SC · MI · O", "축척 · 대칭 · 간격 띄우기"], ["TR · EX · F", "자르기 · 연장 · 모깎기"], ["E · Delete", "지우기"], + ["IMPORT · 파일 끌어 놓기", "DWG · DXF 불러오기(고치기 카드)"], ["누름 · 끌기 · Ctrl+A", "고르기 · 전부"], ["Esc", "고름 풀기 · 명령 멈춤"], ["Ctrl+Z · Ctrl+Y", "되돌리기 · 다시 하기"], diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 412d06e3e..f3eea9bdd 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -47,7 +47,11 @@ async def start_stage( stage_no: int, params: Dict[str, Any] | None = None, ) -> None: - """단계를 시작하여 IN_PROGRESS 상태로 만들고, 이후 단계들을 STALE로 전환한다.""" + """단계를 시작하여 IN_PROGRESS 상태로 만들고, 이후 단계들을 STALE로 전환한다. + + `message` 의 개발 잠금풀기 표식(`DEV_UNLOCK:` · `common_util_dev_unlock`)은 남김 — 지우면 + 우회로 연 단계가 쓰기 한 번에 다시 잠김. + """ now = datetime.utcnow() params_json = json.dumps(params, ensure_ascii=False) if params is not None else None @@ -58,7 +62,7 @@ async def start_stage( SET state = 'IN_PROGRESS', progress_percent = 0, params = COALESCE(%s, params), - message = NULL, + message = CASE WHEN message LIKE 'DEV_UNLOCK:%%' THEN message ELSE NULL END, started_at = %s, completed_at = NULL WHERE project_id = %s AND stage_no = %s diff --git a/config/config_dev_server.py b/config/config_dev_server.py new file mode 100644 index 000000000..487a5a3fc --- /dev/null +++ b/config/config_dev_server.py @@ -0,0 +1,301 @@ +""" +config_dev_server.py +개발 서버 전용 — 프런트(Vite) 띄움·끔 + 파이썬 자동 리로드 범위 + +main.py 에서 쪼갠 것(동작 그대로). 운영에서는 `serve_frontend_dev` 가 아무것도 안 한다. +""" + +import locale +import logging +import os +import signal +import subprocess +from pathlib import Path + +from config.config_system import ENVIRONMENT + +logger = logging.getLogger(__name__) + +_ROOT = Path(__file__).resolve().parent.parent + +# 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다. +FRONTEND_DEV_PORT = int(os.getenv("FRONTEND_DEV_PORT", "5173")) +# 띄운 개발 서버 핸들 — 종료 때 이 계통을 통째로 끝낸다. +_frontend_dev_process: subprocess.Popen | None = None + + +def _frontend_process_env(root_dir: Path) -> dict[str, str]: + """config/node_modules를 사용하는 프론트엔드 프로세스 환경을 만든다.""" + env = os.environ.copy() + node_modules = root_dir / "config" / "node_modules" + bin_dir = node_modules / ".bin" + env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" + env["NODE_PATH"] = str(node_modules) + return env + + +def build_frontend() -> bool: + """프론트엔드 빌드 (npm run build from project root)""" + root_dir = _ROOT + + logger.info("[Frontend] npm run build 시작...") + try: + result = subprocess.run( + "npm run build", + shell=True, + cwd=str(root_dir), + env=_frontend_process_env(root_dir), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=300, + ) + if result.returncode == 0: + logger.info("✓ 프론트엔드 빌드 완료") + return True + else: + logger.error(f"✗ 프론트엔드 빌드 실패:\n{result.stderr}") + return False + except subprocess.TimeoutExpired: + logger.error("✗ 프론트엔드 빌드 타임아웃 (300초)") + return False + except Exception as e: + logger.error(f"✗ 프론트엔드 빌드 중 예외: {e}") + return False + + +def _kill_process_tree(pid: int) -> None: + """프로세스와 그 자식들을 함께 끝낸다. + + `shell=True`로 띄우면 실제 계통이 cmd.exe → npm → node라 부모만 죽이면 node가 남는다. + 남은 node가 포트를 계속 물고 있으면 다음 실행 때 Vite가 옆 포트로 올라가고, 사용자는 + 옛 화면을 보게 된다(2026-08-01 실제로 13개가 쌓였다). + """ + try: + if os.name == "nt": + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(pid)], + check=False, + capture_output=True, + ) + else: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except Exception as exc: # 이미 죽었으면 그것으로 됐다 + logger.debug("[Frontend] 프로세스 정리 중 무시된 예외: %s", exc) + + +def dev_port_listening(port: int) -> bool: + """개발 서버(Vite)가 이미 그 포트를 물고 있나. + + **리로드인지 첫 기동인지를 가르는 하나뿐인 표식**이다. uvicorn 리로더는 코드가 바뀌면 + 앱 프로세스만 다시 띄우고 Vite 자식은 그대로 두므로, 포트가 살아 있으면 리로드다. + 환경변수 같은 별도 깃발을 두지 않는 까닭은 그것이 **실제와 어긋날 수 있기** 때문이다 — + 포트는 어긋나지 않는다. + """ + import socket + + probe = socket.socket() + probe.settimeout(0.5) + try: + probe.connect(("127.0.0.1", port)) + return True + except OSError: + return False + finally: + probe.close() + + +def _free_dev_port(port: int) -> None: + """개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다. + + 앞선 실행이 비정상 종료(강제 종료·전원 차단)했을 때를 대비한 청소다. 이걸 하지 않으면 + Vite가 `--strictPort` 때문에 기동에 실패한다. + """ + if os.name != "nt": + return + try: + # 한글 Windows의 netstat는 콘솔 코드페이지(cp949)로 찍는다 — 기본 utf-8로 읽으면 + # 한글 헤더에서 UnicodeDecodeError가 나고, 그 예외가 읽기 스레드에서 터져 + # 포트 정리가 통째로 죽는다(2026-08-16 실제 발생). 시스템 인코딩으로 읽고, + # 깨지는 바이트는 넘긴다 — 우리가 볼 것은 숫자 포트·PID뿐이다. + result = subprocess.run( + ["netstat", "-ano", "-p", "TCP"], + check=False, + capture_output=True, + text=True, + encoding=locale.getpreferredencoding(False), + errors="replace", + ) + except Exception: + return + needle = f":{port} " + for line in (result.stdout or "").splitlines(): + if needle not in line or "LISTENING" not in line: + continue + pid = line.split()[-1] + if pid.isdigit() and int(pid) != os.getpid(): + logger.info("[Frontend] 포트 %d를 물고 있던 잔여 프로세스 %s 정리", port, pid) + _kill_process_tree(int(pid)) + + +def serve_frontend_dev() -> None: + """프론트엔드 개발 서버 실행 (npm run dev from project root) — 선택 사항. + + 포트를 **고정**(`--strictPort`)한다. 예전에는 포트가 비어 있지 않으면 Vite가 조용히 + 옆 포트로 올라갔고, 그렇게 뜬 옛 인스턴스가 계속 살아남아 하루치 변경이 화면에 반영되지 + 않는 일이 있었다. 고정해 두면 포트가 막혀 있을 때 그 자리에서 드러난다. + """ + global _frontend_dev_process + root_dir = _ROOT + + if ENVIRONMENT == "production": + logger.info("[Frontend] 프로덕션 환경: 개발 서버 실행 스킵") + return + + _free_dev_port(FRONTEND_DEV_PORT) + logger.info("[Frontend] npm run dev 시작 (포트 %d 고정, 백그라운드)...", FRONTEND_DEV_PORT) + try: + _frontend_dev_process = subprocess.Popen( + f"npm run dev -- --host --port {FRONTEND_DEV_PORT} --strictPort", + shell=True, + cwd=str(root_dir), + env=_frontend_process_env(root_dir), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + # POSIX에서 자식까지 한 번에 끝내려면 프로세스 그룹을 따로 잡아야 한다. + start_new_session=os.name != "nt", + ) + logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행 (pid %s)", _frontend_dev_process.pid) + except Exception as e: + logger.warning(f"⚠ 프론트엔드 개발 서버 실행 실패: {e}") + + +def stop_frontend_dev() -> None: + """앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다. + + ⚠⚠ **개발 중에는 내리지 않는다** (2026-09-12 실측으로 드러난 고리). + uvicorn 리로더는 `*.py` 가 바뀔 때마다 앱 프로세스를 갈아 끼운다. 그때 여기서 + Vite 를 죽이면 **새 프로세스는 「Vite 가 없다」고 보고 `npm run build` 를 처음부터 + 다시 돌린다**(최대 300초 + B07 CAD). 그 사이 화면은 뜨지 않고 `/api/health` 는 + `stale: true` 인 채로 옛 코드를 계속 내준다 — 그날 리로드가 두 번 멈췄고 한 번은 + 두 시간 반 동안 안 끝났다. **옛 화면을 보고 헛검증하게 만드는 자리다.** + ⇒ Vite 는 **한 앱 프로세스가 아니라 개발 세션의 것**이다. 살려 두고 다음 기동이 + 그대로 물려쓴다(`dev_port_listening`). 남은 유령은 다음 기동의 + `_free_dev_port` 가 포트로 잡아 정리하므로 쌓이지 않는다. + """ + global _frontend_dev_process + if ENVIRONMENT == "development": + logger.info("[Frontend] 개발 서버는 살려 둔다 — 다음 기동이 그대로 물려쓴다") + return + if _frontend_dev_process is None: + return + logger.info("[Frontend] 개발 서버 종료 (pid %s)", _frontend_dev_process.pid) + _kill_process_tree(_frontend_dev_process.pid) + _frontend_dev_process = None + # 셸을 거쳐 띄우므로 손자 프로세스가 남을 수 있다. 포트로 한 번 더 확인해 정리한다. + _free_dev_port(FRONTEND_DEV_PORT) + + +def reload_dirs() -> list[str]: + """자동 리로드가 **훑을 폴더만** 골라 준다 — 폴더째 주면 서버가 쉬는 중에도 CPU를 먹는다. + + uvicorn 의 StatReload 는 0.25초마다 지정 폴더를 통째로 훑는다. 아무것도 안 주면 + 현재 폴더가 대상이 되는데, 여기에는 `storage`(27GB · 프로젝트 산출물)와 `tmp`·`venv`· + `node_modules` 가 함께 들어 있다. 그래서 **요청이 하나도 없는 서버가 CPU 70%** 를 물고 + 있었고, 창 둘이 각각 그러니 기기가 늘 140% 눌린 채였다(2026-09-07 실측). 속도 계측이 + 회차마다 3배씩 흔들린 원인도 이것이다. + + 파이썬이 실제로 사는 폴더만 준다 — 화면 코드(TS)는 vite 가 따로 본다. + """ + root = _ROOT + # ⚠ 루트와 `config` 는 넣지 않는다 — uvicorn 은 준 폴더를 `rglob("*.py")` 로 **재귀**로 + # 훑는다. 루트를 주면 storage·venv(파이썬 파일만 7,530개)를 도로 다 훑고, `config` 안에는 + # `node_modules` 가 들어 있어 역시 무겁다. 그 대신 `main.py`·`config/` 는 + # uvicorn 이 더하는 현재 폴더 감시가 맡는다(`reload_excludes` 참조). + watched: list[str] = [] + for entry in sorted(root.iterdir()): + if not entry.is_dir() or entry.name.startswith(".") or entry.name in RELOAD_SKIP: + continue + # 링크(정션)는 통째로 건너뛴다. `node_modules` 안에 **저장소 루트를 되가리키는 정션**이 + # 있어(`B07_DesignDetail/openwebcad/node_modules/forest-road-webapp` → 저장소 루트, + # npm 이 만든 것) 리로더가 그리로 되돌아가 `venv`·`storage` 를 다시 훑고 또 되감겼다. + # 실측(2026-09-07) — 감시 폴더를 훑으면 파일 **486,809개**, 한 바퀴 **1,045초**. + # 파이썬 `rglob` 은 정션을 따라 들어가고 PowerShell 은 안 따라가 눈으로는 안 보였다. + if entry.is_symlink(): + continue + # 의존성 트리가 **한 겹 아래에 숨어 있는** 경우도 뺀다 — 위 정션이 그 안에 있었다. + if (entry / "node_modules").is_dir() or any(entry.glob("*/node_modules")): + continue + if any(entry.rglob("*.py")): + watched.append(str(entry)) + return watched + + +# 파이썬이 있어도 훑지 않을 폴더 — 산출물·의존성·백업. +RELOAD_SKIP = { + "storage", + "tmp", + "venv", + "node_modules", + "__pycache__", + "log", + "docs", + "resources", + "scratch", + "0_old", + "db_management", + "migrations", + "ui_template", + "old_code", +} + + +def reload_excludes() -> list[str]: + """uvicorn 0.24 는 `reload_dirs` 와 무관하게 현재 폴더 전체를 감시에 더한다 + (`watchfilesreload.py` 의 `Path.cwd()` 추가) — 시험·tmp·docs 의 `.py` 하나로도 서버가 + 다시 읽힌다. 그 폴더들을 **절대 경로**로 제외한다(상대 이름은 uvicorn 이 안 맞춘다). + """ + return [ + str(e) + for e in sorted(_ROOT.iterdir()) + if e.is_dir() and (e.name in RELOAD_SKIP or e.name.startswith(".")) + ] + + +def patch_windows_reload() -> None: + """윈도우에서 uvicorn 리로더가 **영원히 멈추는 것**을 고친다 (2026-09-12 실측). + + 무엇이 잘못됐나 + uvicorn 0.24 의 `BaseReload.restart()` 는 윈도우에서만 옛 워커를 + `os.kill(worker_pid, CTRL_C_EVENT)` 로 내리려 한다. 그런데 CTRL_C_EVENT 는 + **프로세스 그룹을 지정해 보낼 수 없다** — MSDN 이 「그룹 id 가 0 이 아니면 호출은 + 성공하지만 신호는 전달되지 않는다」고 못박아 두었다. 워커는 리로더의 평범한 + 자식이라 그룹 우두머리가 아니므로 그 신호는 **아무 데도 가지 않는다.** + 바로 다음 줄 `join()` 이 영원히 안 풀려서 — + · 옛 워커는 살아서 **옛 코드로 요청을 계속 처리**하고 + · 새 워커는 태어나지 않으며 + · 그 뒤의 파일 변경은 **감지조차 안 된다**(리로더가 그 자리에 갇혀 있다) + 「고쳤는데 화면이 안 바뀐다」로 보이는 자리라 그냥 느린 것이 아니다. + + 실측 (그 자리를 그대로 흉내 낸 부모·자식 한 쌍) + CTRL_C_EVENT → **8초 안에 안 풀림** (DETACHED · CREATE_NO_WINDOW · + CREATE_NO_WINDOW+NEWGROUP 셋 모두) · `terminate()` → **0.00초** · + CTRL_BREAK_EVENT(자식을 그룹 우두머리로) → **0.00초** + ⇒ uvicorn 이 **리눅스에서 이미 쓰는** `terminate()` 가 윈도우에서도 즉시 먹는다. + 그래서 여기서는 그 리눅스 경로를 그대로 쓴다(새 방식을 지어내지 않는다). + + ⚠ 개발 기동(`DEBUG`)에서만 갈아 끼운다. uvicorn 이 위쪽에서 고치면 **이 함수를 지우면 + 된다** — 다른 자리는 아무것도 안 건드린다. + """ + from uvicorn._subprocess import get_subprocess + from uvicorn.supervisors.basereload import BaseReload + + def restart(self: BaseReload) -> None: + self.process.terminate() + self.process.join() + self.process = get_subprocess(config=self.config, target=self.target, sockets=self.sockets) + self.process.start() + + BaseReload.restart = restart + logger.info("[reload] 윈도우 리로더를 terminate() 방식으로 갈아 끼움") diff --git a/main.py b/main.py index b8135cf87..3740dd697 100644 --- a/main.py +++ b/main.py @@ -10,11 +10,8 @@ FastAPI 애플리케이션 진입점 """ import asyncio -import locale import logging import os -import signal -import subprocess import time from contextlib import asynccontextmanager from datetime import datetime @@ -99,6 +96,16 @@ from common_util.common_util_help_router import router as help_router from common_util.common_util_resource_monitor import sample_resources_loop from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool +from config.config_dev_server import ( + FRONTEND_DEV_PORT, + build_frontend, + dev_port_listening, + patch_windows_reload, + reload_dirs, + reload_excludes, + serve_frontend_dev, + stop_frontend_dev, +) # 설정 import from config.config_system import ( @@ -125,189 +132,6 @@ logger = logging.getLogger(__name__) # 둘은 고칠 자리가 전혀 다른데, 이 줄이 없으면 로그만 보고는 못 가린다. logger.info("[boot] 워커 프로세스 시작 pid=%s", os.getpid()) -# ───────────────────────────────────────────────────────────────────────── -# 프론트엔드 빌드 및 서빙 함수 -# ───────────────────────────────────────────────────────────────────────── - - -# 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다. -FRONTEND_DEV_PORT = int(os.getenv("FRONTEND_DEV_PORT", "5173")) -# 띄운 개발 서버 핸들 — 종료 때 이 계통을 통째로 끝낸다. -_frontend_dev_process: subprocess.Popen | None = None - - -def _frontend_process_env(root_dir: Path) -> dict[str, str]: - """config/node_modules를 사용하는 프론트엔드 프로세스 환경을 만든다.""" - env = os.environ.copy() - node_modules = root_dir / "config" / "node_modules" - bin_dir = node_modules / ".bin" - env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" - env["NODE_PATH"] = str(node_modules) - return env - - -def build_frontend() -> bool: - """프론트엔드 빌드 (npm run build from project root)""" - root_dir = Path(__file__).resolve().parent - - logger.info("[Frontend] npm run build 시작...") - try: - result = subprocess.run( - "npm run build", - shell=True, - cwd=str(root_dir), - env=_frontend_process_env(root_dir), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=300, - ) - if result.returncode == 0: - logger.info("✓ 프론트엔드 빌드 완료") - return True - else: - logger.error(f"✗ 프론트엔드 빌드 실패:\n{result.stderr}") - return False - except subprocess.TimeoutExpired: - logger.error("✗ 프론트엔드 빌드 타임아웃 (300초)") - return False - except Exception as e: - logger.error(f"✗ 프론트엔드 빌드 중 예외: {e}") - return False - - -def _kill_process_tree(pid: int) -> None: - """프로세스와 그 자식들을 함께 끝낸다. - - `shell=True`로 띄우면 실제 계통이 cmd.exe → npm → node라 부모만 죽이면 node가 남는다. - 남은 node가 포트를 계속 물고 있으면 다음 실행 때 Vite가 옆 포트로 올라가고, 사용자는 - 옛 화면을 보게 된다(2026-08-01 실제로 13개가 쌓였다). - """ - try: - if os.name == "nt": - subprocess.run( - ["taskkill", "/T", "/F", "/PID", str(pid)], - check=False, - capture_output=True, - ) - else: - os.killpg(os.getpgid(pid), signal.SIGTERM) - except Exception as exc: # 이미 죽었으면 그것으로 됐다 - logger.debug("[Frontend] 프로세스 정리 중 무시된 예외: %s", exc) - - -def _dev_port_listening(port: int) -> bool: - """개발 서버(Vite)가 이미 그 포트를 물고 있나. - - **리로드인지 첫 기동인지를 가르는 하나뿐인 표식**이다. uvicorn 리로더는 코드가 바뀌면 - 앱 프로세스만 다시 띄우고 Vite 자식은 그대로 두므로, 포트가 살아 있으면 리로드다. - 환경변수 같은 별도 깃발을 두지 않는 까닭은 그것이 **실제와 어긋날 수 있기** 때문이다 — - 포트는 어긋나지 않는다. - """ - import socket - - probe = socket.socket() - probe.settimeout(0.5) - try: - probe.connect(("127.0.0.1", port)) - return True - except OSError: - return False - finally: - probe.close() - - -def _free_dev_port(port: int) -> None: - """개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다. - - 앞선 실행이 비정상 종료(강제 종료·전원 차단)했을 때를 대비한 청소다. 이걸 하지 않으면 - Vite가 `--strictPort` 때문에 기동에 실패한다. - """ - if os.name != "nt": - return - try: - # 한글 Windows의 netstat는 콘솔 코드페이지(cp949)로 찍는다 — 기본 utf-8로 읽으면 - # 한글 헤더에서 UnicodeDecodeError가 나고, 그 예외가 읽기 스레드에서 터져 - # 포트 정리가 통째로 죽는다(2026-08-16 실제 발생). 시스템 인코딩으로 읽고, - # 깨지는 바이트는 넘긴다 — 우리가 볼 것은 숫자 포트·PID뿐이다. - result = subprocess.run( - ["netstat", "-ano", "-p", "TCP"], - check=False, - capture_output=True, - text=True, - encoding=locale.getpreferredencoding(False), - errors="replace", - ) - except Exception: - return - needle = f":{port} " - for line in (result.stdout or "").splitlines(): - if needle not in line or "LISTENING" not in line: - continue - pid = line.split()[-1] - if pid.isdigit() and int(pid) != os.getpid(): - logger.info("[Frontend] 포트 %d를 물고 있던 잔여 프로세스 %s 정리", port, pid) - _kill_process_tree(int(pid)) - - -def serve_frontend_dev() -> None: - """프론트엔드 개발 서버 실행 (npm run dev from project root) — 선택 사항. - - 포트를 **고정**(`--strictPort`)한다. 예전에는 포트가 비어 있지 않으면 Vite가 조용히 - 옆 포트로 올라갔고, 그렇게 뜬 옛 인스턴스가 계속 살아남아 하루치 변경이 화면에 반영되지 - 않는 일이 있었다. 고정해 두면 포트가 막혀 있을 때 그 자리에서 드러난다. - """ - global _frontend_dev_process - root_dir = Path(__file__).resolve().parent - - if ENVIRONMENT == "production": - logger.info("[Frontend] 프로덕션 환경: 개발 서버 실행 스킵") - return - - _free_dev_port(FRONTEND_DEV_PORT) - logger.info("[Frontend] npm run dev 시작 (포트 %d 고정, 백그라운드)...", FRONTEND_DEV_PORT) - try: - _frontend_dev_process = subprocess.Popen( - f"npm run dev -- --host --port {FRONTEND_DEV_PORT} --strictPort", - shell=True, - cwd=str(root_dir), - env=_frontend_process_env(root_dir), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - # POSIX에서 자식까지 한 번에 끝내려면 프로세스 그룹을 따로 잡아야 한다. - start_new_session=os.name != "nt", - ) - logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행 (pid %s)", _frontend_dev_process.pid) - except Exception as e: - logger.warning(f"⚠ 프론트엔드 개발 서버 실행 실패: {e}") - - -def stop_frontend_dev() -> None: - """앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다. - - ⚠⚠ **개발 중에는 내리지 않는다** (2026-09-12 실측으로 드러난 고리). - uvicorn 리로더는 `*.py` 가 바뀔 때마다 앱 프로세스를 갈아 끼운다. 그때 여기서 - Vite 를 죽이면 **새 프로세스는 「Vite 가 없다」고 보고 `npm run build` 를 처음부터 - 다시 돌린다**(최대 300초 + B07 CAD). 그 사이 화면은 뜨지 않고 `/api/health` 는 - `stale: true` 인 채로 옛 코드를 계속 내준다 — 그날 리로드가 두 번 멈췄고 한 번은 - 두 시간 반 동안 안 끝났다. **옛 화면을 보고 헛검증하게 만드는 자리다.** - ⇒ Vite 는 **한 앱 프로세스가 아니라 개발 세션의 것**이다. 살려 두고 다음 기동이 - 그대로 물려쓴다(`_dev_port_listening`). 남은 유령은 다음 기동의 - `_free_dev_port` 가 포트로 잡아 정리하므로 쌓이지 않는다. - """ - global _frontend_dev_process - if ENVIRONMENT == "development": - logger.info("[Frontend] 개발 서버는 살려 둔다 — 다음 기동이 그대로 물려쓴다") - return - if _frontend_dev_process is None: - return - logger.info("[Frontend] 개발 서버 종료 (pid %s)", _frontend_dev_process.pid) - _kill_process_tree(_frontend_dev_process.pid) - _frontend_dev_process = None - # 셸을 거쳐 띄우므로 손자 프로세스가 남을 수 있다. 포트로 한 번 더 확인해 정리한다. - _free_dev_port(FRONTEND_DEV_PORT) - async def cleanup_expired_sessions() -> None: """매시간 만료된 세션과 사용 완료 OTP를 정리한다.""" @@ -385,7 +209,7 @@ async def lifespan(app: FastAPI): _CODE_MTIME_AT_START = _newest_source_mtime() logger.info( "[reload] 감시 폴더 %d개, 소스 최신 수정 %s", - len(_reload_dirs()), + len(reload_dirs()), datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"), ) @@ -396,7 +220,7 @@ async def lifespan(app: FastAPI): # 안 끝나, `/api/health` 가 `stale: true` 인 채로 옛 코드를 계속 내주었다. # ⇒ **옛 화면을 보고 헛검증하게 만드는 자리라 그냥 느린 것이 아니다.** # 개발 중에는 Vite 가 HMR 로 이미 최신을 내주므로 다시 빌드할 까닭이 없다. - if ENVIRONMENT == "development" and _dev_port_listening(FRONTEND_DEV_PORT): + if ENVIRONMENT == "development" and dev_port_listening(FRONTEND_DEV_PORT): logger.info( "[Frontend] 개발 서버가 %d 포트에 이미 살아 있음 — 빌드·재기동 건너뜀(리로드)", FRONTEND_DEV_PORT, @@ -571,7 +395,7 @@ async def root(): #: 이 프로세스가 뜬 시각과, 뜰 때 본 **가장 최근 소스 수정 시각**. 기동 때 채운다 -#: (`_reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다). +#: (`reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다). #: 자동 리로드가 조용히 죽는 일이 잦아(오늘만 세 번, 매번 틀린 결론을 냄) 「지금 도는 서버가 #: 옛 코드인가」를 한 번에 가릴 수 있게 남긴다(2026-09-07, PLAN 7-2). _STARTED_AT = 0.0 @@ -581,7 +405,7 @@ _CODE_MTIME_AT_START = 0.0 def _newest_source_mtime() -> float: """감시 대상 폴더의 `.py` 중 가장 최근 수정 시각. 못 읽으면 0.""" newest = 0.0 - for folder in _reload_dirs(): + for folder in reload_dirs(): for path in Path(folder).rglob("*.py"): try: newest = max(newest, path.stat().st_mtime) @@ -696,104 +520,13 @@ app.include_router(help_router, dependencies=protected) # ───────────────────────────────────────────────────────────────────────── # 앱 실행 # ───────────────────────────────────────────────────────────────────────── -def _reload_dirs() -> list[str]: - """자동 리로드가 **훑을 폴더만** 골라 준다 — 폴더째 주면 서버가 쉬는 중에도 CPU를 먹는다. - - uvicorn 의 StatReload 는 0.25초마다 지정 폴더를 통째로 훑는다. 아무것도 안 주면 - 현재 폴더가 대상이 되는데, 여기에는 `storage`(27GB · 프로젝트 산출물)와 `tmp`·`venv`· - `node_modules` 가 함께 들어 있다. 그래서 **요청이 하나도 없는 서버가 CPU 70%** 를 물고 - 있었고, 창 둘이 각각 그러니 기기가 늘 140% 눌린 채였다(2026-09-07 실측). 속도 계측이 - 회차마다 3배씩 흔들린 원인도 이것이다. - - 파이썬이 실제로 사는 폴더만 준다 — 화면 코드(TS)는 vite 가 따로 본다. - """ - root = Path(__file__).resolve().parent - # ⚠ 루트와 `config` 는 넣지 않는다 — uvicorn 은 준 폴더를 `rglob("*.py")` 로 **재귀**로 - # 훑는다. 루트를 주면 storage·venv(파이썬 파일만 7,530개)를 도로 다 훑고, `config` 안에는 - # `node_modules` 가 들어 있어 역시 무겁다. 그 대신 **`main.py` 와 `config/` 를 고칠 때는 - # 서버를 손으로 다시 띄울 것** (둘 다 자주 고치는 자리가 아니다). - watched: list[str] = [] - for entry in sorted(root.iterdir()): - if not entry.is_dir() or entry.name.startswith(".") or entry.name in _RELOAD_SKIP: - continue - # 링크(정션)는 통째로 건너뛴다. `node_modules` 안에 **저장소 루트를 되가리키는 정션**이 - # 있어(`B07_DesignDetail/openwebcad/node_modules/forest-road-webapp` → 저장소 루트, - # npm 이 만든 것) 리로더가 그리로 되돌아가 `venv`·`storage` 를 다시 훑고 또 되감겼다. - # 실측(2026-09-07) — 감시 폴더를 훑으면 파일 **486,809개**, 한 바퀴 **1,045초**. - # 파이썬 `rglob` 은 정션을 따라 들어가고 PowerShell 은 안 따라가 눈으로는 안 보였다. - if entry.is_symlink(): - continue - # 의존성 트리가 **한 겹 아래에 숨어 있는** 경우도 뺀다 — 위 정션이 그 안에 있었다. - if (entry / "node_modules").is_dir() or any(entry.glob("*/node_modules")): - continue - if any(entry.rglob("*.py")): - watched.append(str(entry)) - return watched - - -# 파이썬이 있어도 훑지 않을 폴더 — 산출물·의존성·백업. -_RELOAD_SKIP = { - "storage", - "tmp", - "venv", - "node_modules", - "__pycache__", - "log", - "docs", - "resources", - "scratch", - "0_old", - "db_management", - "migrations", - "ui_template", -} - - -def _patch_windows_reload() -> None: - """윈도우에서 uvicorn 리로더가 **영원히 멈추는 것**을 고친다 (2026-09-12 실측). - - 무엇이 잘못됐나 - uvicorn 0.24 의 `BaseReload.restart()` 는 윈도우에서만 옛 워커를 - `os.kill(worker_pid, CTRL_C_EVENT)` 로 내리려 한다. 그런데 CTRL_C_EVENT 는 - **프로세스 그룹을 지정해 보낼 수 없다** — MSDN 이 「그룹 id 가 0 이 아니면 호출은 - 성공하지만 신호는 전달되지 않는다」고 못박아 두었다. 워커는 리로더의 평범한 - 자식이라 그룹 우두머리가 아니므로 그 신호는 **아무 데도 가지 않는다.** - 바로 다음 줄 `join()` 이 영원히 안 풀려서 — - · 옛 워커는 살아서 **옛 코드로 요청을 계속 처리**하고 - · 새 워커는 태어나지 않으며 - · 그 뒤의 파일 변경은 **감지조차 안 된다**(리로더가 그 자리에 갇혀 있다) - 「고쳤는데 화면이 안 바뀐다」로 보이는 자리라 그냥 느린 것이 아니다. - - 실측 (그 자리를 그대로 흉내 낸 부모·자식 한 쌍) - CTRL_C_EVENT → **8초 안에 안 풀림** (DETACHED · CREATE_NO_WINDOW · - CREATE_NO_WINDOW+NEWGROUP 셋 모두) · `terminate()` → **0.00초** · - CTRL_BREAK_EVENT(자식을 그룹 우두머리로) → **0.00초** - ⇒ uvicorn 이 **리눅스에서 이미 쓰는** `terminate()` 가 윈도우에서도 즉시 먹는다. - 그래서 여기서는 그 리눅스 경로를 그대로 쓴다(새 방식을 지어내지 않는다). - - ⚠ 개발 기동(`DEBUG`)에서만 갈아 끼운다. uvicorn 이 위쪽에서 고치면 **이 함수를 지우면 - 된다** — 다른 자리는 아무것도 안 건드린다. - """ - from uvicorn._subprocess import get_subprocess - from uvicorn.supervisors.basereload import BaseReload - - def restart(self: BaseReload) -> None: - self.process.terminate() - self.process.join() - self.process = get_subprocess(config=self.config, target=self.target, sockets=self.sockets) - self.process.start() - - BaseReload.restart = restart - logger.info("[reload] 윈도우 리로더를 terminate() 방식으로 갈아 끼움") - - if __name__ == "__main__": import sys import uvicorn if DEBUG and sys.platform == "win32": - _patch_windows_reload() + patch_windows_reload() logger.info(f"Starting server on {SERVER_HOST}:{SERVER_PORT}") uvicorn.run( @@ -801,7 +534,8 @@ if __name__ == "__main__": host=SERVER_HOST, port=SERVER_PORT, reload=DEBUG, - # 폴더를 좁히지 않으면 리로더가 storage(27GB)까지 0.25초마다 훑는다 — 위 주석 참조. - reload_dirs=_reload_dirs() if DEBUG else None, + # 폴더를 좁히지 않으면 storage(27GB)까지 훑는다 — config_dev_server 참조. + reload_dirs=reload_dirs() if DEBUG else None, + reload_excludes=reload_excludes() if DEBUG else None, access_log=False, ) diff --git a/resources/tester/cad/undo-506-3.test.ts b/resources/tester/cad/undo-506-3.test.ts new file mode 100644 index 000000000..a77316b31 --- /dev/null +++ b/resources/tester/cad/undo-506-3.test.ts @@ -0,0 +1,77 @@ +/** + * 506-3 다 — CAD 되돌리기 두 흠. + * 6-f 특성 판 색 끌기 = 끄는 동안 화면만 · 멎은 뒤 되돌리기 한 칸(리본 색 칸과 같은 `paintNowCommitLater`). + * 6-g LAYMRG = 사본으로 갈아 끼움 → Ctrl+Z 한 번에 옛 층. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config vitest.tmp.config.ts undo-506-3 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("react-toastify", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }), +})); + +import { + paintNowCommitLater, + styleTargets, +} from "../../../B07_DesignDetail/openwebcad/src/helpers/selection-style"; +import { + getSelectedEntities, + resetUndoBaseline, + setActiveLayerId, + setLayers, + setSelectedEntityIds, + undo, +} from "../../../B07_DesignDetail/openwebcad/src/state"; +import { click, entities, initIdle, key, type } from "./helpers/cad-drive"; + +const layer = (id: string) => ({ id, name: id, isVisible: true, isLocked: false }); +const line = (a: string, b: string) => { + type("l"); + type(a); + type(b); + key("Escape"); +}; + +beforeEach(() => { + initIdle(); + setLayers([layer("A"), layer("B")]); + setActiveLayerId("A"); +}); +afterEach(() => vi.useRealTimers()); + +describe("6-f 특성 판 색 끌기", () => { + it("끄는 동안 다섯 번 = 되돌리기 한 칸 · Ctrl+Z 한 번에 옛 색", () => { + line("0,0", "20,0"); + const before = entities()[0].lineColor; + resetUndoBaseline(); + setSelectedEntityIds([entities()[0].id]); + vi.useFakeTimers(); + for (const color of ["#110000", "#220000", "#330000", "#440000", "#550000"]) { + paintNowCommitLater(styleTargets(getSelectedEntities()), (entity) => { + entity.lineColor = color; + }); + vi.advanceTimersByTime(50); // 끄는 중(멎기 전) + } + expect(entities()[0].lineColor).toBe("#550000"); + vi.advanceTimersByTime(400); // 멎음 → 한 칸 + undo(); + expect(entities()[0].lineColor).toBe(before); + }); +}); + +describe("6-g LAYMRG", () => { + it("병합 뒤 Ctrl+Z 한 번 = 옛 층", () => { + line("0,0", "20,0"); + setActiveLayerId("B"); + line("0,50", "20,50"); + resetUndoBaseline(); + const [onA, onB] = entities(); + expect([onA.layerId, onB.layerId]).toEqual(["A", "B"]); + type("laymrg"); + click(10, 0); + click(10, 50); + expect(entities().map((entity) => entity.layerId)).toEqual(["B", "B"]); + undo(); + expect(entities().map((entity) => entity.layerId)).toEqual(["A", "B"]); + }); +}); diff --git a/resources/tester/helper_506_3_b07_save.cjs b/resources/tester/helper_506_3_b07_save.cjs new file mode 100644 index 000000000..cd9db841b --- /dev/null +++ b/resources/tester/helper_506_3_b07_save.cjs @@ -0,0 +1,106 @@ +/* 506-3 B07 저장 길 헬퍼 — `_UI_Save` 를 Node 로 돌려 끊긴(abort) 저장 판정 · 저장 중 표 · 자동백업 열쇠 · + * [초기화] 물음 글을 표준출력 JSON 한 벌로. 서버 길 · 알림 · 물음은 기록용 가짜. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B07 = path.join(__dirname, "..", "..", "B07_DesignDetail"); +const calls = []; +let confirmError = null; +let listConfirmed = false; +let savingSeen = null; +let asked = ""; +let Save; + +const STUBS = { + "@ui/ui_template_elements": { + showToast: (message, kind) => calls.push(["toast", kind]), + showLoadingOverlay: () => calls.push(["overlay+"]), + hideLoadingOverlay: () => calls.push(["overlay-"]), + showConfirmDialog: async (message) => ((asked = message), calls.push(["ask"]), true), + }, + "./B07_DesignDetail_Api_Fetch": { + confirmDesignDrawing: async (projectId, id) => { + calls.push(["confirm", id]); + savingSeen = Save.isSaving(); + if (confirmError) throw confirmError; + }, + fetchDesignDrawingList: async () => { + calls.push(["list"]); + return { drawings: [{ id: "cross_s1", confirmed: listConfirmed }] }; + }, + invalidateDesignDrawing: async () => undefined, + resetDesignDrawings: async () => ({ status: "success" }), + }, +}; + +global.localStorage = { removeItem: (key) => calls.push(["drop", key]) }; +global.location = { reload: () => calls.push(["reload"]) }; +global.document = { querySelectorAll: () => [] }; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (r) => STUBS[r] ?? {}); + return box.exports; +} + +Save = loadTs(path.join(B07, "B07_DesignDetail_UI_Save.ts")); + +const storeOf = () => { + let dirty = true; + const tab = { id: "cross_s1", dirty }; + return { + projectId: "p1", + drawings: [{ id: "cross_s1", kind: "cross", label: "횡단 1", confirmed: false }], + tabs: { + tabs: () => [tab], + active: () => tab, + markClean: (t) => ((t ?? tab).dirty = false), + anyDirty: () => tab.dirty, + }, + current: () => ({ id: "cross_s1", kind: "cross", label: "횡단 1", confirmed: false }), + cadDirty: () => dirty, + setCadClean: () => (dirty = false), + requestCad: async () => ({ drawing: {}, quantityTable: null }), + isFrameEditing: () => false, + forget: () => undefined, + reload: async () => undefined, + }; +}; +const take = () => calls.splice(0).map((c) => c.join(":")); +const abort = () => new DOMException("The operation was aborted.", "AbortError"); + +(async () => { + const out = {}; + out.scope = Save.recoveryScopeOf("p1", "cross_s1"); + + await Save.saveDrawingsNow(storeOf(), () => calls.push(["saved"])); + out.normal = { calls: take(), savingSeen, savingAfter: Save.isSaving() }; + + confirmError = abort(); + listConfirmed = true; + await Save.saveDrawingsNow(storeOf(), () => calls.push(["saved"])); + out.abortSaved = { calls: take(), savingAfter: Save.isSaving() }; + + listConfirmed = false; + await Save.saveDrawingsNow(storeOf(), () => calls.push(["saved"])); + out.abortUnsaved = { calls: take(), savingAfter: Save.isSaving() }; + + confirmError = new Error("서버 오류"); + listConfirmed = true; + await Save.saveDrawingsNow(storeOf(), () => calls.push(["saved"])); + out.otherError = take(); + confirmError = null; + + await Save.resetDrawings(storeOf()); + out.reset = { calls: take(), asked }; + + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/helper_506_3_b09_print.cjs b/resources/tester/helper_506_3_b09_print.cjs new file mode 100644 index 000000000..8fdb50f22 --- /dev/null +++ b/resources/tester/helper_506_3_b09_print.cjs @@ -0,0 +1,33 @@ +/* 506-3 4-1 헬퍼 — B09 초안 [인쇄 미리보기] 단추를 Node 로 눌러 저장(DRAFT.save) 뒤 이동인지 표준출력 JSON 으로. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const calls = []; +const DRAFT = { builders: {}, extend: [], panels: [], actions: [] }; +const STUBS = { + "@ui/ui_template_elements": { createButton: (opts) => ({ dataset: {}, onClick: opts.onClick }) }, + "../../A00_Common/router": { navigateTo: (to) => calls.push(`go:${to}`) }, + "../B09_Estimation_UI_Tabs": { DRAFT }, + "./B09_Draft_Print_Route": { ROUTE: { path: "/b09-print" } }, +}; +const file = path.join(__dirname, "..", "..", "B09_Estimation", "draft", "B09_Draft_Print_UI.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +new Function("exports", "module", "require", js)({}, { exports: {} }, (r) => STUBS[r] ?? {}); + +const tick = () => new Promise((done) => setTimeout(done, 0)); +(async () => { + const button = DRAFT.actions[0](); + const out = {}; + DRAFT.save = async () => (calls.push("save"), true); + button.onClick(); + await tick(); + out.saved = calls.splice(0); + DRAFT.save = async () => (calls.push("save"), false); + button.onClick(); + await tick(); + out.failed = calls.splice(0); + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_298_2_b07_buttons.py b/resources/tester/test_298_2_b07_buttons.py index 2f42c8c64..5e5773ef8 100644 --- a/resources/tester/test_298_2_b07_buttons.py +++ b/resources/tester/test_298_2_b07_buttons.py @@ -83,8 +83,8 @@ def test_초기화(out): "ask", "overlay+", "reset:p1", - "drop:OPEN_WEB_CAD__RECOVERY__cover", - "drop:OPEN_WEB_CAD__RECOVERY__cross_s1", + "drop:OPEN_WEB_CAD__RECOVERY__b07:p1:cover", + "drop:OPEN_WEB_CAD__RECOVERY__b07:p1:cross_s1", "toast:success", "reload", "overlay-", diff --git a/resources/tester/test_505_3_cross_card_screen.py b/resources/tester/test_505_3_cross_card_screen.py new file mode 100644 index 000000000..61dd405b5 --- /dev/null +++ b/resources/tester/test_505_3_cross_card_screen.py @@ -0,0 +1,217 @@ +"""PLAN 505-3 화면 — 자기 포트(5179) Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4. + +- 종단 측점선 · 원지반선 겹친 자리 누름 = 측점선이 받음(지반선 누름 통과) → 임시 횡단 창 = B06 카드 + 「⚠ 배관 관경 빈칸」 칩 +- 관경 800(세션만 · [저장] 안 함) → 창 카드에 관 형상 · Ctrl+휠 확대가 창 칸 밖으로 안 넘침 · 밝음 · 어두움 한 장씩 +- 곡선 판 = 도구줄 아래 · 열린 횡단 판 왼쪽(판을 나중에 열어도 비킴) +- 계정 화면 취향(종단 · 횡단 판 열림 · 높이)만 시험 앞 값으로 되돌림 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_505_3_cross_card_screen.py -q +""" + +from __future__ import annotations + +import json +import urllib.request +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BASE = "http://localhost:5179" +PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md) +SHOTS = ROOT / "tmp" / "shots_505_3" +PIPE_M = 388 # 검증_sub4 배관 하나(관경 빈칸 · 19+8) +PREFS = "/api/dashboard/me/ui-prefs" +# 이 시험이 건드리는 취향만 되돌림 — 계정을 다른 창도 같이 써서 통째로 덮으면 남의 값이 사라짐 +TOUCHED = ("profile-collapsed", "profile-height", "b05-routeedit-cross-v2") + + +def _up() -> bool: + try: + urllib.request.urlopen(BASE, timeout=3) + return True + except Exception: + return False + + +pytestmark = pytest.mark.skipif(not _up(), reason="5179 서버 없음") + +# 측점 m 을 가로 가운데로 · 그 측점선과 원지반선 교점(화면 좌표) · 그 자리 맨 위 요소 +JS_SCROLL = r"""(m) => { + const marker = [...document.querySelectorAll('.b06-chart__station')] + .find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6); + const body = marker.closest('.b05-route-profile__body'); + body.scrollLeft = Math.max(0, +marker.querySelector('line').getAttribute('x1') - body.clientWidth / 2); +}""" +JS_POINT = r"""(m) => { + const marker = [...document.querySelectorAll('.b06-chart__station')] + .find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6); + const line = marker.querySelector('.b06-chart__station-line'); + const svg = line.ownerSVGElement; + const x = +line.getAttribute('x1'); + const poly = svg.querySelector('.b06-chart__profile'); + const pts = poly.getAttribute('points').trim().split(/\s+/).map((s) => s.split(',').map(Number)); + const k = Math.max(1, pts.findIndex((p) => p[0] >= x)); + const [a, b] = [pts[k - 1], pts[k]]; + const pt = svg.createSVGPoint(); + pt.x = x; + pt.y = a[1] + ((b[1] - a[1]) * (x - a[0])) / (b[0] - a[0] || 1); + const s = pt.matrixTransform(poly.getScreenCTM()); + return { x: s.x, y: s.y, hit: document.elementFromPoint(s.x, s.y)?.getAttribute('class') }; +}""" +JS_MODAL = r"""() => { + const r = document.querySelector('.b05-crossmodal'); + const card = r.querySelector('.b06-cross-card'); + const box = r.querySelector('.b05-crossmodal__box').getBoundingClientRect(); + const svg = card?.querySelector('svg').getBoundingClientRect(); + return { + station: r.querySelector('.b05-crossmodal__station').textContent, + card: !!card, + chips: [...r.querySelectorAll('.b05-cross-chips > *')].map((e) => e.textContent), + pipes: card ? card.querySelectorAll('.b06-chart__culvert-pipe').length : 0, + inside: !!svg && svg.top >= box.top && svg.bottom <= box.bottom + 1 && svg.right <= box.right + 1, + width: Math.round(r.getBoundingClientRect().width), + buttons: [...r.querySelectorAll('.b05-crossmodal__actions button')].map((e) => e.className), + scale: [...(card?.querySelectorAll('svg g[transform]') ?? [])] + .map((g) => g.getAttribute('transform')).find((t) => /^translate\([^)]*\) scale\(/.test(t)) ?? '', + }; +}""" +JS_RECTS = r"""() => { + const q = (s) => [...document.querySelectorAll(s)].find((x) => x.getClientRects().length); + const r = (s) => { const o = q(s)?.getBoundingClientRect(); return o ? [o.left, o.top, o.right, o.bottom] : null; }; + return { label: r('.b05-routeedit__label'), tools: r('.b05-routeedit__tools'), side: r('.b05-routeedit__side-wrap') }; +}""" + + +@pytest.fixture(scope="module") +def page(): + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + ctx = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + assert ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok + prefs = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {}) + tab = ctx.new_page() + tab.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')") + tab.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + tab.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + tab.wait_for_timeout(8000) + SHOTS.mkdir(parents=True, exist_ok=True) + try: + yield tab + finally: + tab.wait_for_timeout(2000) # 취향 올리기(1.5 s 모음) 끝난 뒤 되돌림 + now = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {}) + for key in (f"aislo:pref:{name}" for name in TOUCHED): + if key in prefs: + now[key] = prefs[key] + else: + now.pop(key, None) + assert ctx.request.put(f"{BASE}{PREFS}", data={"prefs": now}).ok + chrome.close() + + +def _profile(page, open_: bool) -> None: + height = page.evaluate( + "() => document.querySelector('.b05-route-profile')?.getBoundingClientRect().height ?? 0" + ) + if (height > 100) != open_: # 접힘 = 높이 1 px(측점 요소는 DOM 에 남음) + page.get_by_text("종단", exact=True).last.click() + page.wait_for_timeout(3000) + + +def _open_modal(page) -> dict: + _profile(page, True) + page.evaluate(JS_SCROLL, PIPE_M) + page.wait_for_timeout(800) + at = page.evaluate(JS_POINT, PIPE_M) + assert "b06-chart__station-line" in (at["hit"] or ""), at # 지반선이 아니라 측점선이 받음 + page.mouse.move(at["x"], at["y"]) + page.wait_for_timeout(300) + page.mouse.click(at["x"], at["y"]) + page.wait_for_function( + "() => document.querySelector('.b05-crossmodal .b06-cross-card')", timeout=30000 + ) + page.wait_for_timeout(1500) + return page.evaluate(JS_MODAL) + + +def test_0_곡선_판_도구줄_횡단판_비킴(page): + _profile(page, False) + rects = page.evaluate(JS_RECTS) + if rects["side"][2] - rects["side"][0] > 100: # 횡단 판 접고 시작 + page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click() + page.wait_for_timeout(2000) + shown = "() => [...document.querySelectorAll('.b05-routeedit__label')].some((x) => !x.hidden)" + for _ in range(3): # 첫 누름이 앞 고름(구조물) 풂으로 갈 수 있음 + page.mouse.click(1199, 296) # 검증_sub4 첫 화면의 14번째 꺾임점 + page.wait_for_timeout(1500) + if page.evaluate(shown): + break + page.screenshot(path=str(SHOTS / "curve_pick.png")) + assert page.evaluate(shown) + # 지도를 위 · 오른쪽으로 밀어 꺾임점을 도구줄 · 오른쪽 끝 밑으로 + page.mouse.move(800, 650) + page.mouse.down(button="middle") + page.mouse.move(1050, 380, steps=16) + page.mouse.up(button="middle") + page.wait_for_timeout(1200) + rects = page.evaluate(JS_RECTS) + assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5 + page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click() + page.wait_for_timeout(2500) + rects = page.evaluate(JS_RECTS) + assert rects["label"][2] <= rects["side"][0] - 8 + 0.5 # 판을 나중에 열어도 비킴 + assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5 + page.screenshot(path=str(SHOTS / "curve_light.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + page.screenshot(path=str(SHOTS / "curve_dark.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + page.keyboard.press("Escape") # 꺾임점 고름 풂 + + +def test_1_빈칸_칩_카드(page): + modal = _open_modal(page) + assert modal["station"] == "19+8" and modal["card"] + assert modal["chips"] == ["⚠ 배관 관경 빈칸"] + assert modal["pipes"] == 0 and modal["width"] == 380 + assert all("ui-btn ui-btn--ghost ui-btn--sm" in name for name in modal["buttons"]) + page.locator(".b05-crossmodal").get_by_text("닫기").click() + + +def test_2_횡단_판_칩_관경_넣으면_형상_확대_밝음_어두움(page): + _profile(page, False) + page.locator(".b05-route__irregular-item", has_text="19+8").first.click() + page.wait_for_timeout(3000) + chips = page.eval_on_selector_all( + ".b05-routeedit__cross .b05-cross-chips > *", "(els) => els.map((e) => e.textContent)" + ) + assert "⚠ 배관 관경 빈칸" in chips # 「횡단」 판 = 카드 머리를 걷어도 칩은 그래프 오른쪽 위 + page.locator(".b05-routeedit__cross").first.screenshot(path=str(SHOTS / "panel_chip.png")) + if not page.locator("select:visible").filter(has=page.locator("option[value='800']")).count(): + page.get_by_text("구조물 배치", exact=True).click() + page.wait_for_timeout(1500) + page.locator("select:visible").filter( + has=page.locator("option[value='800']") + ).first.select_option("800") # 세션 초안만 — [저장] 안 누름 + page.wait_for_timeout(5000) + modal = _open_modal(page) + assert modal["chips"] == [] and modal["pipes"] > 0 + shot = page.locator(".b05-crossmodal") + shot.screenshot(path=str(SHOTS / "modal_light.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + shot.screenshot(path=str(SHOTS / "modal_dark.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + box = page.locator(".b05-crossmodal__box").bounding_box() + cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 + page.evaluate( + "([x, y]) => document.elementFromPoint(x, y).dispatchEvent(new WheelEvent('wheel'," + " {deltaY: 300, ctrlKey: true, clientX: x, clientY: y, bubbles: true, cancelable: true}))", + [cx, cy], + ) + page.wait_for_timeout(800) + zoomed = page.evaluate(JS_MODAL) + assert "scale(1.15)" in zoomed["scale"] and zoomed["inside"] + page.locator(".b05-crossmodal").get_by_text("닫기").click() diff --git a/resources/tester/test_505_3_cross_card_text.py b/resources/tester/test_505_3_cross_card_text.py new file mode 100644 index 000000000..05ecc02e1 --- /dev/null +++ b/resources/tester/test_505_3_cross_card_text.py @@ -0,0 +1,84 @@ +"""505-3 B05 그림 · 글 — 임시 횡단 창 = B06 카드(형상 얹은 단면) · 빈칸 칩 그래프 오른쪽 위 · 곡선 판 가둠 · +지반선 · 계획선 누름 통과 · STALE 글 · 창 단추 공용 꼴. + +./venv/Scripts/python.exe -m pytest resources/tester/test_505_3_cross_card_text.py -q +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +def test_측점_뜨기는_형상_얹은_단면도_돌려줌() -> None: + probe = _read("A00_Common", "design", "design_pipeline_probe.ts") + assert "Promise<{ preview: CrossPreviewResponse; section: CrossSection } | null>" in probe + assert "const section = stationSection(station, cross, {}, intervalM);" in probe + assert "attachStatus([section], status);" in probe + detail = _read("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts") + assert "export function attachStatus(sections: CrossSection[], status: ShapeStatus)" in detail + + +def test_임시_횡단_창은_B06_카드() -> None: + modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts") + assert "crossSourceOf(" in modal and "routeCrossCard(source," in modal + assert "now ? [now.preview] : await previewStructureSections(projectId(), [m])" in modal + # 서버 미리 뜬 것(분석 지형 다름)만 선 그림 + assert "buildCrossSvg(preview, width, height)" in modal + assert 'class="ui-button"' not in modal + assert modal.count('class="ui-btn ui-btn--ghost ui-btn--sm" data-act=') == 3 + css = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.css") + assert ".b05-crossmodal__box > .b06-cross-card {" in css + + +def test_판_재료_한_벌() -> None: + card = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts") + assert ( + "return crossSourceOf(projectId, found, sections, detail.longitudinal.design_profiles, pane);" + in card + ) + assert card.count("export function crossSourceOf(") == 1 + + +def test_빈칸_칩은_머리_걷기_전에_꺼냄() -> None: + card = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts") + chips = card.index('":scope > header .b06-cross-card__warning"') + remove = card.index(':scope > header, :scope > footer, .b06-cross-card__zoom"') + assert chips < remove + assert 'box.className = "b05-cross-chips";' in card + css = _read("B05_Profile", "B05_Profile_UI_Style_RouteEdit.css") + rule = css[css.index(".b05-cross-chips {") :] + assert "top: 4px;" in rule[:200] and "right: 4px;" in rule[:200] + assert ".b06-cross-card:has(> .b05-cross-chips) {\n position: relative;" in css + + +def test_곡선_판_가둠_도구줄_횡단판_종단판() -> None: + bar = _read("B05_Profile", "B05_Profile_UI_RouteEdit_CurveBar.ts") + for selector in ( + ".b05-routeedit__tools", + ".b05-routeedit__side-wrap", + ".b05-route-profile", + ): + assert f'box("{selector}")' in bar + assert "top: Math.max(rect.top, tools?.bottom ?? rect.top) + 8," in bar + assert "right: Math.min(rect.right, side?.left ?? rect.right) - 8," in bar + assert "bottom: Math.min(rect.bottom, profile?.top ?? rect.bottom) - 8," in bar + + +def test_지반선_계획선_누름_통과() -> None: + css = _read("B06_Section", "B06_Section_UI_Style_Cross.css") + assert ".b06-chart__profile,\n.b06-chart__design-profile {\n pointer-events: none;" in css + + +def test_STALE_글_앞_단계() -> None: + locale = _read("ui_template", "ui_template_locale_common.ts") + assert ( + 'WF_State_Stale: ["다시 확인 필요 (앞 단계 변경)", "Stale (an earlier step changed)"],' + in locale + ) + assert "하위 단계" not in locale diff --git a/resources/tester/test_506_1_reload_scope.py b/resources/tester/test_506_1_reload_scope.py new file mode 100644 index 000000000..7e6ee5c70 --- /dev/null +++ b/resources/tester/test_506_1_reload_scope.py @@ -0,0 +1,42 @@ +"""506-1 — 개발 서버 다시 읽기 범위: 시험·tmp .py 는 제외, 모듈 폴더 .py 는 읽음.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from uvicorn.config import Config # noqa: E402 +from uvicorn.supervisors.watchfilesreload import FileFilter # noqa: E402 + +from config.config_dev_server import RELOAD_SKIP, reload_dirs, reload_excludes # noqa: E402 + + +def _filter() -> FileFilter: + cfg = Config( + "main:app", reload=True, reload_dirs=reload_dirs(), reload_excludes=reload_excludes() + ) + return FileFilter(cfg) + + +def test_제외_목록은_절대_경로_실존_폴더(): + ex = reload_excludes() + assert ex and all(Path(e).is_absolute() and Path(e).is_dir() for e in ex) + names = {Path(e).name for e in ex} + assert {"resources", "tmp", "venv", "docs", ".claude"} <= names + assert names - {n for n in names if n.startswith(".")} <= RELOAD_SKIP + + +def test_감시_폴더는_제외_폴더와_안_겹치고_모듈_폴더_15개(): + dirs = reload_dirs() + assert len(dirs) == 15 + skip = {Path(e) for e in reload_excludes()} + assert not skip & {Path(d) for d in dirs} + + +def test_필터_시험_tmp는_안_읽고_모듈_main_config는_읽음(): + f = _filter() + for rel in ("resources/tester/x.py", "tmp/x.py", "docs/x.py", ".claude/x.py", "venv/Lib/x.py"): + assert not f(ROOT / rel), rel + for rel in ("B05_Profile/x.py", "B07_DesignDetail/x.py", "main.py", "config/config_db.py"): + assert f(ROOT / rel), rel diff --git a/resources/tester/test_506_3_b07_browser.py b/resources/tester/test_506_3_b07_browser.py new file mode 100644 index 000000000..667c53788 --- /dev/null +++ b/resources/tester/test_506_3_b07_browser.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +"""506-3 화면 — 자기 포트(5177) Playwright 머리 없는 브라우저 · 검증_sub2 · `AISLO_LIVE_DB=1` 일 때만. + +- B07 표지에 선 하나 → CAD 자동백업 열쇠 = `OPEN_WEB_CAD__RECOVERY__b07:{프로젝트}:cover` +- 특성 판 색 칸 다섯 번 끌기 → 되돌리기 한 번 = 처음 색 +- 미리받기가 도는 중 [저장] → 「저장했습니다」 · 머묾 글 없음 · 저장 동안 새 미리받기 GET 없음 +- [초기화] 물음 글 = 프로젝트 도각 안내(밝음 · 어두움 한 장씩 · tmp/shots_506_3) → [취소] +- B09 [인쇄 미리보기] = PUT /estimation 뒤 #/b09-print · complete 안 부름 +- 단계 행 · B07 · B09 storage 를 먼저 떠 두고 끝에 되돌림 + + AISLO_LIVE_DB=1 ./venv/Scripts/python.exe -m pytest resources/tester/test_506_3_b07_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)) +BASE = "http://localhost:5177" +PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) +SHOTS = ROOT / "tmp" / "shots_506_3" +COLS = ( + "state", + "progress_percent", + "params", + "message", + "started_at", + "completed_at", + "updated_at", +) + + +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 · 단계 행을 씀)", +) + + +async def _keep(mode: str, held: dict) -> None: + """단계 행 · projects 상태 · B07 · B09 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,)) + root = Path(resolve_stored_project_path((await cur.fetchone())["storage_path"])) + await cur.execute( + "SELECT * FROM project_workflow_stages WHERE project_id=%s", (PROJECT,) + ) + held["stages"] = await cur.fetchall() + await cur.execute("SELECT status, updated_at FROM projects WHERE id=%s", (PROJECT,)) + held["project"] = await cur.fetchone() + held["copy"] = ROOT / "tmp" / f"pt_506_3_keep_{int(time.time())}" + held["dirs"] = [ + d for d in root.iterdir() if d.is_dir() and d.name.startswith(("B07", "B09")) + ] + for d in held["dirs"]: + shutil.copytree(d, held["copy"] / d.name) + return + for row in held["stages"]: + await cur.execute( + f"UPDATE project_workflow_stages SET {', '.join(f'{k}=%s' for k in COLS)} " + "WHERE id=%s", + (*[row[k] for k in COLS], row["id"]), + ) + project = held["project"] + await cur.execute( + "UPDATE projects SET status=%s, updated_at=%s WHERE id=%s", + (project["status"], project["updated_at"], PROJECT), + ) + await conn.commit() + for d in held["dirs"]: + shutil.rmtree(d) + shutil.copytree(held["copy"] / d.name, d) + 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}')") + yield context + chrome.close() + finally: + asyncio.run(_keep("restore", held)) + + +def _cad(page): + return next(f for f in page.frames if "/b07-cad/" in f.url) + + +def _type(page, *lines: str) -> None: + for line in lines: + page.keyboard.type(line) + page.keyboard.press("Enter") + + +def test_b07_저장_길_화면(ctx): + page = ctx.new_page() + gets: list[tuple[float, str]] = [] + puts: list[tuple[float, float]] = [] + page.on( + "request", + lambda q: ( + q.method == "GET" + and "/design-drawings/" in q.url + and gets.append((time.time(), q.url.rsplit("/", 1)[-1])) + ), + ) + starts: dict = {} + page.on( + "request", + lambda q: q.method == "PUT" and "/confirm" in q.url and starts.update(t=time.time()), + ) + page.on( + "requestfinished", + lambda q: ( + q.method == "PUT" and "/confirm" in q.url and puts.append((starts["t"], time.time())) + ), + ) + page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded") + page.wait_for_selector( + '.b07-drawing-button[data-drawing-id="cover"][data-active="true"]', timeout=90000 + ) + page.wait_for_selector(".cad-host[data-loading='false']", timeout=90000) + cad = _cad(page) + + # 선 하나 — 표지 미저장 · 자동백업 + cad.locator("canvas").first.click(position={"x": 900, "y": 600}) + page.keyboard.press("Escape") + _type(page, "l", "100,100", "400,100") + page.keyboard.press("Escape") + page.keyboard.press("Control+a") + cad.wait_for_timeout(300) + + # 특성 판 색 다섯 번 끌기 → 되돌리기 한 번 + if cad.locator(".cad-inspector[data-collapsed='true']").count(): + cad.locator(".cad-inspector__collapse").click() + cad.locator(".cad-inspector-tabs button", has_text="특성").click() + color = cad.locator(".cad-properties-editor input[type=color]") + color.wait_for(timeout=10000) + first = color.input_value() + for value in ("#110000", "#220000", "#330000", "#440000", "#550000"): + color.evaluate( + "(el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')" + ".set.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); }", + value, + ) + cad.wait_for_timeout(60) + painted = color.input_value() + cad.wait_for_timeout(600) # 멎음 → 한 칸 + cad.locator("canvas").first.click(position={"x": 600, "y": 60}) # 초점 = 캔버스(고름은 다시) + page.keyboard.press("Control+z") + page.keyboard.press("Control+a") + cad.wait_for_timeout(300) + undone = color.input_value() + print(f"색 처음 {first} · 끈 뒤 {painted} · 되돌리기 한 번 {undone}") + assert painted == "#550000" and undone == first + + # 자동백업 열쇠(적재 뒤 5 s 쉼 + 5 s 미룸) + cad.wait_for_timeout(11000) + keys = cad.evaluate( + "Object.keys(localStorage).filter(k => k.startsWith('OPEN_WEB_CAD__RECOVERY'))" + ) + print("자동백업 열쇠", keys) + assert f"OPEN_WEB_CAD__RECOVERY__b07:{PROJECT}:cover" in keys + assert "OPEN_WEB_CAD__RECOVERY__cover" not in keys + + # 미리받기 도는 중 [저장] + before = len(gets) + page.get_by_role("button", name="저장", exact=True).click() + toast = page.wait_for_selector( + ".ui-toast:has-text('저장했습니다'), .ui-toast:has-text('머묾')", timeout=90000 + ).inner_text() + page.wait_for_function( + "() => document.querySelector('.b07-drawing-button[data-drawing-id=cover]')?.dataset.confirmed === 'true'", + timeout=90000, + ) + put_start, put_end = puts[-1] + during = [name for t, name in gets if put_start < t < put_end] + print( + f"[저장] 앞 미리받기 GET {before} · PUT {put_end - put_start:.2f} s · 알림 「{toast}」 · 저장 중 새 GET {during}" + ) + assert before > 1 # 미리받기 도는 중 + assert "머묾" not in toast and "저장했습니다" in toast + assert during == [] + + # [초기화] 물음 글 → [취소] + page.get_by_role("button", name="초기화", exact=True).click() + message = page.wait_for_selector(".ui-confirm__message", timeout=10000) + text = message.inner_text() + print("초기화 물음", text) + assert "프로젝트 도각은 그대로" in text and "[기본 도각 복원]" in text + SHOTS.mkdir(parents=True, exist_ok=True) + panel = page.locator(".ui-confirm__panel") + panel.screenshot(path=str(SHOTS / "reset_light.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + panel.screenshot(path=str(SHOTS / "reset_dark.png")) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + page.locator(".ui-confirm__actions button", has_text="취소").click() + page.close() + + +def test_b09_인쇄_미리보기_저장_뒤_이동(ctx): + page = ctx.new_page() + calls: list[str] = [] + page.on( + "request", + lambda q: ( + "/estimation" in q.url + and q.method != "GET" + and calls.append(f"{q.method} {q.url.split('/estimation')[-1] or '/'}") + ), + ) + page.goto(f"{BASE}/#/b09-estimation", wait_until="domcontentloaded") + button = page.wait_for_selector("[data-b09-print]", timeout=90000) + page.wait_for_timeout(6000) # 원가 자료 받기 + user = ctx.request.get(f"{BASE}/api/projects/{PROJECT}/estimation").json().get("user") or {} + # 고친 값 있음 표 — 지금 서버 값 그대로(PUT 이 같은 값을 씀) + page.evaluate( + "([k, v]) => sessionStorage.setItem(k, v)", + [f"aislo:draft:b09-estimation:{PROJECT}", json.dumps(user, ensure_ascii=False)], + ) + button.click() + page.wait_for_url("**/#/b09-print", timeout=60000) + print("B09 [인쇄 미리보기] 요청", calls) + assert calls[:1] == ["PUT /"] + assert not any("complete" in call for call in calls) + page.close() diff --git a/resources/tester/test_506_3_b07_cad_cleanup.py b/resources/tester/test_506_3_b07_cad_cleanup.py new file mode 100644 index 000000000..747e63775 --- /dev/null +++ b/resources/tester/test_506_3_b07_cad_cleanup.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +"""506-3 나 · 다 · 정함 — 죽은 길 걷음 · CAD 되돌리기 이음 · B09 [인쇄 미리보기] · M02 도움말. + +못박는 것 + 6-a 이전 · 다음 도면 길(navigateDrawing · onNavigate · aislo:b08:navigate · hasPrev · hasNext) 없음. + 6-b CAD `cad-host-save` 클래스 토글 없음(CSS 규칙 0). + 4-2 B08 안 쓰는 로케일 글 11 없음. + 6-f 특성 판 색 칸 = `paintNowCommitLater`(리본과 한 벌 · `selection-style.ts`) — 동작은 vitest `cad/undo-506-3`. + 4-1 B09 [인쇄 미리보기] = 저장(`DRAFT.save`) 성공 뒤 이동 · 실패면 머묾 · 단계 그대로(complete 안 부름). + 6-e M02 카드 단축키 도움말에 DWG 불러오기 한 줄. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +CAD = ROOT / "B07_DesignDetail" / "openwebcad" / "src" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8").replace("\r\n", "\n") + + +def test_6a_이전_다음_길_없음(): + page = _read(ROOT / "B07_DesignDetail" / "B07_DesignDetail_UI_Page.ts") + host = _read(ROOT / "ui_template" / "cad_host" / "cad_host.ts") + types = _read(CAD / "App.types.ts") + for text, words in ( + (page, ("navigateDrawing", "onNavigate", "hasPrev", "hasNext")), + (host, ("onNavigate", "aislo:b08:navigate", "direction")), + (types, ("hasPrev", "hasNext")), + ): + for word in words: + assert word not in text, word + + +def test_6b_host_save_클래스_없음(): + state = _read(CAD / "state.ts") + assert "cad-host-save" not in state + assert "export const setHostSave" in state and "export const isHostSave" in state + + +def test_4_2_안_쓰는_글_없음(): + locale = _read(ROOT / "ui_template" / "ui_template_locale_b2.ts") + for key in ( + "B08_Quantity_Side_Pending", + "B08_Quantity_Btn_Confirm", + "B08_Quantity_Confirm_Success", + "B08_Quantity_Confirm_Failed", + "B08_Quantity_Dev_Title", + "B08_Quantity_Dev_Unlock", + "B08_Quantity_Dev_Relock", + "B08_Quantity_Dev_Bypassed", + "B08_Quantity_Dev_Normal", + "B08_Quantity_Dev_Done", + "B08_Quantity_Dev_Failed", + ): + assert key not in locale, key + assert "B08_Quantity_Title" in locale + + +def test_6f_색_칸_한_벌(): + style = _read(CAD / "helpers" / "selection-style.ts") + assert "export function paintNowCommitLater" in style + ribbon = _read(CAD / "components" / "RibbonWidgets.tsx") + assert "function paintNowCommitLater" not in ribbon and "paintNowCommitLater(" in ribbon + editor = _read(CAD / "components" / "PropertiesEditor.tsx") + color = editor[editor.index("색상") :] + color = color[: color.index("")] + assert "paintNowCommitLater(styleTargets(getSelectedEntities())" in color + assert "applyToLines" not in color + + +def test_6g_laymrg_사본(): + tools = _read(CAD / "tools" / "utility" / "layer-tools.ts") + merge = tools[tools.index("export const layerMergeToolStateMachine") :] + merge = merge[: merge.index("\n});")] + assert "restyle(" in merge + assert ( + "entity.layerId = targetId;" in merge and "for (const entity of getEntities())" not in merge + ) + + +def test_4_1_인쇄_미리보기_저장_뒤_이동(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_506_3_b09_print.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["saved"] == ["save", "go:/b09-print"] + assert out["failed"] == ["save"] + shell = _read(ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts") + assert "DRAFT.save = saveChanged;" in shell + ui = _read(ROOT / "B09_Estimation" / "draft" / "B09_Draft_Print_UI.ts") + assert "complete" not in ui.split("DRAFT.actions.push(")[1] # 단계 그대로 + + +def test_6e_m02_dwg_도움말(): + card = _read(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_DrawingCard.ts") + keys = card[card.index("export const DRAWING_KEYS") :] + keys = keys[: keys.index("];")] + assert '["IMPORT · 파일 끌어 놓기", "DWG · DXF 불러오기(고치기 카드)"]' in keys diff --git a/resources/tester/test_506_3_b07_save_path.py b/resources/tester/test_506_3_b07_save_path.py new file mode 100644 index 000000000..540eb301d --- /dev/null +++ b/resources/tester/test_506_3_b07_save_path.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +"""506-3 가 · 나(서버) — B07 저장 길 · 풀린 장 찌꺼기. + +못박는 것 + 1-A 도면 GET · 저장 PUT = `API_ANALYSIS_TIMEOUT_MS` · 저장 중 미리받기 쉼 · 끊긴 저장 = 목록 `confirmed` 로 판정. + 2 CAD 자동백업 칸 = `b07:{프로젝트}:{도면}` · 도각 편집 = `b07-frame:{프로젝트}` · [초기화]가 같은 열쇠로 지움. + 3-1 [초기화] 물음 글 = 프로젝트 도각은 그대로 · [도각 편집] → [기본 도각 복원] 안내. + 3-2 풀린 장(`confirmed` 거짓) = 저장본 json · 수량표 걷음 · 도면번호 남김. + 5-1 B07 쓰기 길 = `mark_stage(save)`(옛 `start_stage` 없음). + 7-3 `start_stage` 가 `DEV_UNLOCK:` 표식을 남김. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +B07 = ROOT / "B07_DesignDetail" +sys.path.insert(0, str(ROOT)) + + +def _read(name: str) -> str: + return (B07 / name).read_text(encoding="utf-8").replace("\r\n", "\n") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_506_3_b07_save.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_1a_한도(): + api = _read("B07_DesignDetail_Api_Fetch.ts") + get = api[api.index("export function fetchDesignDrawing(") :] + get = get[: get.index("\n}\n")] + assert "API_ANALYSIS_TIMEOUT_MS" in get + put = api[api.index("export function confirmDesignDrawing(") :] + put = put[: put.index("\n}\n")] + assert "API_ANALYSIS_TIMEOUT_MS" in put + assert "timeoutMs: number = API_TIMEOUT_MS" in api + + +def test_1a_미리받기_쉼(): + page = _read("B07_DesignDetail_UI_Page.ts") + assert "while (loadInFlight || isSaving())" in page + + +def test_1a_저장_판정(out): + assert out["normal"]["savingSeen"] is True + assert out["normal"]["savingAfter"] is False + assert out["normal"]["calls"] == [ + "overlay+", + "confirm:cross_s1", + "overlay-", + "toast:success", + "saved", + ] + # 끊김 + 서버 저장됨 = 저장했습니다 + assert out["abortSaved"]["calls"] == [ + "overlay+", + "confirm:cross_s1", + "list", + "overlay-", + "toast:success", + "saved", + ] + # 끊김 + 서버 저장 안 됨 = 머묾 + assert out["abortUnsaved"]["calls"] == [ + "overlay+", + "confirm:cross_s1", + "list", + "toast:error", + "overlay-", + ] + assert out["abortUnsaved"]["savingAfter"] is False + # 끊김 아닌 오류 = 목록 안 읽음 + assert out["otherError"] == ["overlay+", "confirm:cross_s1", "toast:error", "overlay-"] + + +def test_2_자동백업_열쇠(out): + assert out["scope"] == "b07:p1:cross_s1" + assert "drop:OPEN_WEB_CAD__RECOVERY__b07:p1:cross_s1" in out["reset"]["calls"] + page = _read("B07_DesignDetail_UI_Page.ts") + assert "recoveryScopeOf(projectId, drawing.id)" in page + assert "{ guide, recoveryScope }" in page + assert "recoveryScope: `b07-frame:${projectId}`" in page + + +def test_3_1_초기화_글(out): + asked = out["reset"]["asked"] + assert "프로젝트 도각은 그대로" in asked + assert "[도각 편집] → [기본 도각 복원]" in asked + frame = (ROOT / "ui_template" / "cad_host" / "cad_host_frame_edit.ts").read_text( + encoding="utf-8" + ) + assert 'label: "기본 도각 복원"' in frame + + +def _project(tmp_path: Path) -> Path: + from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _write_manifest + + stage = _design_root(tmp_path) + (stage / "drawings").mkdir(parents=True) + for drawing_id in ("cross_s1", "plan_a_1"): + (stage / "drawings" / f"{drawing_id}.json").write_text("{}", encoding="utf-8") + _write_manifest( + tmp_path, + { + "drawings": { + "cross_s1": { + "kind": "cross", + "confirmed": True, + "number": 7, + "file": "drawings/cross_s1.json", + "quantity_table": {"a": 1}, + "quantity_tables": {"1": {}}, + }, + "plan_a_1": {"kind": "plan", "confirmed": True, "file": "drawings/plan_a_1.json"}, + } + }, + ) + return stage + + +def test_3_2_풂_찌꺼기(tmp_path): + from B07_DesignDetail.B07_DesignDetail_Router_Support import _invalidate_drawing + from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest + + stage = _project(tmp_path) + _invalidate_drawing(tmp_path, "cross_s1") + entry = _read_manifest(tmp_path)["drawings"]["cross_s1"] + assert entry == {"kind": "cross", "confirmed": False, "number": 7} + assert not (stage / "drawings" / "cross_s1.json").exists() + # 안 푼 장은 그대로 + assert (stage / "drawings" / "plan_a_1.json").exists() + assert _read_manifest(tmp_path)["drawings"]["plan_a_1"]["file"] == "drawings/plan_a_1.json" + + +def test_3_2_초기화_찌꺼기(tmp_path): + from B07_DesignDetail.B07_DesignDetail_Router_Reset import reset_design_files + + stage = _project(tmp_path) + assert reset_design_files(tmp_path)["released"] == 2 + assert list((stage / "drawings").iterdir()) == [] + + +def test_5_1_새_단계_규칙(): + router = _read("B07_DesignDetail_Router.py") + scale = _read("B07_DesignDetail_Router_Support_Scale.py") + assert "start_stage" not in router + assert "start_stage" not in scale + assert router.count('await mark_stage(str(project_id), 4, "save", cursor)') == 3 + assert scale.count('await mark_stage(str(project_id), 4, "save", cursor)') == 1 + + +class _Cursor: + def __init__(self) -> None: + self.sql: list[str] = [] + + async def execute(self, sql: str, args: tuple = ()) -> None: + # pymysql 꼴 — 인자가 있으면 `%` 서식(`%%` = 글자 %) + self.sql.append(sql % tuple(repr(a) for a in args) if args else sql) + + +def test_7_3_잠금풀기_표식_남김(): + from common_util.common_util_workflow_state import start_stage + + cursor = _Cursor() + asyncio.run(start_stage(cursor, "p1", 4)) + first = cursor.sql[0] + assert "message = CASE WHEN message LIKE 'DEV_UNLOCK:%' THEN message ELSE NULL END" in first + assert "message = NULL" not in first diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 04f615dfa..70e00ff94 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -21,7 +21,6 @@ const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error"; const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed"; const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request"; const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response"; -const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate"; const CAD_EXPORT_MESSAGE = "aislo:b08:export-file"; const CAD_HOST_SAVE_MESSAGE = "aislo:b08:host-save"; /** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시). @@ -67,8 +66,6 @@ export interface CadHostOptions { title: string; /** CAD 편집 통지 — dirty 는 미저장 편집이 있는가. */ onChanged?: (dirty: boolean) => void; - /** CAD 리본의 이전·다음 도면 단추. */ - onNavigate?: (direction: "prev" | "next") => void; /** CAD 리본의 DXF·DWG 내보내기 단추. */ onExport?: (fileFormat: "dxf" | "dwg") => void; /** CAD 안 💾 · Ctrl+S — `hostSave` 로 실은 화면(M02 양식)만 온다. */ @@ -291,7 +288,6 @@ export function createCadHost