From 17e6500cf6acb4dd1c811b0091cc3ce7983b40b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 21 Oct 2026 12:48:36 +0900 Subject: [PATCH] =?UTF-8?q?fix(506-3=20B07=20=C2=B7=20CAD=20=EC=9E=91?= =?UTF-8?q?=EC=9D=80=20=ED=9D=A0):=20=EC=A0=80=EC=9E=A5=20=EA=B8=B8=20?= =?UTF-8?q?=C2=B7=20=EC=A3=BD=EC=9D=80=20=EA=B8=B8=20=EA=B1=B7=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20CAD=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?B09=20=EC=9D=B8=EC=87=84=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20M02=20=EB=8F=84=EC=9B=80=EB=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1-A 도면 GET · 저장 PUT 한도 60 s · 저장 중 미리받기 쉼 · 끊긴 저장 = 목록 confirmed 로 판정 - 2 CAD 자동백업 열쇠 = b07:{프로젝트}:{도면} · 도각 편집 = b07-frame:{프로젝트} · [초기화] 같은 열쇠 - 3-1 [초기화] 물음 글 = 프로젝트 도각 그대로 · [도각 편집] → [기본 도각 복원] 안내 - 3-2 풀린 장 저장본 json · 수량표 찌꺼기 걷음(_write_manifest 한 곳) - 5-1 확정 풀기 · 배치 · 축척 = mark_stage(save) - 7-3 start_stage 가 DEV_UNLOCK 표식 남김 - 6-a navigateDrawing · onNavigate · hasPrev · hasNext 걷음 · 6-b cad-host-save 토글 걷음 · 4-2 B08 안 쓰는 글 11 걷음 - 6-f 특성 판 색 끌기 = 되돌리기 한 칸(paintNowCommitLater 한 벌) · 6-g LAYMRG 사본 길 - 4-1 B09 [인쇄 미리보기] = 저장 뒤 이동 · 단계 그대로 · 6-e M02 카드 도움말 DWG 한 줄 - 시험 test_506_3_* 둘 · cad/undo-506-3 · test_298_2_b07_buttons 열쇠 맞춤 Co-Authored-By: Claude Opus 5.5 --- .../B07_DesignDetail_Api_Fetch.ts | 18 +- B07_DesignDetail/B07_DesignDetail_Router.py | 6 +- .../B07_DesignDetail_Router_Reset.py | 2 +- .../B07_DesignDetail_Router_Support_Io.py | 7 + .../B07_DesignDetail_Router_Support_Scale.py | 4 +- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 34 ++-- B07_DesignDetail/B07_DesignDetail_UI_Save.ts | 37 +++- B07_DesignDetail/openwebcad/src/App.types.ts | 3 - .../src/components/PropertiesEditor.tsx | 13 +- .../src/components/RibbonWidgets.tsx | 22 +-- .../openwebcad/src/helpers/selection-style.ts | 19 ++ B07_DesignDetail/openwebcad/src/state.test.ts | 2 - B07_DesignDetail/openwebcad/src/state.ts | 2 - .../src/tools/utility/layer-tools.ts | 12 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 1 + B09_Estimation/B09_Estimation_UI_Tabs.ts | 2 + B09_Estimation/draft/B09_Draft_Print_UI.ts | 8 +- .../M02_MasterTemplete_DrawingCard.ts | 1 + common_util/common_util_workflow_state.py | 8 +- resources/tester/cad/undo-506-3.test.ts | 77 ++++++++ resources/tester/helper_506_3_b07_save.cjs | 106 ++++++++++ resources/tester/helper_506_3_b09_print.cjs | 33 ++++ resources/tester/test_298_2_b07_buttons.py | 4 +- .../tester/test_506_3_b07_cad_cleanup.py | 111 +++++++++++ resources/tester/test_506_3_b07_save_path.py | 187 ++++++++++++++++++ ui_template/cad_host/cad_host.ts | 6 - ui_template/ui_template_locale_b2.ts | 26 --- 27 files changed, 639 insertions(+), 112 deletions(-) create mode 100644 resources/tester/cad/undo-506-3.test.ts create mode 100644 resources/tester/helper_506_3_b07_save.cjs create mode 100644 resources/tester/helper_506_3_b09_print.cjs create mode 100644 resources/tester/test_506_3_b07_cad_cleanup.py create mode 100644 resources/tester/test_506_3_b07_save_path.py 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/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_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