Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_4' into sub_laptop_3

This commit is contained in:
2026-10-21 13:16:05 +09:00
42 changed files with 1704 additions and 427 deletions
+12 -3
View File
@@ -6,6 +6,7 @@
* `crossWithUphill` · `stationSection`)를 그 자리 측점 하나로 다시 부름 — [측점 추가] 뒤 묶음이 낼 측점과
* 같은 틀 · 반경 · 확폭(기존 측점 + 그 자리로 셈). 파생 저장 · 캐시 · 묶음 상태는 안 건드림.
* 지형 격자가 없거나 분석 지형 ≠ 설계 지형이면 null(부르는 쪽이 서버 미리 뜨기로).
* 단면(`section`) = 그 측점에 지금 현황 형상 · 이름을 얹은 것 — 창이 「횡단」 판과 같은 B06 카드로 그림(505-3).
* ========================================================================== */
import { pagePipeline } from "./design_pipeline_page";
@@ -19,27 +20,31 @@ import { probeStation } from "./logic/design_pipeline_probe_station";
import { groundStageDeps } from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
import { createGroundStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
import {
attachStatus,
crossWithShapes,
crossAtSeat,
crossWithUphill,
shapeStatus,
stationSection,
type CrossInput,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
import { pipelineCrossAt } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Cross";
import type { CrossPreviewResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
const SAME_M = 1e-6;
/**
* 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단 — 묶음이 아직 안 셌거나 격자 없음 · 분석 지형 ≠ 설계 지형이면 null.
* 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단(선 그림 재료 · 형상 얹은 단면) — 묶음이 아직 안 셌거나
* 격자 없음 · 분석 지형 ≠ 설계 지형이면 null.
*/
export async function pipelineCrossAtChainage(
projectId: string,
chainageM: number,
intervalM: number,
): Promise<CrossPreviewResponse | null> {
): Promise<{ preview: CrossPreviewResponse; section: CrossSection } | null> {
const pipe = pagePipeline(projectId);
const route = pipe?.output("route");
const profile = pipe?.output("profile");
@@ -105,5 +110,9 @@ export async function pipelineCrossAtChainage(
output: (name: StageName) =>
name === "ground" ? { stations: [station] } : name === "cross" ? cross : null,
} as unknown as Parameters<typeof pipelineCrossAt>[0];
return pipelineCrossAt(one, chainageM, intervalM, undefined);
const preview = pipelineCrossAt(one, chainageM, intervalM, undefined);
if (!preview) return null;
const section = stationSection(station, cross, {}, intervalM);
attachStatus([section], status);
return { preview, section };
}
@@ -43,6 +43,14 @@
background: var(--color-surface);
}
/* B06 카드 읽기 전용(505-3) — 칸 테두리 안 · 고름 강조 · 손가락 없음 */
.b05-crossmodal__box > .b06-cross-card {
border: 0;
border-radius: 0;
box-shadow: none;
cursor: default;
}
.b05-crossmodal__foot {
color: var(--color-text-secondary);
font-size: var(--text-caption);
@@ -5,7 +5,8 @@
* 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침
* (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산
* `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` ·
* 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시.
* 저장 계획선 기준 · 글 한 줄) · 그림 = 평면 「횡단」 판과 같은 B06 카드(구조물 형상 · 포장 · 면적 · 505-3 ·
* 서버 미리 뜬 것만 선 그림 `buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시.
* [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가
* 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1).
* ========================================================================== */
@@ -20,7 +21,7 @@ import {
} from "../A00_Common/design/logic/design_profile_free";
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../B06_Section/B06_Section_UI_Longitudinal";
import { previewStructureSections } from "../B06_Section/B06_Section_Api_Fetch";
import { pagePipeline } from "../A00_Common/design/design_pipeline_page";
import { pagePipeline, pipelineDetail } from "../A00_Common/design/design_pipeline_page";
import { pipelineCrossAtChainage } from "../A00_Common/design/design_pipeline_probe";
import { stationLabel } from "../common_util/common_util_station";
import { plusStationsOf, writePlusDraft } from "./B05_Profile_Api_PlusStations";
@@ -30,6 +31,7 @@ import {
CROSS_PLOT_PAD,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { crossSourceOf, routeCrossCard } from "./B05_Profile_UI_RouteEdit_Cross_Card";
import {
crossPickAt,
groundElevationAt,
@@ -196,9 +198,9 @@ function createCrossModal(host: HTMLElement): CrossModal {
<strong>임시 횡단</strong>
<span class="b05-crossmodal__station"></span>
<div class="b05-crossmodal__actions">
<button type="button" class="ui-button" data-act="add">측점 추가</button>
<button type="button" class="ui-button" data-act="remove">삭제</button>
<button type="button" class="ui-button" data-act="close">닫기</button>
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="add">측점 추가</button>
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="remove">삭제</button>
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="close">닫기</button>
</div>
</div>
<div class="b05-crossmodal__box"></div>
@@ -241,7 +243,7 @@ function createCrossModal(host: HTMLElement): CrossModal {
const now = await pipelineCrossAtChainage(projectId(), m, current?.intervalM ?? 20).catch(
() => null,
);
const [cross] = now ? [now] : await previewStructureSections(projectId(), [m]);
const [cross] = now ? [now.preview] : await previewStructureSections(projectId(), [m]);
if (mine !== turn) return;
if (!cross) {
foot.textContent = "이 자리의 횡단을 뜨지 못했습니다.";
@@ -249,21 +251,37 @@ function createCrossModal(host: HTMLElement): CrossModal {
}
const preview = cross as unknown as CrossPreviewResponse;
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.round(rect.width) || 320,
Math.round(rect.height) || 180,
);
box.replaceChildren(plot.svg);
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
const [width, height] = [Math.round(rect.width) || 320, Math.round(rect.height) || 180];
if (now) {
// 「횡단」 판과 같은 B06 카드(505-3) — 그림 밖 높이만큼 줄여 한 번 더 지어 칸에 맞춤
const detail = pipelineDetail(projectId());
const source = crossSourceOf(
projectId(),
now.section,
detail?.cross_sections ?? [now.section],
detail?.longitudinal.design_profiles,
"modal",
);
const build = (plotHeight: number): HTMLElement => {
const card = routeCrossCard(source, current?.intervalM ?? 20, width, plotHeight);
box.replaceChildren(card);
return card;
};
const extra = build(height).offsetHeight - height;
if (extra > 0) build(Math.max(120, height - extra));
} else {
const plot = buildCrossSvg(preview, width, height);
box.replaceChildren(plot.svg);
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
}
const planZ = Number(
(preview.design as { design_elevation_m?: unknown } | null)?.design_elevation_m,
);
@@ -15,7 +15,7 @@ import "../B06_Section/B06_Section_UI_Style_Cross_Controls.css";
import { pipelineDetail } from "../A00_Common/design/design_pipeline_page";
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
import { nearestWithin } from "../A00_Common/design/design_selection";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CrossSection, DesignProfile } from "../B06_Section/B06_Section_Api_Fetch";
import { peekSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { displayHalfWidth } from "../B06_Section/B06_Section_Display_Width";
import { pageRouteId } from "../A00_Common/design/design_page_route";
@@ -74,7 +74,17 @@ export function routeCrossSource(
const sections = detail?.cross_sections ?? [];
const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m);
if (!detail || !found) return null;
const profiles = detail.longitudinal.design_profiles;
return crossSourceOf(projectId, found, sections, detail.longitudinal.design_profiles, pane);
}
/** 단면 하나 → 판 재료(사본) — 이웃 단면 · 계획선은 연동 관 · 반폭 · 계획고 재료(종단 임시 횡단 창도 씀 · 505-3). */
export function crossSourceOf(
projectId: string,
found: CrossSection,
sections: readonly CrossSection[],
profiles: DesignProfile[] | undefined,
pane: string,
): RouteCrossSource {
const planZ = designElevationAt(profiles, found.chainage_m);
return {
section: {
@@ -146,6 +156,14 @@ export function routeCrossCard(
box,
);
// 그래프만(393-3) — 카드 머리 · 줌 단추 · 발(암 경계 · 요약) 걷음 · 벽 · 선 · 면적 고름 막음
// 경고 칩(빈칸 · 기본값 · 사면 열림)만 그래프 오른쪽 위에 겹쳐 남김(505-3)
const chips = card.querySelectorAll(":scope > header .b06-cross-card__warning");
if (chips.length) {
const box = document.createElement("div");
box.className = "b05-cross-chips";
box.append(...chips);
card.append(box);
}
card
.querySelectorAll(":scope > header, :scope > footer, .b06-cross-card__zoom")
.forEach((el) => el.remove());
@@ -180,6 +180,13 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
);
}
// 가둠 판(도구줄 · 횡단 판 · 종단 판)이 열리고 닫히고 크기가 바뀌면 다시 가둠(505-3) · 편집 창이 닫히면 끊음
const watched = new Set<Element>();
const refit = new ResizeObserver(() => {
if (!params.canvas.isConnected) return refit.disconnect();
sync();
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
function sync(): void {
const {
@@ -199,11 +206,25 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
return;
}
const rect = params.canvas.getBoundingClientRect();
// 가둠 = 도구줄 아래 · 열린 횡단 판 왼쪽 · 뜬 종단 판 위(505-3 · 판 밑에 깔려 가리지 않게)
const main = params.canvas.closest(".design-page__main") ?? params.canvas.parentElement;
const box = (selector: string): DOMRect | null => {
const element = main?.querySelector(selector);
if (element && !watched.has(element)) {
watched.add(element);
refit.observe(element);
}
const found = element?.getBoundingClientRect();
return found && found.width > 0 && found.height > 0 ? found : null;
};
const tools = box(".b05-routeedit__tools");
const side = box(".b05-routeedit__side-wrap");
const profile = box(".b05-route-profile");
const bounds = {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
top: Math.max(rect.top, tools?.bottom ?? rect.top) + 8,
right: Math.min(rect.right, side?.left ?? rect.right) - 8,
bottom: Math.min(rect.bottom, profile?.top ?? rect.bottom) - 8,
};
const last = planned.length - 1;
if (picked === 0 || picked === last) {
@@ -288,6 +288,32 @@
cursor: default;
}
/* 카드 경고 칩(빈칸 · 기본값 · 사면 열림) — 머리를 걷어도 그래프 오른쪽 위에 겹쳐 남김(505-3) · 판 · 임시 횡단 창 같이 씀 */
.b06-cross-card:has(> .b05-cross-chips) {
position: relative;
}
.b05-cross-chips {
position: absolute;
top: 4px;
right: 4px;
z-index: 3;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
max-width: 70%;
pointer-events: none;
}
.b05-cross-chips > .b06-cross-card__warning {
padding: 0 4px;
border-radius: var(--radius-4, 4px);
background: color-mix(in srgb, var(--color-surface-raised) 85%, transparent);
font-size: var(--text-caption);
pointer-events: auto;
}
.b05-routeedit__cross-foot:empty {
display: none;
}
@@ -231,7 +231,7 @@ export function stationSection(
/** 측점마다 그림 자료 · 카드 이름 = 지금 현황을 그 자리에 다시 얹음(캐시 `withDraftShapes` · 이름 = 측점 계획
* 제목 · 457) — 옛 측점 그림을 옮기지 않음. */
function attachStatus(sections: CrossSection[], status: ShapeStatus): void {
export function attachStatus(sections: CrossSection[], status: ShapeStatus): void {
const { pipes, structures, types } = status;
attachShapes(sections as unknown as Array<Record<string, unknown>>, pipes, structures, types);
const planned = planStations(pipes, structures, types, registryDefaults(types));
@@ -436,6 +436,12 @@ html.is-bm-route .b06-section__notice-bar {
stroke-width: 2.5;
}
/* 지반선 · 계획선은 누름을 통과 — 겹친 측점선 누름이 측점 고름 · 임시 횡단으로 감(505-3) */
.b06-chart__profile,
.b06-chart__design-profile {
pointer-events: none;
}
.b06-chart__cross-profile {
fill: none;
stroke: var(--color-chart-1);
+14 -4
View File
@@ -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,
);
}
+3 -3
View File
@@ -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()
+12 -22
View File
@@ -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: () => {
+33 -4
View File
@@ -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', () => {
-2
View File
@@ -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",
+2
View File
@@ -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 열을 들여씀 */
+6 -2
View File
@@ -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", "되돌리기 · 다시 하기"],
+6 -2
View File
@@ -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
+301
View File
@@ -0,0 +1,301 @@
"""
config_dev_server.py
개발 서버 전용 — 프런트(Vite) 띄움·끔 + 파이썬 자동 리로드 범위
main.py 에서 쪼갠 것(동작 그대로). 운영에서는 `serve_frontend_dev` 가 아무것도 안 한다.
"""
import locale
import logging
import os
import signal
import subprocess
from pathlib import Path
from config.config_system import ENVIRONMENT
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
# 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다.
FRONTEND_DEV_PORT = int(os.getenv("FRONTEND_DEV_PORT", "5173"))
# 띄운 개발 서버 핸들 — 종료 때 이 계통을 통째로 끝낸다.
_frontend_dev_process: subprocess.Popen | None = None
def _frontend_process_env(root_dir: Path) -> dict[str, str]:
"""config/node_modules를 사용하는 프론트엔드 프로세스 환경을 만든다."""
env = os.environ.copy()
node_modules = root_dir / "config" / "node_modules"
bin_dir = node_modules / ".bin"
env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
env["NODE_PATH"] = str(node_modules)
return env
def build_frontend() -> bool:
"""프론트엔드 빌드 (npm run build from project root)"""
root_dir = _ROOT
logger.info("[Frontend] npm run build 시작...")
try:
result = subprocess.run(
"npm run build",
shell=True,
cwd=str(root_dir),
env=_frontend_process_env(root_dir),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=300,
)
if result.returncode == 0:
logger.info("✓ 프론트엔드 빌드 완료")
return True
else:
logger.error(f"✗ 프론트엔드 빌드 실패:\n{result.stderr}")
return False
except subprocess.TimeoutExpired:
logger.error("✗ 프론트엔드 빌드 타임아웃 (300초)")
return False
except Exception as e:
logger.error(f"✗ 프론트엔드 빌드 중 예외: {e}")
return False
def _kill_process_tree(pid: int) -> None:
"""프로세스와 그 자식들을 함께 끝낸다.
`shell=True`로 띄우면 실제 계통이 cmd.exe → npm → node라 부모만 죽이면 node가 남는다.
남은 node가 포트를 계속 물고 있으면 다음 실행 때 Vite가 옆 포트로 올라가고, 사용자는
옛 화면을 보게 된다(2026-08-01 실제로 13개가 쌓였다).
"""
try:
if os.name == "nt":
subprocess.run(
["taskkill", "/T", "/F", "/PID", str(pid)],
check=False,
capture_output=True,
)
else:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except Exception as exc: # 이미 죽었으면 그것으로 됐다
logger.debug("[Frontend] 프로세스 정리 중 무시된 예외: %s", exc)
def dev_port_listening(port: int) -> bool:
"""개발 서버(Vite)가 이미 그 포트를 물고 있나.
**리로드인지 첫 기동인지를 가르는 하나뿐인 표식**이다. uvicorn 리로더는 코드가 바뀌면
앱 프로세스만 다시 띄우고 Vite 자식은 그대로 두므로, 포트가 살아 있으면 리로드다.
환경변수 같은 별도 깃발을 두지 않는 까닭은 그것이 **실제와 어긋날 수 있기** 때문이다 —
포트는 어긋나지 않는다.
"""
import socket
probe = socket.socket()
probe.settimeout(0.5)
try:
probe.connect(("127.0.0.1", port))
return True
except OSError:
return False
finally:
probe.close()
def _free_dev_port(port: int) -> None:
"""개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다.
앞선 실행이 비정상 종료(강제 종료·전원 차단)했을 때를 대비한 청소다. 이걸 하지 않으면
Vite가 `--strictPort` 때문에 기동에 실패한다.
"""
if os.name != "nt":
return
try:
# 한글 Windows의 netstat는 콘솔 코드페이지(cp949)로 찍는다 — 기본 utf-8로 읽으면
# 한글 헤더에서 UnicodeDecodeError가 나고, 그 예외가 읽기 스레드에서 터져
# 포트 정리가 통째로 죽는다(2026-08-16 실제 발생). 시스템 인코딩으로 읽고,
# 깨지는 바이트는 넘긴다 — 우리가 볼 것은 숫자 포트·PID뿐이다.
result = subprocess.run(
["netstat", "-ano", "-p", "TCP"],
check=False,
capture_output=True,
text=True,
encoding=locale.getpreferredencoding(False),
errors="replace",
)
except Exception:
return
needle = f":{port} "
for line in (result.stdout or "").splitlines():
if needle not in line or "LISTENING" not in line:
continue
pid = line.split()[-1]
if pid.isdigit() and int(pid) != os.getpid():
logger.info("[Frontend] 포트 %d를 물고 있던 잔여 프로세스 %s 정리", port, pid)
_kill_process_tree(int(pid))
def serve_frontend_dev() -> None:
"""프론트엔드 개발 서버 실행 (npm run dev from project root) — 선택 사항.
포트를 **고정**(`--strictPort`)한다. 예전에는 포트가 비어 있지 않으면 Vite가 조용히
옆 포트로 올라갔고, 그렇게 뜬 옛 인스턴스가 계속 살아남아 하루치 변경이 화면에 반영되지
않는 일이 있었다. 고정해 두면 포트가 막혀 있을 때 그 자리에서 드러난다.
"""
global _frontend_dev_process
root_dir = _ROOT
if ENVIRONMENT == "production":
logger.info("[Frontend] 프로덕션 환경: 개발 서버 실행 스킵")
return
_free_dev_port(FRONTEND_DEV_PORT)
logger.info("[Frontend] npm run dev 시작 (포트 %d 고정, 백그라운드)...", FRONTEND_DEV_PORT)
try:
_frontend_dev_process = subprocess.Popen(
f"npm run dev -- --host --port {FRONTEND_DEV_PORT} --strictPort",
shell=True,
cwd=str(root_dir),
env=_frontend_process_env(root_dir),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
# POSIX에서 자식까지 한 번에 끝내려면 프로세스 그룹을 따로 잡아야 한다.
start_new_session=os.name != "nt",
)
logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행 (pid %s)", _frontend_dev_process.pid)
except Exception as e:
logger.warning(f"⚠ 프론트엔드 개발 서버 실행 실패: {e}")
def stop_frontend_dev() -> None:
"""앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다.
⚠⚠ **개발 중에는 내리지 않는다** (2026-09-12 실측으로 드러난 고리).
uvicorn 리로더는 `*.py` 가 바뀔 때마다 앱 프로세스를 갈아 끼운다. 그때 여기서
Vite 를 죽이면 **새 프로세스는 「Vite 가 없다」고 보고 `npm run build` 를 처음부터
다시 돌린다**(최대 300초 + B07 CAD). 그 사이 화면은 뜨지 않고 `/api/health` 는
`stale: true` 인 채로 옛 코드를 계속 내준다 — 그날 리로드가 두 번 멈췄고 한 번은
두 시간 반 동안 안 끝났다. **옛 화면을 보고 헛검증하게 만드는 자리다.**
⇒ Vite 는 **한 앱 프로세스가 아니라 개발 세션의 것**이다. 살려 두고 다음 기동이
그대로 물려쓴다(`dev_port_listening`). 남은 유령은 다음 기동의
`_free_dev_port` 가 포트로 잡아 정리하므로 쌓이지 않는다.
"""
global _frontend_dev_process
if ENVIRONMENT == "development":
logger.info("[Frontend] 개발 서버는 살려 둔다 — 다음 기동이 그대로 물려쓴다")
return
if _frontend_dev_process is None:
return
logger.info("[Frontend] 개발 서버 종료 (pid %s)", _frontend_dev_process.pid)
_kill_process_tree(_frontend_dev_process.pid)
_frontend_dev_process = None
# 셸을 거쳐 띄우므로 손자 프로세스가 남을 수 있다. 포트로 한 번 더 확인해 정리한다.
_free_dev_port(FRONTEND_DEV_PORT)
def reload_dirs() -> list[str]:
"""자동 리로드가 **훑을 폴더만** 골라 준다 — 폴더째 주면 서버가 쉬는 중에도 CPU를 먹는다.
uvicorn 의 StatReload 는 0.25초마다 지정 폴더를 통째로 훑는다. 아무것도 안 주면
현재 폴더가 대상이 되는데, 여기에는 `storage`(27GB · 프로젝트 산출물)와 `tmp`·`venv`·
`node_modules` 가 함께 들어 있다. 그래서 **요청이 하나도 없는 서버가 CPU 70%** 를 물고
있었고, 창 둘이 각각 그러니 기기가 늘 140% 눌린 채였다(2026-09-07 실측). 속도 계측이
회차마다 3배씩 흔들린 원인도 이것이다.
파이썬이 실제로 사는 폴더만 준다 — 화면 코드(TS)는 vite 가 따로 본다.
"""
root = _ROOT
# ⚠ 루트와 `config` 는 넣지 않는다 — uvicorn 은 준 폴더를 `rglob("*.py")` 로 **재귀**로
# 훑는다. 루트를 주면 storage·venv(파이썬 파일만 7,530개)를 도로 다 훑고, `config` 안에는
# `node_modules` 가 들어 있어 역시 무겁다. 그 대신 `main.py`·`config/` 는
# uvicorn 이 더하는 현재 폴더 감시가 맡는다(`reload_excludes` 참조).
watched: list[str] = []
for entry in sorted(root.iterdir()):
if not entry.is_dir() or entry.name.startswith(".") or entry.name in RELOAD_SKIP:
continue
# 링크(정션)는 통째로 건너뛴다. `node_modules` 안에 **저장소 루트를 되가리키는 정션**이
# 있어(`B07_DesignDetail/openwebcad/node_modules/forest-road-webapp` → 저장소 루트,
# npm 이 만든 것) 리로더가 그리로 되돌아가 `venv`·`storage` 를 다시 훑고 또 되감겼다.
# 실측(2026-09-07) — 감시 폴더를 훑으면 파일 **486,809개**, 한 바퀴 **1,045초**.
# 파이썬 `rglob` 은 정션을 따라 들어가고 PowerShell 은 안 따라가 눈으로는 안 보였다.
if entry.is_symlink():
continue
# 의존성 트리가 **한 겹 아래에 숨어 있는** 경우도 뺀다 — 위 정션이 그 안에 있었다.
if (entry / "node_modules").is_dir() or any(entry.glob("*/node_modules")):
continue
if any(entry.rglob("*.py")):
watched.append(str(entry))
return watched
# 파이썬이 있어도 훑지 않을 폴더 — 산출물·의존성·백업.
RELOAD_SKIP = {
"storage",
"tmp",
"venv",
"node_modules",
"__pycache__",
"log",
"docs",
"resources",
"scratch",
"0_old",
"db_management",
"migrations",
"ui_template",
"old_code",
}
def reload_excludes() -> list[str]:
"""uvicorn 0.24 는 `reload_dirs` 와 무관하게 현재 폴더 전체를 감시에 더한다
(`watchfilesreload.py` 의 `Path.cwd()` 추가) — 시험·tmp·docs 의 `.py` 하나로도 서버가
다시 읽힌다. 그 폴더들을 **절대 경로**로 제외한다(상대 이름은 uvicorn 이 안 맞춘다).
"""
return [
str(e)
for e in sorted(_ROOT.iterdir())
if e.is_dir() and (e.name in RELOAD_SKIP or e.name.startswith("."))
]
def patch_windows_reload() -> None:
"""윈도우에서 uvicorn 리로더가 **영원히 멈추는 것**을 고친다 (2026-09-12 실측).
무엇이 잘못됐나
uvicorn 0.24 의 `BaseReload.restart()` 는 윈도우에서만 옛 워커를
`os.kill(worker_pid, CTRL_C_EVENT)` 로 내리려 한다. 그런데 CTRL_C_EVENT 는
**프로세스 그룹을 지정해 보낼 수 없다** — MSDN 이 「그룹 id 가 0 이 아니면 호출은
성공하지만 신호는 전달되지 않는다」고 못박아 두었다. 워커는 리로더의 평범한
자식이라 그룹 우두머리가 아니므로 그 신호는 **아무 데도 가지 않는다.**
바로 다음 줄 `join()` 이 영원히 안 풀려서 —
· 옛 워커는 살아서 **옛 코드로 요청을 계속 처리**하고
· 새 워커는 태어나지 않으며
· 그 뒤의 파일 변경은 **감지조차 안 된다**(리로더가 그 자리에 갇혀 있다)
「고쳤는데 화면이 안 바뀐다」로 보이는 자리라 그냥 느린 것이 아니다.
실측 (그 자리를 그대로 흉내 낸 부모·자식 한 쌍)
CTRL_C_EVENT → **8초 안에 안 풀림** (DETACHED · CREATE_NO_WINDOW ·
CREATE_NO_WINDOW+NEWGROUP 셋 모두) · `terminate()` → **0.00초** ·
CTRL_BREAK_EVENT(자식을 그룹 우두머리로) → **0.00초**
⇒ uvicorn 이 **리눅스에서 이미 쓰는** `terminate()` 가 윈도우에서도 즉시 먹는다.
그래서 여기서는 그 리눅스 경로를 그대로 쓴다(새 방식을 지어내지 않는다).
⚠ 개발 기동(`DEBUG`)에서만 갈아 끼운다. uvicorn 이 위쪽에서 고치면 **이 함수를 지우면
된다** — 다른 자리는 아무것도 안 건드린다.
"""
from uvicorn._subprocess import get_subprocess
from uvicorn.supervisors.basereload import BaseReload
def restart(self: BaseReload) -> None:
self.process.terminate()
self.process.join()
self.process = get_subprocess(config=self.config, target=self.target, sockets=self.sockets)
self.process.start()
BaseReload.restart = restart
logger.info("[reload] 윈도우 리로더를 terminate() 방식으로 갈아 끼움")
+18 -284
View File
@@ -10,11 +10,8 @@ FastAPI 애플리케이션 진입점
"""
import asyncio
import locale
import logging
import os
import signal
import subprocess
import time
from contextlib import asynccontextmanager
from datetime import datetime
@@ -99,6 +96,16 @@ from common_util.common_util_help_router import router as help_router
from common_util.common_util_resource_monitor import sample_resources_loop
from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop
from config.config_db import close_db_pool, get_db_pool, init_db_pool
from config.config_dev_server import (
FRONTEND_DEV_PORT,
build_frontend,
dev_port_listening,
patch_windows_reload,
reload_dirs,
reload_excludes,
serve_frontend_dev,
stop_frontend_dev,
)
# 설정 import
from config.config_system import (
@@ -125,189 +132,6 @@ logger = logging.getLogger(__name__)
# 둘은 고칠 자리가 전혀 다른데, 이 줄이 없으면 로그만 보고는 못 가린다.
logger.info("[boot] 워커 프로세스 시작 pid=%s", os.getpid())
# ─────────────────────────────────────────────────────────────────────────
# 프론트엔드 빌드 및 서빙 함수
# ─────────────────────────────────────────────────────────────────────────
# 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다.
FRONTEND_DEV_PORT = int(os.getenv("FRONTEND_DEV_PORT", "5173"))
# 띄운 개발 서버 핸들 — 종료 때 이 계통을 통째로 끝낸다.
_frontend_dev_process: subprocess.Popen | None = None
def _frontend_process_env(root_dir: Path) -> dict[str, str]:
"""config/node_modules를 사용하는 프론트엔드 프로세스 환경을 만든다."""
env = os.environ.copy()
node_modules = root_dir / "config" / "node_modules"
bin_dir = node_modules / ".bin"
env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
env["NODE_PATH"] = str(node_modules)
return env
def build_frontend() -> bool:
"""프론트엔드 빌드 (npm run build from project root)"""
root_dir = Path(__file__).resolve().parent
logger.info("[Frontend] npm run build 시작...")
try:
result = subprocess.run(
"npm run build",
shell=True,
cwd=str(root_dir),
env=_frontend_process_env(root_dir),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=300,
)
if result.returncode == 0:
logger.info("✓ 프론트엔드 빌드 완료")
return True
else:
logger.error(f"✗ 프론트엔드 빌드 실패:\n{result.stderr}")
return False
except subprocess.TimeoutExpired:
logger.error("✗ 프론트엔드 빌드 타임아웃 (300초)")
return False
except Exception as e:
logger.error(f"✗ 프론트엔드 빌드 중 예외: {e}")
return False
def _kill_process_tree(pid: int) -> None:
"""프로세스와 그 자식들을 함께 끝낸다.
`shell=True`로 띄우면 실제 계통이 cmd.exe → npm → node라 부모만 죽이면 node가 남는다.
남은 node가 포트를 계속 물고 있으면 다음 실행 때 Vite가 옆 포트로 올라가고, 사용자는
옛 화면을 보게 된다(2026-08-01 실제로 13개가 쌓였다).
"""
try:
if os.name == "nt":
subprocess.run(
["taskkill", "/T", "/F", "/PID", str(pid)],
check=False,
capture_output=True,
)
else:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except Exception as exc: # 이미 죽었으면 그것으로 됐다
logger.debug("[Frontend] 프로세스 정리 중 무시된 예외: %s", exc)
def _dev_port_listening(port: int) -> bool:
"""개발 서버(Vite)가 이미 그 포트를 물고 있나.
**리로드인지 첫 기동인지를 가르는 하나뿐인 표식**이다. uvicorn 리로더는 코드가 바뀌면
앱 프로세스만 다시 띄우고 Vite 자식은 그대로 두므로, 포트가 살아 있으면 리로드다.
환경변수 같은 별도 깃발을 두지 않는 까닭은 그것이 **실제와 어긋날 수 있기** 때문이다 —
포트는 어긋나지 않는다.
"""
import socket
probe = socket.socket()
probe.settimeout(0.5)
try:
probe.connect(("127.0.0.1", port))
return True
except OSError:
return False
finally:
probe.close()
def _free_dev_port(port: int) -> None:
"""개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다.
앞선 실행이 비정상 종료(강제 종료·전원 차단)했을 때를 대비한 청소다. 이걸 하지 않으면
Vite가 `--strictPort` 때문에 기동에 실패한다.
"""
if os.name != "nt":
return
try:
# 한글 Windows의 netstat는 콘솔 코드페이지(cp949)로 찍는다 — 기본 utf-8로 읽으면
# 한글 헤더에서 UnicodeDecodeError가 나고, 그 예외가 읽기 스레드에서 터져
# 포트 정리가 통째로 죽는다(2026-08-16 실제 발생). 시스템 인코딩으로 읽고,
# 깨지는 바이트는 넘긴다 — 우리가 볼 것은 숫자 포트·PID뿐이다.
result = subprocess.run(
["netstat", "-ano", "-p", "TCP"],
check=False,
capture_output=True,
text=True,
encoding=locale.getpreferredencoding(False),
errors="replace",
)
except Exception:
return
needle = f":{port} "
for line in (result.stdout or "").splitlines():
if needle not in line or "LISTENING" not in line:
continue
pid = line.split()[-1]
if pid.isdigit() and int(pid) != os.getpid():
logger.info("[Frontend] 포트 %d를 물고 있던 잔여 프로세스 %s 정리", port, pid)
_kill_process_tree(int(pid))
def serve_frontend_dev() -> None:
"""프론트엔드 개발 서버 실행 (npm run dev from project root) — 선택 사항.
포트를 **고정**(`--strictPort`)한다. 예전에는 포트가 비어 있지 않으면 Vite가 조용히
옆 포트로 올라갔고, 그렇게 뜬 옛 인스턴스가 계속 살아남아 하루치 변경이 화면에 반영되지
않는 일이 있었다. 고정해 두면 포트가 막혀 있을 때 그 자리에서 드러난다.
"""
global _frontend_dev_process
root_dir = Path(__file__).resolve().parent
if ENVIRONMENT == "production":
logger.info("[Frontend] 프로덕션 환경: 개발 서버 실행 스킵")
return
_free_dev_port(FRONTEND_DEV_PORT)
logger.info("[Frontend] npm run dev 시작 (포트 %d 고정, 백그라운드)...", FRONTEND_DEV_PORT)
try:
_frontend_dev_process = subprocess.Popen(
f"npm run dev -- --host --port {FRONTEND_DEV_PORT} --strictPort",
shell=True,
cwd=str(root_dir),
env=_frontend_process_env(root_dir),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
# POSIX에서 자식까지 한 번에 끝내려면 프로세스 그룹을 따로 잡아야 한다.
start_new_session=os.name != "nt",
)
logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행 (pid %s)", _frontend_dev_process.pid)
except Exception as e:
logger.warning(f"⚠ 프론트엔드 개발 서버 실행 실패: {e}")
def stop_frontend_dev() -> None:
"""앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다.
⚠⚠ **개발 중에는 내리지 않는다** (2026-09-12 실측으로 드러난 고리).
uvicorn 리로더는 `*.py` 가 바뀔 때마다 앱 프로세스를 갈아 끼운다. 그때 여기서
Vite 를 죽이면 **새 프로세스는 「Vite 가 없다」고 보고 `npm run build` 를 처음부터
다시 돌린다**(최대 300초 + B07 CAD). 그 사이 화면은 뜨지 않고 `/api/health` 는
`stale: true` 인 채로 옛 코드를 계속 내준다 — 그날 리로드가 두 번 멈췄고 한 번은
두 시간 반 동안 안 끝났다. **옛 화면을 보고 헛검증하게 만드는 자리다.**
⇒ Vite 는 **한 앱 프로세스가 아니라 개발 세션의 것**이다. 살려 두고 다음 기동이
그대로 물려쓴다(`_dev_port_listening`). 남은 유령은 다음 기동의
`_free_dev_port` 가 포트로 잡아 정리하므로 쌓이지 않는다.
"""
global _frontend_dev_process
if ENVIRONMENT == "development":
logger.info("[Frontend] 개발 서버는 살려 둔다 — 다음 기동이 그대로 물려쓴다")
return
if _frontend_dev_process is None:
return
logger.info("[Frontend] 개발 서버 종료 (pid %s)", _frontend_dev_process.pid)
_kill_process_tree(_frontend_dev_process.pid)
_frontend_dev_process = None
# 셸을 거쳐 띄우므로 손자 프로세스가 남을 수 있다. 포트로 한 번 더 확인해 정리한다.
_free_dev_port(FRONTEND_DEV_PORT)
async def cleanup_expired_sessions() -> None:
"""매시간 만료된 세션과 사용 완료 OTP를 정리한다."""
@@ -385,7 +209,7 @@ async def lifespan(app: FastAPI):
_CODE_MTIME_AT_START = _newest_source_mtime()
logger.info(
"[reload] 감시 폴더 %d개, 소스 최신 수정 %s",
len(_reload_dirs()),
len(reload_dirs()),
datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"),
)
@@ -396,7 +220,7 @@ async def lifespan(app: FastAPI):
# 안 끝나, `/api/health` 가 `stale: true` 인 채로 옛 코드를 계속 내주었다.
# ⇒ **옛 화면을 보고 헛검증하게 만드는 자리라 그냥 느린 것이 아니다.**
# 개발 중에는 Vite 가 HMR 로 이미 최신을 내주므로 다시 빌드할 까닭이 없다.
if ENVIRONMENT == "development" and _dev_port_listening(FRONTEND_DEV_PORT):
if ENVIRONMENT == "development" and dev_port_listening(FRONTEND_DEV_PORT):
logger.info(
"[Frontend] 개발 서버가 %d 포트에 이미 살아 있음 — 빌드·재기동 건너뜀(리로드)",
FRONTEND_DEV_PORT,
@@ -571,7 +395,7 @@ async def root():
#: 이 프로세스가 뜬 시각과, 뜰 때 본 **가장 최근 소스 수정 시각**. 기동 때 채운다
#: (`_reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다).
#: (`reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다).
#: 자동 리로드가 조용히 죽는 일이 잦아(오늘만 세 번, 매번 틀린 결론을 냄) 「지금 도는 서버가
#: 옛 코드인가」를 한 번에 가릴 수 있게 남긴다(2026-09-07, PLAN 7-2).
_STARTED_AT = 0.0
@@ -581,7 +405,7 @@ _CODE_MTIME_AT_START = 0.0
def _newest_source_mtime() -> float:
"""감시 대상 폴더의 `.py` 중 가장 최근 수정 시각. 못 읽으면 0."""
newest = 0.0
for folder in _reload_dirs():
for folder in reload_dirs():
for path in Path(folder).rglob("*.py"):
try:
newest = max(newest, path.stat().st_mtime)
@@ -696,104 +520,13 @@ app.include_router(help_router, dependencies=protected)
# ─────────────────────────────────────────────────────────────────────────
# 앱 실행
# ─────────────────────────────────────────────────────────────────────────
def _reload_dirs() -> list[str]:
"""자동 리로드가 **훑을 폴더만** 골라 준다 — 폴더째 주면 서버가 쉬는 중에도 CPU를 먹는다.
uvicorn 의 StatReload 는 0.25초마다 지정 폴더를 통째로 훑는다. 아무것도 안 주면
현재 폴더가 대상이 되는데, 여기에는 `storage`(27GB · 프로젝트 산출물)와 `tmp`·`venv`·
`node_modules` 가 함께 들어 있다. 그래서 **요청이 하나도 없는 서버가 CPU 70%** 를 물고
있었고, 창 둘이 각각 그러니 기기가 늘 140% 눌린 채였다(2026-09-07 실측). 속도 계측이
회차마다 3배씩 흔들린 원인도 이것이다.
파이썬이 실제로 사는 폴더만 준다 — 화면 코드(TS)는 vite 가 따로 본다.
"""
root = Path(__file__).resolve().parent
# ⚠ 루트와 `config` 는 넣지 않는다 — uvicorn 은 준 폴더를 `rglob("*.py")` 로 **재귀**로
# 훑는다. 루트를 주면 storage·venv(파이썬 파일만 7,530개)를 도로 다 훑고, `config` 안에는
# `node_modules` 가 들어 있어 역시 무겁다. 그 대신 **`main.py` 와 `config/` 를 고칠 때는
# 서버를 손으로 다시 띄울 것** (둘 다 자주 고치는 자리가 아니다).
watched: list[str] = []
for entry in sorted(root.iterdir()):
if not entry.is_dir() or entry.name.startswith(".") or entry.name in _RELOAD_SKIP:
continue
# 링크(정션)는 통째로 건너뛴다. `node_modules` 안에 **저장소 루트를 되가리키는 정션**이
# 있어(`B07_DesignDetail/openwebcad/node_modules/forest-road-webapp` → 저장소 루트,
# npm 이 만든 것) 리로더가 그리로 되돌아가 `venv`·`storage` 를 다시 훑고 또 되감겼다.
# 실측(2026-09-07) — 감시 폴더를 훑으면 파일 **486,809개**, 한 바퀴 **1,045초**.
# 파이썬 `rglob` 은 정션을 따라 들어가고 PowerShell 은 안 따라가 눈으로는 안 보였다.
if entry.is_symlink():
continue
# 의존성 트리가 **한 겹 아래에 숨어 있는** 경우도 뺀다 — 위 정션이 그 안에 있었다.
if (entry / "node_modules").is_dir() or any(entry.glob("*/node_modules")):
continue
if any(entry.rglob("*.py")):
watched.append(str(entry))
return watched
# 파이썬이 있어도 훑지 않을 폴더 — 산출물·의존성·백업.
_RELOAD_SKIP = {
"storage",
"tmp",
"venv",
"node_modules",
"__pycache__",
"log",
"docs",
"resources",
"scratch",
"0_old",
"db_management",
"migrations",
"ui_template",
}
def _patch_windows_reload() -> None:
"""윈도우에서 uvicorn 리로더가 **영원히 멈추는 것**을 고친다 (2026-09-12 실측).
무엇이 잘못됐나
uvicorn 0.24 의 `BaseReload.restart()` 는 윈도우에서만 옛 워커를
`os.kill(worker_pid, CTRL_C_EVENT)` 로 내리려 한다. 그런데 CTRL_C_EVENT 는
**프로세스 그룹을 지정해 보낼 수 없다** — MSDN 이 「그룹 id 가 0 이 아니면 호출은
성공하지만 신호는 전달되지 않는다」고 못박아 두었다. 워커는 리로더의 평범한
자식이라 그룹 우두머리가 아니므로 그 신호는 **아무 데도 가지 않는다.**
바로 다음 줄 `join()` 이 영원히 안 풀려서 —
· 옛 워커는 살아서 **옛 코드로 요청을 계속 처리**하고
· 새 워커는 태어나지 않으며
· 그 뒤의 파일 변경은 **감지조차 안 된다**(리로더가 그 자리에 갇혀 있다)
「고쳤는데 화면이 안 바뀐다」로 보이는 자리라 그냥 느린 것이 아니다.
실측 (그 자리를 그대로 흉내 낸 부모·자식 한 쌍)
CTRL_C_EVENT → **8초 안에 안 풀림** (DETACHED · CREATE_NO_WINDOW ·
CREATE_NO_WINDOW+NEWGROUP 셋 모두) · `terminate()` → **0.00초** ·
CTRL_BREAK_EVENT(자식을 그룹 우두머리로) → **0.00초**
⇒ uvicorn 이 **리눅스에서 이미 쓰는** `terminate()` 가 윈도우에서도 즉시 먹는다.
그래서 여기서는 그 리눅스 경로를 그대로 쓴다(새 방식을 지어내지 않는다).
⚠ 개발 기동(`DEBUG`)에서만 갈아 끼운다. uvicorn 이 위쪽에서 고치면 **이 함수를 지우면
된다** — 다른 자리는 아무것도 안 건드린다.
"""
from uvicorn._subprocess import get_subprocess
from uvicorn.supervisors.basereload import BaseReload
def restart(self: BaseReload) -> None:
self.process.terminate()
self.process.join()
self.process = get_subprocess(config=self.config, target=self.target, sockets=self.sockets)
self.process.start()
BaseReload.restart = restart
logger.info("[reload] 윈도우 리로더를 terminate() 방식으로 갈아 끼움")
if __name__ == "__main__":
import sys
import uvicorn
if DEBUG and sys.platform == "win32":
_patch_windows_reload()
patch_windows_reload()
logger.info(f"Starting server on {SERVER_HOST}:{SERVER_PORT}")
uvicorn.run(
@@ -801,7 +534,8 @@ if __name__ == "__main__":
host=SERVER_HOST,
port=SERVER_PORT,
reload=DEBUG,
# 폴더를 좁히지 않으면 리로더가 storage(27GB)까지 0.25초마다 훑는다 — 위 주석 참조.
reload_dirs=_reload_dirs() if DEBUG else None,
# 폴더를 좁히지 않으면 storage(27GB)까지 훑는다 — config_dev_server 참조.
reload_dirs=reload_dirs() if DEBUG else None,
reload_excludes=reload_excludes() if DEBUG else None,
access_log=False,
)
+77
View File
@@ -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"]);
});
});
+106
View File
@@ -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));
})();
+2 -2
View File
@@ -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,217 @@
"""PLAN 505-3 화면 — 자기 포트(5179) Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub4.
- 종단 측점선 · 원지반선 겹친 자리 누름 = 측점선이 받음(지반선 누름 통과) → 임시 횡단 창 = B06 카드 + 「⚠ 배관 관경 빈칸」 칩
- 관경 800(세션만 · [저장] 안 함) → 창 카드에 관 형상 · Ctrl+휠 확대가 창 칸 밖으로 안 넘침 · 밝음 · 어두움 한 장씩
- 곡선 판 = 도구줄 아래 · 열린 횡단 판 왼쪽(판을 나중에 열어도 비킴)
- 계정 화면 취향(종단 · 횡단 판 열림 · 높이)만 시험 앞 값으로 되돌림
./venv/Scripts/python.exe -m pytest resources/tester/test_505_3_cross_card_screen.py -q
"""
from __future__ import annotations
import json
import urllib.request
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
BASE = "http://localhost:5179"
PROJECT = "c725d747-50ae-46b0-87ce-4d4e56a53cf3" # 검증_sub4(OWNERS.md)
SHOTS = ROOT / "tmp" / "shots_505_3"
PIPE_M = 388 # 검증_sub4 배관 하나(관경 빈칸 · 19+8)
PREFS = "/api/dashboard/me/ui-prefs"
# 이 시험이 건드리는 취향만 되돌림 — 계정을 다른 창도 같이 써서 통째로 덮으면 남의 값이 사라짐
TOUCHED = ("profile-collapsed", "profile-height", "b05-routeedit-cross-v2")
def _up() -> bool:
try:
urllib.request.urlopen(BASE, timeout=3)
return True
except Exception:
return False
pytestmark = pytest.mark.skipif(not _up(), reason="5179 서버 없음")
# 측점 m 을 가로 가운데로 · 그 측점선과 원지반선 교점(화면 좌표) · 그 자리 맨 위 요소
JS_SCROLL = r"""(m) => {
const marker = [...document.querySelectorAll('.b06-chart__station')]
.find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6);
const body = marker.closest('.b05-route-profile__body');
body.scrollLeft = Math.max(0, +marker.querySelector('line').getAttribute('x1') - body.clientWidth / 2);
}"""
JS_POINT = r"""(m) => {
const marker = [...document.querySelectorAll('.b06-chart__station')]
.find((e) => Math.abs(+e.getAttribute('data-chainage') - m) < 1e-6);
const line = marker.querySelector('.b06-chart__station-line');
const svg = line.ownerSVGElement;
const x = +line.getAttribute('x1');
const poly = svg.querySelector('.b06-chart__profile');
const pts = poly.getAttribute('points').trim().split(/\s+/).map((s) => s.split(',').map(Number));
const k = Math.max(1, pts.findIndex((p) => p[0] >= x));
const [a, b] = [pts[k - 1], pts[k]];
const pt = svg.createSVGPoint();
pt.x = x;
pt.y = a[1] + ((b[1] - a[1]) * (x - a[0])) / (b[0] - a[0] || 1);
const s = pt.matrixTransform(poly.getScreenCTM());
return { x: s.x, y: s.y, hit: document.elementFromPoint(s.x, s.y)?.getAttribute('class') };
}"""
JS_MODAL = r"""() => {
const r = document.querySelector('.b05-crossmodal');
const card = r.querySelector('.b06-cross-card');
const box = r.querySelector('.b05-crossmodal__box').getBoundingClientRect();
const svg = card?.querySelector('svg').getBoundingClientRect();
return {
station: r.querySelector('.b05-crossmodal__station').textContent,
card: !!card,
chips: [...r.querySelectorAll('.b05-cross-chips > *')].map((e) => e.textContent),
pipes: card ? card.querySelectorAll('.b06-chart__culvert-pipe').length : 0,
inside: !!svg && svg.top >= box.top && svg.bottom <= box.bottom + 1 && svg.right <= box.right + 1,
width: Math.round(r.getBoundingClientRect().width),
buttons: [...r.querySelectorAll('.b05-crossmodal__actions button')].map((e) => e.className),
scale: [...(card?.querySelectorAll('svg g[transform]') ?? [])]
.map((g) => g.getAttribute('transform')).find((t) => /^translate\([^)]*\) scale\(/.test(t)) ?? '',
};
}"""
JS_RECTS = r"""() => {
const q = (s) => [...document.querySelectorAll(s)].find((x) => x.getClientRects().length);
const r = (s) => { const o = q(s)?.getBoundingClientRect(); return o ? [o.left, o.top, o.right, o.bottom] : null; };
return { label: r('.b05-routeedit__label'), tools: r('.b05-routeedit__tools'), side: r('.b05-routeedit__side-wrap') };
}"""
@pytest.fixture(scope="module")
def page():
sync = pytest.importorskip("playwright.sync_api")
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
with sync.sync_playwright() as play:
chrome = play.chromium.launch(channel="msedge", headless=True)
ctx = chrome.new_context(viewport={"width": 1600, "height": 1000})
login = {k: cred[k] for k in ("email", "password")}
assert ctx.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok
prefs = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {})
tab = ctx.new_page()
tab.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')")
tab.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded")
tab.wait_for_function("() => !!window.__aisloPipeline", timeout=90000)
tab.wait_for_timeout(8000)
SHOTS.mkdir(parents=True, exist_ok=True)
try:
yield tab
finally:
tab.wait_for_timeout(2000) # 취향 올리기(1.5 s 모음) 끝난 뒤 되돌림
now = ctx.request.get(f"{BASE}{PREFS}").json().get("prefs", {})
for key in (f"aislo:pref:{name}" for name in TOUCHED):
if key in prefs:
now[key] = prefs[key]
else:
now.pop(key, None)
assert ctx.request.put(f"{BASE}{PREFS}", data={"prefs": now}).ok
chrome.close()
def _profile(page, open_: bool) -> None:
height = page.evaluate(
"() => document.querySelector('.b05-route-profile')?.getBoundingClientRect().height ?? 0"
)
if (height > 100) != open_: # 접힘 = 높이 1 px(측점 요소는 DOM 에 남음)
page.get_by_text("종단", exact=True).last.click()
page.wait_for_timeout(3000)
def _open_modal(page) -> dict:
_profile(page, True)
page.evaluate(JS_SCROLL, PIPE_M)
page.wait_for_timeout(800)
at = page.evaluate(JS_POINT, PIPE_M)
assert "b06-chart__station-line" in (at["hit"] or ""), at # 지반선이 아니라 측점선이 받음
page.mouse.move(at["x"], at["y"])
page.wait_for_timeout(300)
page.mouse.click(at["x"], at["y"])
page.wait_for_function(
"() => document.querySelector('.b05-crossmodal .b06-cross-card')", timeout=30000
)
page.wait_for_timeout(1500)
return page.evaluate(JS_MODAL)
def test_0_곡선_판_도구줄_횡단판_비킴(page):
_profile(page, False)
rects = page.evaluate(JS_RECTS)
if rects["side"][2] - rects["side"][0] > 100: # 횡단 판 접고 시작
page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click()
page.wait_for_timeout(2000)
shown = "() => [...document.querySelectorAll('.b05-routeedit__label')].some((x) => !x.hidden)"
for _ in range(3): # 첫 누름이 앞 고름(구조물) 풂으로 갈 수 있음
page.mouse.click(1199, 296) # 검증_sub4 첫 화면의 14번째 꺾임점
page.wait_for_timeout(1500)
if page.evaluate(shown):
break
page.screenshot(path=str(SHOTS / "curve_pick.png"))
assert page.evaluate(shown)
# 지도를 위 · 오른쪽으로 밀어 꺾임점을 도구줄 · 오른쪽 끝 밑으로
page.mouse.move(800, 650)
page.mouse.down(button="middle")
page.mouse.move(1050, 380, steps=16)
page.mouse.up(button="middle")
page.wait_for_timeout(1200)
rects = page.evaluate(JS_RECTS)
assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5
page.locator(".b05-routeedit__side-wrap").get_by_text("횡단").first.click()
page.wait_for_timeout(2500)
rects = page.evaluate(JS_RECTS)
assert rects["label"][2] <= rects["side"][0] - 8 + 0.5 # 판을 나중에 열어도 비킴
assert rects["label"][1] >= rects["tools"][3] + 8 - 0.5
page.screenshot(path=str(SHOTS / "curve_light.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
page.screenshot(path=str(SHOTS / "curve_dark.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
page.keyboard.press("Escape") # 꺾임점 고름 풂
def test_1_빈칸_칩_카드(page):
modal = _open_modal(page)
assert modal["station"] == "19+8" and modal["card"]
assert modal["chips"] == ["⚠ 배관 관경 빈칸"]
assert modal["pipes"] == 0 and modal["width"] == 380
assert all("ui-btn ui-btn--ghost ui-btn--sm" in name for name in modal["buttons"])
page.locator(".b05-crossmodal").get_by_text("닫기").click()
def test_2_횡단_판_칩_관경_넣으면_형상_확대_밝음_어두움(page):
_profile(page, False)
page.locator(".b05-route__irregular-item", has_text="19+8").first.click()
page.wait_for_timeout(3000)
chips = page.eval_on_selector_all(
".b05-routeedit__cross .b05-cross-chips > *", "(els) => els.map((e) => e.textContent)"
)
assert "⚠ 배관 관경 빈칸" in chips # 「횡단」 판 = 카드 머리를 걷어도 칩은 그래프 오른쪽 위
page.locator(".b05-routeedit__cross").first.screenshot(path=str(SHOTS / "panel_chip.png"))
if not page.locator("select:visible").filter(has=page.locator("option[value='800']")).count():
page.get_by_text("구조물 배치", exact=True).click()
page.wait_for_timeout(1500)
page.locator("select:visible").filter(
has=page.locator("option[value='800']")
).first.select_option("800") # 세션 초안만 — [저장] 안 누름
page.wait_for_timeout(5000)
modal = _open_modal(page)
assert modal["chips"] == [] and modal["pipes"] > 0
shot = page.locator(".b05-crossmodal")
shot.screenshot(path=str(SHOTS / "modal_light.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
shot.screenshot(path=str(SHOTS / "modal_dark.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
box = page.locator(".b05-crossmodal__box").bounding_box()
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
page.evaluate(
"([x, y]) => document.elementFromPoint(x, y).dispatchEvent(new WheelEvent('wheel',"
" {deltaY: 300, ctrlKey: true, clientX: x, clientY: y, bubbles: true, cancelable: true}))",
[cx, cy],
)
page.wait_for_timeout(800)
zoomed = page.evaluate(JS_MODAL)
assert "scale(1.15)" in zoomed["scale"] and zoomed["inside"]
page.locator(".b05-crossmodal").get_by_text("닫기").click()
@@ -0,0 +1,84 @@
"""505-3 B05 그림 · 글 — 임시 횡단 창 = B06 카드(형상 얹은 단면) · 빈칸 칩 그래프 오른쪽 위 · 곡선 판 가둠 ·
지반선 · 계획선 누름 통과 · STALE 글 · 창 단추 공용 꼴.
./venv/Scripts/python.exe -m pytest resources/tester/test_505_3_cross_card_text.py -q
"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _read(*parts: str) -> str:
return ROOT.joinpath(*parts).read_text(encoding="utf-8")
def test_측점_뜨기는_형상_얹은_단면도_돌려줌() -> None:
probe = _read("A00_Common", "design", "design_pipeline_probe.ts")
assert "Promise<{ preview: CrossPreviewResponse; section: CrossSection } | null>" in probe
assert "const section = stationSection(station, cross, {}, intervalM);" in probe
assert "attachStatus([section], status);" in probe
detail = _read("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts")
assert "export function attachStatus(sections: CrossSection[], status: ShapeStatus)" in detail
def test_임시_횡단_창은_B06_카드() -> None:
modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts")
assert "crossSourceOf(" in modal and "routeCrossCard(source," in modal
assert "now ? [now.preview] : await previewStructureSections(projectId(), [m])" in modal
# 서버 미리 뜬 것(분석 지형 다름)만 선 그림
assert "buildCrossSvg(preview, width, height)" in modal
assert 'class="ui-button"' not in modal
assert modal.count('class="ui-btn ui-btn--ghost ui-btn--sm" data-act=') == 3
css = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.css")
assert ".b05-crossmodal__box > .b06-cross-card {" in css
def test_판_재료_한_벌() -> None:
card = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts")
assert (
"return crossSourceOf(projectId, found, sections, detail.longitudinal.design_profiles, pane);"
in card
)
assert card.count("export function crossSourceOf(") == 1
def test_빈칸_칩은_머리_걷기_전에_꺼냄() -> None:
card = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts")
chips = card.index('":scope > header .b06-cross-card__warning"')
remove = card.index(':scope > header, :scope > footer, .b06-cross-card__zoom"')
assert chips < remove
assert 'box.className = "b05-cross-chips";' in card
css = _read("B05_Profile", "B05_Profile_UI_Style_RouteEdit.css")
rule = css[css.index(".b05-cross-chips {") :]
assert "top: 4px;" in rule[:200] and "right: 4px;" in rule[:200]
assert ".b06-cross-card:has(> .b05-cross-chips) {\n position: relative;" in css
def test_곡선_판_가둠_도구줄_횡단판_종단판() -> None:
bar = _read("B05_Profile", "B05_Profile_UI_RouteEdit_CurveBar.ts")
for selector in (
".b05-routeedit__tools",
".b05-routeedit__side-wrap",
".b05-route-profile",
):
assert f'box("{selector}")' in bar
assert "top: Math.max(rect.top, tools?.bottom ?? rect.top) + 8," in bar
assert "right: Math.min(rect.right, side?.left ?? rect.right) - 8," in bar
assert "bottom: Math.min(rect.bottom, profile?.top ?? rect.bottom) - 8," in bar
def test_지반선_계획선_누름_통과() -> None:
css = _read("B06_Section", "B06_Section_UI_Style_Cross.css")
assert ".b06-chart__profile,\n.b06-chart__design-profile {\n pointer-events: none;" in css
def test_STALE_글_앞_단계() -> None:
locale = _read("ui_template", "ui_template_locale_common.ts")
assert (
'WF_State_Stale: ["다시 확인 필요 (앞 단계 변경)", "Stale (an earlier step changed)"],'
in locale
)
assert "하위 단계" not in locale
@@ -0,0 +1,42 @@
"""506-1 — 개발 서버 다시 읽기 범위: 시험·tmp .py 는 제외, 모듈 폴더 .py 는 읽음."""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from uvicorn.config import Config # noqa: E402
from uvicorn.supervisors.watchfilesreload import FileFilter # noqa: E402
from config.config_dev_server import RELOAD_SKIP, reload_dirs, reload_excludes # noqa: E402
def _filter() -> FileFilter:
cfg = Config(
"main:app", reload=True, reload_dirs=reload_dirs(), reload_excludes=reload_excludes()
)
return FileFilter(cfg)
def test_제외_목록은_절대_경로_실존_폴더():
ex = reload_excludes()
assert ex and all(Path(e).is_absolute() and Path(e).is_dir() for e in ex)
names = {Path(e).name for e in ex}
assert {"resources", "tmp", "venv", "docs", ".claude"} <= names
assert names - {n for n in names if n.startswith(".")} <= RELOAD_SKIP
def test_감시_폴더는_제외_폴더와_안_겹치고_모듈_폴더_15개():
dirs = reload_dirs()
assert len(dirs) == 15
skip = {Path(e) for e in reload_excludes()}
assert not skip & {Path(d) for d in dirs}
def test_필터_시험_tmp는_안_읽고_모듈_main_config는_읽음():
f = _filter()
for rel in ("resources/tester/x.py", "tmp/x.py", "docs/x.py", ".claude/x.py", "venv/Lib/x.py"):
assert not f(ROOT / rel), rel
for rel in ("B05_Profile/x.py", "B07_DesignDetail/x.py", "main.py", "config/config_db.py"):
assert f(ROOT / rel), rel
+266
View File
@@ -0,0 +1,266 @@
# -*- coding: utf-8 -*-
"""506-3 화면 — 자기 포트(5177) Playwright 머리 없는 브라우저 · 검증_sub2 · `AISLO_LIVE_DB=1` 일 때만.
- B07 표지에 선 하나 → CAD 자동백업 열쇠 = `OPEN_WEB_CAD__RECOVERY__b07:{프로젝트}:cover`
- 특성 판 색 칸 다섯 번 끌기 → 되돌리기 한 번 = 처음 색
- 미리받기가 도는 중 [저장] → 「저장했습니다」 · 머묾 글 없음 · 저장 동안 새 미리받기 GET 없음
- [초기화] 물음 글 = 프로젝트 도각 안내(밝음 · 어두움 한 장씩 · tmp/shots_506_3) → [취소]
- B09 [인쇄 미리보기] = PUT /estimation 뒤 #/b09-print · complete 안 부름
- 단계 행 · B07 · B09 storage 를 먼저 떠 두고 끝에 되돌림
AISLO_LIVE_DB=1 ./venv/Scripts/python.exe -m pytest resources/tester/test_506_3_b07_browser.py -q -s
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import sys
import time
import urllib.request
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
BASE = "http://localhost:5177"
PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md)
SHOTS = ROOT / "tmp" / "shots_506_3"
COLS = (
"state",
"progress_percent",
"params",
"message",
"started_at",
"completed_at",
"updated_at",
)
def _up() -> bool:
try:
urllib.request.urlopen(BASE, timeout=3)
return True
except Exception:
return False
pytestmark = pytest.mark.skipif(
os.environ.get("AISLO_LIVE_DB") != "1" or not _up(),
reason="AISLO_LIVE_DB=1 · 5177 서버 있을 때만(검증_sub2 storage · 단계 행을 씀)",
)
async def _keep(mode: str, held: dict) -> None:
"""단계 행 · projects 상태 · B07 · B09 storage 떠 두기(save) · 되돌리기(restore)."""
import aiomysql
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
conn = await aiomysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME,
charset="utf8mb4", autocommit=False,
) # fmt: skip
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
if mode == "save":
await cur.execute("SELECT storage_path FROM projects WHERE id=%s", (PROJECT,))
root = Path(resolve_stored_project_path((await cur.fetchone())["storage_path"]))
await cur.execute(
"SELECT * FROM project_workflow_stages WHERE project_id=%s", (PROJECT,)
)
held["stages"] = await cur.fetchall()
await cur.execute("SELECT status, updated_at FROM projects WHERE id=%s", (PROJECT,))
held["project"] = await cur.fetchone()
held["copy"] = ROOT / "tmp" / f"pt_506_3_keep_{int(time.time())}"
held["dirs"] = [
d for d in root.iterdir() if d.is_dir() and d.name.startswith(("B07", "B09"))
]
for d in held["dirs"]:
shutil.copytree(d, held["copy"] / d.name)
return
for row in held["stages"]:
await cur.execute(
f"UPDATE project_workflow_stages SET {', '.join(f'{k}=%s' for k in COLS)} "
"WHERE id=%s",
(*[row[k] for k in COLS], row["id"]),
)
project = held["project"]
await cur.execute(
"UPDATE projects SET status=%s, updated_at=%s WHERE id=%s",
(project["status"], project["updated_at"], PROJECT),
)
await conn.commit()
for d in held["dirs"]:
shutil.rmtree(d)
shutil.copytree(held["copy"] / d.name, d)
finally:
conn.close()
@pytest.fixture(scope="module")
def ctx():
sync = pytest.importorskip("playwright.sync_api")
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
held: dict = {}
asyncio.run(_keep("save", held))
try:
with sync.sync_playwright() as play:
chrome = play.chromium.launch(channel="msedge", headless=True)
context = chrome.new_context(viewport={"width": 1600, "height": 950})
login = {k: cred[k] for k in ("email", "password")}
r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000)
assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음
context.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')")
yield context
chrome.close()
finally:
asyncio.run(_keep("restore", held))
def _cad(page):
return next(f for f in page.frames if "/b07-cad/" in f.url)
def _type(page, *lines: str) -> None:
for line in lines:
page.keyboard.type(line)
page.keyboard.press("Enter")
def test_b07_저장_길_화면(ctx):
page = ctx.new_page()
gets: list[tuple[float, str]] = []
puts: list[tuple[float, float]] = []
page.on(
"request",
lambda q: (
q.method == "GET"
and "/design-drawings/" in q.url
and gets.append((time.time(), q.url.rsplit("/", 1)[-1]))
),
)
starts: dict = {}
page.on(
"request",
lambda q: q.method == "PUT" and "/confirm" in q.url and starts.update(t=time.time()),
)
page.on(
"requestfinished",
lambda q: (
q.method == "PUT" and "/confirm" in q.url and puts.append((starts["t"], time.time()))
),
)
page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded")
page.wait_for_selector(
'.b07-drawing-button[data-drawing-id="cover"][data-active="true"]', timeout=90000
)
page.wait_for_selector(".cad-host[data-loading='false']", timeout=90000)
cad = _cad(page)
# 선 하나 — 표지 미저장 · 자동백업
cad.locator("canvas").first.click(position={"x": 900, "y": 600})
page.keyboard.press("Escape")
_type(page, "l", "100,100", "400,100")
page.keyboard.press("Escape")
page.keyboard.press("Control+a")
cad.wait_for_timeout(300)
# 특성 판 색 다섯 번 끌기 → 되돌리기 한 번
if cad.locator(".cad-inspector[data-collapsed='true']").count():
cad.locator(".cad-inspector__collapse").click()
cad.locator(".cad-inspector-tabs button", has_text="특성").click()
color = cad.locator(".cad-properties-editor input[type=color]")
color.wait_for(timeout=10000)
first = color.input_value()
for value in ("#110000", "#220000", "#330000", "#440000", "#550000"):
color.evaluate(
"(el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')"
".set.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); }",
value,
)
cad.wait_for_timeout(60)
painted = color.input_value()
cad.wait_for_timeout(600) # 멎음 → 한 칸
cad.locator("canvas").first.click(position={"x": 600, "y": 60}) # 초점 = 캔버스(고름은 다시)
page.keyboard.press("Control+z")
page.keyboard.press("Control+a")
cad.wait_for_timeout(300)
undone = color.input_value()
print(f"색 처음 {first} · 끈 뒤 {painted} · 되돌리기 한 번 {undone}")
assert painted == "#550000" and undone == first
# 자동백업 열쇠(적재 뒤 5 s 쉼 + 5 s 미룸)
cad.wait_for_timeout(11000)
keys = cad.evaluate(
"Object.keys(localStorage).filter(k => k.startsWith('OPEN_WEB_CAD__RECOVERY'))"
)
print("자동백업 열쇠", keys)
assert f"OPEN_WEB_CAD__RECOVERY__b07:{PROJECT}:cover" in keys
assert "OPEN_WEB_CAD__RECOVERY__cover" not in keys
# 미리받기 도는 중 [저장]
before = len(gets)
page.get_by_role("button", name="저장", exact=True).click()
toast = page.wait_for_selector(
".ui-toast:has-text('저장했습니다'), .ui-toast:has-text('머묾')", timeout=90000
).inner_text()
page.wait_for_function(
"() => document.querySelector('.b07-drawing-button[data-drawing-id=cover]')?.dataset.confirmed === 'true'",
timeout=90000,
)
put_start, put_end = puts[-1]
during = [name for t, name in gets if put_start < t < put_end]
print(
f"[저장] 앞 미리받기 GET {before} · PUT {put_end - put_start:.2f} s · 알림 「{toast}」 · 저장 중 새 GET {during}"
)
assert before > 1 # 미리받기 도는 중
assert "머묾" not in toast and "저장했습니다" in toast
assert during == []
# [초기화] 물음 글 → [취소]
page.get_by_role("button", name="초기화", exact=True).click()
message = page.wait_for_selector(".ui-confirm__message", timeout=10000)
text = message.inner_text()
print("초기화 물음", text)
assert "프로젝트 도각은 그대로" in text and "[기본 도각 복원]" in text
SHOTS.mkdir(parents=True, exist_ok=True)
panel = page.locator(".ui-confirm__panel")
panel.screenshot(path=str(SHOTS / "reset_light.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
panel.screenshot(path=str(SHOTS / "reset_dark.png"))
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
page.locator(".ui-confirm__actions button", has_text="취소").click()
page.close()
def test_b09_인쇄_미리보기_저장_뒤_이동(ctx):
page = ctx.new_page()
calls: list[str] = []
page.on(
"request",
lambda q: (
"/estimation" in q.url
and q.method != "GET"
and calls.append(f"{q.method} {q.url.split('/estimation')[-1] or '/'}")
),
)
page.goto(f"{BASE}/#/b09-estimation", wait_until="domcontentloaded")
button = page.wait_for_selector("[data-b09-print]", timeout=90000)
page.wait_for_timeout(6000) # 원가 자료 받기
user = ctx.request.get(f"{BASE}/api/projects/{PROJECT}/estimation").json().get("user") or {}
# 고친 값 있음 표 — 지금 서버 값 그대로(PUT 이 같은 값을 씀)
page.evaluate(
"([k, v]) => sessionStorage.setItem(k, v)",
[f"aislo:draft:b09-estimation:{PROJECT}", json.dumps(user, ensure_ascii=False)],
)
button.click()
page.wait_for_url("**/#/b09-print", timeout=60000)
print("B09 [인쇄 미리보기] 요청", calls)
assert calls[:1] == ["PUT /"]
assert not any("complete" in call for call in calls)
page.close()
@@ -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
-6
View File
@@ -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) {
-26
View File
@@ -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"],
+1 -1
View File
@@ -85,7 +85,7 @@ export const ui_locales_common = {
WF_Step_DesignDetail: ["상세설계", "Detail Design"],
WF_Step_Quantity: ["수량산출", "Quantity"],
WF_Step_Estimation: ["원가계산", "Cost Estimate"],
WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"],
WF_State_Stale: ["다시 확인 필요 (앞 단계 변경)", "Stale (an earlier step changed)"],
WF_State_Failed: ["실패", "Failed"],
WF_State_Complete: ["완료", "Complete"],
WF_State_InProgress: ["진행 중", "In Progress"],