fix(506-3 B07 · CAD 작은 흠): 저장 길 · 죽은 길 걷음 · CAD 되돌리기 · B09 인쇄 미리보기 · M02 도움말
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
/** `timeoutMs` = 끊는 한도 — 도면 GET · 저장 PUT 은 `API_ANALYSIS_TIMEOUT_MS`(평면도 작도 · 횡단 읽기가 30 s 넘음). */
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs: number = API_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
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<DesignDrawingResponse> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
POST /api/projects/{id}/design-drawings/reset 본문 없음
|
||||
|
||||
기준 = 저장본 없이 지금 B06 정본으로 그린 원본(떠 두지 않음 · 앞 단계 저장 = 저절로 새 기준).
|
||||
지움 = 모든 저장본 확정 풂(`confirmed=False` · 도면번호 · json 남김 = [원본에서 다시 그리기] 꼴) ·
|
||||
지움 = 모든 저장본 확정 풂(`confirmed=False` · 도면번호 남김 · json 걷음 = [원본에서 다시 그리기] 꼴) ·
|
||||
B07 이 확정으로 올린 측점 설계 → 잠정 · 설정 파일(축척 · 횡단 배치 · 표 배율 · 평면도 종류 · 표식).
|
||||
M02 층(도각 · 표지)은 안 건드림. 단계 = [저장]과 같음(`mark_stage` save).
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
});
|
||||
};
|
||||
|
||||
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<void> {
|
||||
const prefetchAllDrawings = async (): Promise<void> => {
|
||||
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<void> {
|
||||
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> {
|
||||
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<SaveResult> => cad.requestSave();
|
||||
|
||||
/** 저장 · 원본 되돌림 묶음(`_UI_Save`)이 쓰는 상태 길. */
|
||||
@@ -411,7 +398,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
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: () => {
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/** 저장 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<boolean> {
|
||||
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<boolean>
|
||||
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<boolean>
|
||||
);
|
||||
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<void> {
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -51,9 +51,6 @@ export interface DesignMeta {
|
||||
confirmed: boolean;
|
||||
/** 수량 산출표 값 (횡단도만). 미산정 항목은 null. */
|
||||
quantityTable: Record<string, number | null> | null;
|
||||
/** 이전/다음 도면 존재 여부 (경계에서 버튼 비활성화) */
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
export interface StateMetaData {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
commonValue,
|
||||
LINE_TYPES,
|
||||
LINE_WIDTHS,
|
||||
paintNowCommitLater,
|
||||
restyle,
|
||||
selectionLineStyle,
|
||||
styleTargets,
|
||||
@@ -47,11 +48,13 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ 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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -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<typeof setTimeout> | 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());
|
||||
|
||||
|
||||
@@ -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<typeof setTimeout> | 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);
|
||||
}
|
||||
|
||||
@@ -40,8 +40,6 @@ const meta = (confirmed: boolean): DesignMeta => ({
|
||||
info: '',
|
||||
confirmed,
|
||||
quantityTable: null,
|
||||
hasPrev: false,
|
||||
hasNext: false,
|
||||
});
|
||||
|
||||
describe('setActiveLayerId', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}(으)로 병합했습니다.`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -132,6 +132,8 @@ export const DRAFT: {
|
||||
panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>;
|
||||
actions: Array<() => HTMLElement>;
|
||||
next?: () => void;
|
||||
/** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */
|
||||
save?: () => Promise<boolean>;
|
||||
} = { builders: {}, extend: [], panels: [], actions: [] };
|
||||
|
||||
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", "되돌리기 · 다시 하기"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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-",
|
||||
|
||||
@@ -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("<span>색상</span>") :]
|
||||
color = color[: color.index("</label>")]
|
||||
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
|
||||
@@ -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
|
||||
@@ -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<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
detail?: string;
|
||||
drawing?: D;
|
||||
quantityTable?: Q | null;
|
||||
direction?: "prev" | "next";
|
||||
fileFormat?: "dxf" | "dwg";
|
||||
dirty?: boolean;
|
||||
kind?: string;
|
||||
@@ -343,8 +339,6 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
showFailure(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
options.onChanged?.(message.dirty !== false);
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
options.onNavigate?.(message.direction);
|
||||
} else if (message.type === CAD_HOST_SAVE_MESSAGE) {
|
||||
options.onHostSave?.();
|
||||
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
|
||||
|
||||
@@ -566,19 +566,6 @@ export const ui_locales_b2 = {
|
||||
|
||||
/* --- B08_Quantity 수량 산출 --- */
|
||||
B08_Quantity_Title: ["수량산출", "Quantity"],
|
||||
B08_Quantity_Side_Pending: [
|
||||
"수량 산출 기능은 준비 중입니다. [확정]을 누르면 5차 단계를 마치고 설계도서로 이동합니다.",
|
||||
"Quantity module is in preparation. Press [Confirm] to finish stage 5 and move to Design Docs.",
|
||||
],
|
||||
B08_Quantity_Btn_Confirm: ["확정", "Confirm"],
|
||||
B08_Quantity_Confirm_Success: [
|
||||
"수량 단계를 확정했습니다. 설계도서로 이동합니다.",
|
||||
"Quantity stage confirmed. Moving to Design Docs.",
|
||||
],
|
||||
B08_Quantity_Confirm_Failed: [
|
||||
"수량 단계 확정에 실패했습니다.",
|
||||
"Failed to confirm the quantity stage.",
|
||||
],
|
||||
B08_Quantity_Tab_Earthwork: ["토공", "Earthwork"],
|
||||
B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"],
|
||||
B08_Quantity_Grid_Empty: [
|
||||
@@ -603,19 +590,6 @@ export const ui_locales_b2 = {
|
||||
"콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.",
|
||||
"The placing method changes this work item's unit price — concrete volume is handed over as a placing work item.",
|
||||
],
|
||||
B08_Quantity_Dev_Title: [
|
||||
"⚠ 개발 전용 — 확정을 건너뛰고 다음 단계를 엽니다(계산은 돌지 않습니다).",
|
||||
"⚠ Dev only — unlocks the next stage without confirming (no recalculation).",
|
||||
],
|
||||
B08_Quantity_Dev_Unlock: ["확정 없이 다음으로", "Skip confirm"],
|
||||
B08_Quantity_Dev_Relock: ["원래대로 되돌리기", "Undo skip"],
|
||||
B08_Quantity_Dev_Bypassed: [
|
||||
"지금 확정을 건너뛴 상태입니다 — 값이 비어 보이는 것은 정상입니다. 건너뛴 단계",
|
||||
"Currently skipping confirmation — empty values are expected. Skipped stages",
|
||||
],
|
||||
B08_Quantity_Dev_Normal: ["건너뛴 단계 없음 (정상 상태)", "No skipped stages"],
|
||||
B08_Quantity_Dev_Done: ["단계 잠금을 바꿨습니다.", "Stage lock updated."],
|
||||
B08_Quantity_Dev_Failed: ["단계 잠금을 바꾸지 못했습니다.", "Failed to update stage lock."],
|
||||
B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"],
|
||||
B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"],
|
||||
B08_Quantity_Side_TopsoilTarget_Label: ["표토제거 대상", "Topsoil removal target"],
|
||||
|
||||
Reference in New Issue
Block a user