Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -136,6 +136,14 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
|
||||
migratedRoutes.add(migrateKey);
|
||||
void migrateLegacyStations(projectId, routeId)
|
||||
.then((result) => {
|
||||
const etc = result.legacy_etc ?? [];
|
||||
if (etc.length)
|
||||
showToast(
|
||||
`옛 측점 이관이 만든 「기타 구조물」 ${etc.length}곳(${etc.map((m) => `${m}m`).join(" · ")}) — ` +
|
||||
"측점 없는 구조물입니다. 필요 없으면 구조물 목록에서 지우세요.",
|
||||
"warning",
|
||||
8000,
|
||||
);
|
||||
if (result.migrated > 0) {
|
||||
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
|
||||
return deps
|
||||
|
||||
@@ -329,7 +329,13 @@ export async function saveStructures(
|
||||
export async function migrateLegacyStations(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<{ status: string; migrated: number; revision: number }> {
|
||||
): Promise<{
|
||||
status: string;
|
||||
migrated: number;
|
||||
revision: number;
|
||||
/** 옛 이관이 만든 「기타 구조물」 누가거리(지우지 않음 · 알림만 · 280-3). */
|
||||
legacy_etc?: number[];
|
||||
}> {
|
||||
return requestJson(`/projects/${projectId}/route/structures/migrate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ route_id: routeId }),
|
||||
|
||||
@@ -65,7 +65,9 @@ def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bo
|
||||
label = str(entry.get("structure", "")).strip()
|
||||
at = float(entry.get("chainage_m", 0.0))
|
||||
return (
|
||||
entry.get("origin") == "pipe"
|
||||
# 이름 빈 측점 = 추가 측점 정본(`plus_stations.json`) 소관 — 「기타」로 안 옮김(280-3)
|
||||
not label
|
||||
or entry.get("origin") == "pipe"
|
||||
or entry.get("structureType") == PIPE_STRUCTURE_NAME
|
||||
or label == PIPE_STRUCTURE_NAME
|
||||
or label in {item.name for item in structure_type_map().values()}
|
||||
|
||||
@@ -49,7 +49,7 @@ from common_util.common_util_route_follow import (
|
||||
route_outside,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_structure_station import plan_stations
|
||||
from common_util.common_util_structure_station import SAME_STATION_M, plan_stations
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
|
||||
|
||||
@@ -266,9 +266,10 @@ class StructureMigrateRequest(BaseModel):
|
||||
|
||||
async def _saved_irregular_stations(
|
||||
project_id: UUID, root: str, route_id: int | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천. 화면 상세는 초안
|
||||
(옮긴 관 자리)이 섞일 수 있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음)."""
|
||||
) -> tuple[list[dict[str, Any]], list[float]]:
|
||||
"""저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천 · 서 있는 구조물 측점
|
||||
누가거리(비정규 · 이름 단 격자 · 낡음 판정 280-3). 화면 상세는 초안(옮긴 관 자리)이 섞일 수
|
||||
있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음)."""
|
||||
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
||||
from B06_Section.B06_Section_Router_Design import resolve_longitudinal_path
|
||||
|
||||
@@ -283,21 +284,37 @@ async def _saved_irregular_stations(
|
||||
else None
|
||||
)
|
||||
if not row or not row.get("longitudinal_file_path"):
|
||||
return []
|
||||
return [], []
|
||||
try:
|
||||
path = resolve_longitudinal_path(Path(root), str(row["longitudinal_file_path"]))
|
||||
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return []
|
||||
return [
|
||||
return [], []
|
||||
stations = [station for station in stations if station.get("chainage_m") is not None]
|
||||
legacy = [
|
||||
{"chainage_m": station["chainage_m"], "structure": station.get("structure") or ""}
|
||||
for station in stations
|
||||
if station.get("kind") == "irregular" and station.get("chainage_m") is not None
|
||||
if station.get("kind") == "irregular"
|
||||
]
|
||||
standing = [
|
||||
float(station["chainage_m"])
|
||||
for station in stations
|
||||
if station.get("kind") == "irregular" or station.get("structure")
|
||||
]
|
||||
return legacy, standing
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/structures/migrate", response_model=None)
|
||||
async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse:
|
||||
"""구 비정규 측점 이관 — [저장]과 같은 프로젝트 잠금 안(280-3 · 다른 탭 저장 도중 낡은 종단으로
|
||||
옮기지 않음 · 몸통 `_migrate_structures`)."""
|
||||
from common_util import common_util_save_guard as guard
|
||||
|
||||
async with guard.project_lock(str(project_id)):
|
||||
return await _migrate_structures(project_id, payload)
|
||||
|
||||
|
||||
async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse:
|
||||
"""구 비정규 측점(자유 텍스트)을 구조물 정본으로 옮긴다. 멱등 — 이미 정본에 있는
|
||||
(타입, 위치)는 건너뛰고, 배관은 관 지점 정본 소관이라 옮기지 않는다
|
||||
(2026-08-17 컨테이너 병합 3단계, 매핑은 `B05_Profile_Structures_Migration` 정의)."""
|
||||
@@ -320,15 +337,38 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
|
||||
plus_chainages(root),
|
||||
)
|
||||
]
|
||||
legacy = await _saved_irregular_stations(project_id, root, payload.route_id)
|
||||
plan = migrate_irregular_stations(legacy, planted)
|
||||
legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id)
|
||||
|
||||
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
|
||||
# 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다
|
||||
# (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본).
|
||||
|
||||
def key_of(item: StructureInstance) -> str:
|
||||
return f"{item.type_id}@{round(item.anchor_m(), 3)}"
|
||||
|
||||
# 옛 이관이 만든 「기타 구조물」(이름 없는 측점 · 메모 없음) — 지우지 않고 알림만(280-3)
|
||||
legacy_etc = [
|
||||
round(item.anchor_m(), 3)
|
||||
for item in existing
|
||||
if item.type_id == "etc"
|
||||
and key_of(item) in migrated_before
|
||||
and str(item.options.get("name") or "기타 구조물") == "기타 구조물"
|
||||
and not item.memo
|
||||
]
|
||||
# 종단 정본이 지금 심는 자리를 다 못 세움 = 낡음(저장 도중 끊김 · 다른 탭 [저장] 도중) —
|
||||
# 낡은 측점을 「기타」로 옮기지 않음(280-3)
|
||||
if any(all(abs(at - s) >= SAME_STATION_M for s in standing) for at in planted):
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"migrated": 0,
|
||||
"revision": revision,
|
||||
"pipe_facilities": 0,
|
||||
"skipped": "stale_longitudinal",
|
||||
"legacy_etc": legacy_etc,
|
||||
}
|
||||
)
|
||||
plan = migrate_irregular_stations(legacy, planted)
|
||||
|
||||
occupied = {key_of(item) for item in existing}
|
||||
fresh = [
|
||||
item
|
||||
@@ -360,6 +400,7 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
|
||||
"migrated": moved_pipes,
|
||||
"revision": revision,
|
||||
"pipe_facilities": moved_pipes,
|
||||
"legacy_etc": legacy_etc,
|
||||
}
|
||||
)
|
||||
new_revision = save_structures(
|
||||
@@ -381,6 +422,7 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
|
||||
"migrated": len(fresh) + moved_pipes,
|
||||
"revision": new_revision,
|
||||
"pipe_facilities": moved_pipes,
|
||||
"legacy_etc": legacy_etc,
|
||||
}
|
||||
)
|
||||
except LookupError:
|
||||
|
||||
@@ -296,13 +296,19 @@ async def _regenerate_structure_stations(
|
||||
return made
|
||||
|
||||
|
||||
class StationsUnsynced(RuntimeError):
|
||||
"""맞출 구조물 측점이 있는데 샘플링 조건이 없음 — 저장은 그대로 · 부른 쪽이 「맞춤 실패」
|
||||
알림(280-3)."""
|
||||
|
||||
|
||||
async def ensure_structure_stations(project_id: UUID) -> int:
|
||||
"""빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎).
|
||||
곡선 측점이 지금 관 · 구조물과 어긋나도 맞춤(407-3 · [저장] 의 관 · 구조물 차례 뒤).
|
||||
|
||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리
|
||||
횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다.
|
||||
만들 수 없으면(노선 · 샘플링 조건 없음 · 빠진 것 없음) 0 — 저장을 막지 않는다.
|
||||
노선이 없거나 맞출 것이 없으면 0 · 맞출 것이 있는데 샘플링 조건이 없으면 `StationsUnsynced`
|
||||
(부른 쪽이 저장은 그대로 두고 응답 `stations_synced` 거짓 · 280-3).
|
||||
"""
|
||||
paths = await _project_paths(project_id)
|
||||
if paths is None:
|
||||
@@ -326,10 +332,9 @@ async def ensure_structure_stations(project_id: UUID) -> int:
|
||||
return 0
|
||||
snapshot = await _sampling_conditions(project_id, project_root)
|
||||
if snapshot is None:
|
||||
logger.warning(
|
||||
"B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale
|
||||
raise StationsUnsynced(
|
||||
f"구조물 측점 {len(missing) or stale}곳을 못 맞춤 — 샘플링 조건 없음"
|
||||
)
|
||||
return 0
|
||||
await _regenerate_structure_stations(project_id, project_root, route, snapshot)
|
||||
return len(missing) or stale
|
||||
|
||||
@@ -447,14 +452,23 @@ async def put_plus_stations(project_id: UUID, request: PlusStationsRequest) -> J
|
||||
return save_plus_stations(project_root, chainages, xy)
|
||||
|
||||
saved = await asyncio.to_thread(_save)
|
||||
await ensure_structure_stations(project_id)
|
||||
synced = True
|
||||
try:
|
||||
await ensure_structure_stations(project_id)
|
||||
except StationsUnsynced:
|
||||
logger.warning(
|
||||
"B06 추가 측점: 측점 맞춤 실패(저장은 그대로): project_id=%s", project_id
|
||||
)
|
||||
synced = False
|
||||
except Exception:
|
||||
logger.exception("B06 추가 측점 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "추가 측점을 저장하지 못했습니다."},
|
||||
)
|
||||
return JSONResponse(content={"status": "success", "chainages": saved})
|
||||
return JSONResponse(
|
||||
content={"status": "success", "chainages": saved, "stations_synced": synced}
|
||||
)
|
||||
|
||||
|
||||
#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게.
|
||||
|
||||
@@ -93,7 +93,7 @@ async function withDraftWalls(
|
||||
await loadSavedPlusStations(projectId);
|
||||
// 노선 초안 동안 틀(280-3) — 저장 정본 · 초안 전 상세 = 옛 노선 틀 · 그 밖 = 묶음이 갈아끼운 새 노선 틀
|
||||
const frame =
|
||||
useDesignStore(projectId, null).get("routeDraft") === null
|
||||
readState("route-draft", projectId) === null // 가게 `routeDraft` 칸(세션)
|
||||
? null
|
||||
: detail === savedSectionDetail(projectId, routeId) ||
|
||||
detail === heldSectionDetail(projectId, routeId)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 평면도 = 평면도 관리 모달 그대로(`openPlanManager`)
|
||||
* 횡단도 = 장 배치(열 · 행 · [이 장] · [모든 장]) · 표 배율 · [안내선 숨기기] — 좌측 카드 밑에서 옮김
|
||||
* 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 토적 · 전개도 = 몸 비움(축척 칸은 485-2)
|
||||
* 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 횡단 · 토적 · 전개도 = X · Y 축척 칸(`_UI_Settings_Scale`)
|
||||
* [원본에서 다시 그리기] = 지금 연 도면이 그 묶음일 때만 켬
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isCrossSheet,
|
||||
} from "./B07_DesignDetail_UI_Panels";
|
||||
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
|
||||
import { mountDrawingScale } from "./B07_DesignDetail_UI_Settings_Scale";
|
||||
|
||||
type Kind = DesignDrawingItem["kind"];
|
||||
|
||||
@@ -81,6 +82,7 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void
|
||||
closeModal = close;
|
||||
if (mine) body.append(note(`지금 도면: ${current?.label}`));
|
||||
if (kind === "structure") body.append(note("M02 그림 — 축척 없음"));
|
||||
mountDrawingScale(body, kind, deps, close);
|
||||
if (kind === "cross") mountCross(body, deps, close);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_UI_Settings_Scale.ts
|
||||
* ⚙ 모달 X · Y 축척 칸(485-2 · 요구 280-5) — 종단 · 횡단 · 토적 · 전개도만.
|
||||
*
|
||||
* 칸 = 분모 정수 · 범위 밖 = 칸 붉게 + [축척 저장] 꺼짐 · 기본이 자동인 칸(토적 X · 전개도) = 「자동」
|
||||
* [축척 저장] = 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT → 화면 다시 세움(도면 다시 그림)
|
||||
* [기본값] = 칸만 기본으로 채움 — 저장은 [축척 저장]
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import {
|
||||
type DrawingScaleAxes,
|
||||
type DrawingScaleKind,
|
||||
type DrawingScales,
|
||||
fetchDrawingScales,
|
||||
saveDrawingScales,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import type { DrawingSettingsDeps } from "./B07_DesignDetail_UI_Settings";
|
||||
import { readScaleAxis, scaleToSend } from "./logic/B07_DesignDetail_Scale_Check";
|
||||
|
||||
const LABEL: Record<DrawingScaleKind, string> = {
|
||||
longitudinal: "종단도",
|
||||
cross: "횡단도",
|
||||
mass_haul: "토적도",
|
||||
development: "전개도",
|
||||
};
|
||||
const AXES = [
|
||||
["x", "X(가로) 1/"],
|
||||
["y", "Y(세로) 1/"],
|
||||
] as const;
|
||||
|
||||
const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL;
|
||||
|
||||
/** ⚙ 모달 몸에 축척 칸을 붙임 — 축척 없는 종류는 아무것도 안 함. */
|
||||
export function mountDrawingScale(
|
||||
body: HTMLElement,
|
||||
kind: string,
|
||||
deps: DrawingSettingsDeps,
|
||||
close: () => void,
|
||||
): void {
|
||||
if (!isScaleKind(kind) || !deps.plan) return;
|
||||
const { projectId, onClosed } = deps.plan;
|
||||
const section = document.createElement("div");
|
||||
section.className = "b07-scale";
|
||||
section.textContent = "축척 받는 중…";
|
||||
body.append(section);
|
||||
void fetchDrawingScales(projectId)
|
||||
.then((data) => {
|
||||
section.textContent = "";
|
||||
buildScaleFields(section, kind, data, async (next) => {
|
||||
const saved = data.saved[kind] ?? 0;
|
||||
const ask = `${LABEL[kind]} 축척을 바꾸면 저장본 ${saved}장이 풀립니다(원본에서 다시 그림). 바꿀까요?`;
|
||||
if (saved > 0 && !(await showConfirmDialog(ask, "바꿈"))) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await saveDrawingScales(projectId, { [kind]: next });
|
||||
close();
|
||||
showToast(`${LABEL[kind]} 축척을 바꿨습니다.`, "success");
|
||||
onClosed(true, deps.current()?.id); // 목록 · 도면을 서버에서 다시 받아 다시 그림
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "축척을 저장하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
section.textContent = "축척을 받지 못했습니다.";
|
||||
});
|
||||
}
|
||||
|
||||
function buildScaleFields(
|
||||
section: HTMLElement,
|
||||
kind: DrawingScaleKind,
|
||||
data: DrawingScales,
|
||||
save: (next: DrawingScaleAxes) => Promise<void>,
|
||||
): void {
|
||||
const fields = AXES.map(([axis, label]) => {
|
||||
const fallback = data.defaults[kind][axis];
|
||||
const range = data.ranges[kind][axis];
|
||||
const [min, max] = range;
|
||||
const field = createInputField({ label, type: "number", compact: true, min, max });
|
||||
field.root.classList.add("b07-scale__field");
|
||||
const auto = fallback === null ? createCheckbox("자동") : undefined;
|
||||
if (auto) field.root.append(auto.root);
|
||||
const set = (value: number | null): void => {
|
||||
field.input.value = value === null ? "" : String(value);
|
||||
if (auto) auto.input.checked = value === null;
|
||||
field.input.disabled = value === null && !!auto;
|
||||
};
|
||||
set(data.scales[kind][axis]);
|
||||
const read = () => readScaleAxis(field.input.value, !!auto?.input.checked, range);
|
||||
return { axis, field, auto, fallback, set, read };
|
||||
});
|
||||
|
||||
const saveButton = createButton({
|
||||
label: "축척 저장",
|
||||
onClick: () => {
|
||||
const [x, y] = fields.map((f) => f.read()) as [number | null, number | null];
|
||||
const now = data.scales[kind];
|
||||
if (x === now.x && y === now.y) {
|
||||
showToast("바뀐 축척이 없습니다.", "info");
|
||||
return;
|
||||
}
|
||||
void save({ x: scaleToSend(x, fields[0].fallback), y: scaleToSend(y, fields[1].fallback) });
|
||||
},
|
||||
});
|
||||
const check = (): void => {
|
||||
let bad = false;
|
||||
for (const f of fields) {
|
||||
const result = f.read();
|
||||
f.field.setError(typeof result === "string" ? result : undefined);
|
||||
bad ||= typeof result === "string";
|
||||
}
|
||||
saveButton.disabled = bad;
|
||||
};
|
||||
for (const f of fields) {
|
||||
f.field.input.addEventListener("input", check);
|
||||
f.auto?.input.addEventListener("change", () => {
|
||||
if (f.auto!.input.checked) f.set(null);
|
||||
else f.field.input.disabled = false; // 빈 칸 — 숫자를 적어야 저장
|
||||
check();
|
||||
});
|
||||
}
|
||||
const reset = createButton({
|
||||
label: "기본값",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
fields.forEach((f) => f.set(f.fallback));
|
||||
check();
|
||||
},
|
||||
});
|
||||
reset.title = "칸을 기본 축척으로 채움 — [축척 저장]으로 씀";
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.className = "b07-settings__note";
|
||||
title.textContent =
|
||||
kind === "cross" ? "축척 — 횡단도 장(표준 횡단면도 뺌)" : `축척 — ${LABEL[kind]}`;
|
||||
const row = document.createElement("div");
|
||||
row.className = "b07-scale__row";
|
||||
row.append(...fields.map((f) => f.field.root));
|
||||
const buttons = document.createElement("div");
|
||||
buttons.className = "b07-scale__actions";
|
||||
buttons.append(reset, saveButton);
|
||||
section.append(title, row, buttons);
|
||||
}
|
||||
@@ -503,3 +503,27 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
|
||||
gap: var(--spacing-8);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
/* ⚙ 축척 칸(485-2) — X · Y 두 칸 한 줄 · 단추 오른쪽 */
|
||||
.b07-scale {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 280px;
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-scale__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-scale__field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.b07-scale__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_Scale_Check.ts
|
||||
* ⚙ 축척 칸(485-2) 판정 — 칸 글 → 분모(정수 · 범위 안) · 자동 = null · 틀리면 까닭 글.
|
||||
* 서버 `checked_scales`(`_Router_Support_Scale.py`)와 같은 규칙 — 범위는 서버가 내려 줌.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 칸 하나 읽기 — 숫자 · null(자동) · 까닭 글(틀림). */
|
||||
export function readScaleAxis(
|
||||
raw: string,
|
||||
auto: boolean,
|
||||
[low, high]: [number, number],
|
||||
): number | null | string {
|
||||
if (auto) return null;
|
||||
const text = raw.trim().replace(/,/g, "");
|
||||
const value = Number(text);
|
||||
if (text === "" || !Number.isInteger(value) || value < low || value > high) {
|
||||
return `1/${low.toLocaleString()}~1/${high.toLocaleString()} 사이 정수`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 보낼 값 — 기본값과 같으면 null(파일엔 기본 그대로 남김). */
|
||||
export const scaleToSend = (value: number | null, fallback: number | null): number | null =>
|
||||
value === fallback ? null : value;
|
||||
@@ -96,6 +96,27 @@ const modals = [];
|
||||
const plans = [];
|
||||
const calls = [];
|
||||
let failConfirm = false;
|
||||
const SCALES = {
|
||||
scales: {
|
||||
longitudinal: { x: 1000, y: 200 },
|
||||
cross: { x: 100, y: 100 },
|
||||
mass_haul: { x: null, y: 50000 },
|
||||
development: { x: null, y: null },
|
||||
},
|
||||
defaults: {
|
||||
longitudinal: { x: 1000, y: 200 },
|
||||
cross: { x: 100, y: 100 },
|
||||
mass_haul: { x: null, y: 50000 },
|
||||
development: { x: null, y: null },
|
||||
},
|
||||
ranges: {
|
||||
longitudinal: { x: [100, 10000], y: [10, 2000] },
|
||||
cross: { x: [10, 1000], y: [10, 1000] },
|
||||
mass_haul: { x: [100, 20000], y: [1000, 1000000] },
|
||||
development: { x: [10, 10000], y: [10, 10000] },
|
||||
},
|
||||
saved: { longitudinal: 2, cross: 0, mass_haul: 0, development: 0 },
|
||||
};
|
||||
let answer = true;
|
||||
|
||||
const button = (opts) => {
|
||||
@@ -118,13 +139,24 @@ const STUBS = {
|
||||
label.className = "ui-field__label";
|
||||
label.textContent = opts.label;
|
||||
root.append(label, input);
|
||||
return { root, input };
|
||||
const setError = (msg) => (input.error = msg ?? null);
|
||||
return { root, input, setError };
|
||||
},
|
||||
showToast: (message, kind) => toasts.push([kind, message]),
|
||||
showLoadingOverlay: () => calls.push(["overlay+"]),
|
||||
hideLoadingOverlay: () => calls.push(["overlay-"]),
|
||||
showConfirmDialog: async (message) => (calls.push(["ask", message]), answer),
|
||||
},
|
||||
"@ui/ui_template_checkbox": {
|
||||
createCheckbox: (text) => {
|
||||
const root = new FakeNode("label");
|
||||
root.className = "ui-checkbox";
|
||||
const input = new FakeNode("input");
|
||||
input.checked = false;
|
||||
root.append(input);
|
||||
return { root, input };
|
||||
},
|
||||
},
|
||||
"@ui/ui_template_modal": {
|
||||
openModal: (opts) => {
|
||||
const body = new FakeNode("div");
|
||||
@@ -152,6 +184,9 @@ const STUBS = {
|
||||
return { all_confirmed: true };
|
||||
},
|
||||
invalidateDesignDrawing: async (projectId, id) => calls.push(["invalidate", id]),
|
||||
// 485-2 축척 — 범위 · 기본은 서버(`_Router_Support_Scale.py`)와 같은 값
|
||||
fetchDrawingScales: async () => JSON.parse(JSON.stringify(SCALES)),
|
||||
saveDrawingScales: async (projectId, scales) => calls.push(["scales", scales]),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -274,12 +309,14 @@ const drawings = [
|
||||
openDrawingSettings("cross", deps);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const cross = modals.at(-1);
|
||||
const crossInputs = all(cross.body).filter((n) => n.tagName === "INPUT");
|
||||
const layoutNode = byClass(cross.body, "b07-cross-layout")[0];
|
||||
const crossInputs = all(layoutNode).filter((n) => n.tagName === "INPUT");
|
||||
out.crossModal = {
|
||||
title: cross.opts.title,
|
||||
labels: byClass(cross.body, "ui-field__label").map((n) => n.textContent),
|
||||
labels: byClass(layoutNode, "ui-field__label").map((n) => n.textContent),
|
||||
values: crossInputs.map((n) => n.value),
|
||||
buttons: all(cross.body)
|
||||
scaleTitle: texts(byClass(cross.body, "b07-scale")[0])[0],
|
||||
buttons: all(layoutNode)
|
||||
.filter((n) => n.tagName === "BUTTON")
|
||||
.map((n) => [n.textContent, n.disabled]),
|
||||
guideLast: byClass(cross.body, "b07-cross-layout")[0].children.at(-1) === guideToggle,
|
||||
@@ -287,7 +324,7 @@ const drawings = [
|
||||
notes: texts(cross.body).filter((t) => t.startsWith("지금 도면")),
|
||||
};
|
||||
crossInputs[0].value = "2";
|
||||
all(cross.body)
|
||||
all(layoutNode)
|
||||
.find((n) => n.textContent === "이 장")
|
||||
.click();
|
||||
out.crossSave = { saved, closed: !cross.open };
|
||||
@@ -318,6 +355,115 @@ const drawings = [
|
||||
openDrawingSettings("cross", deps);
|
||||
out.standardInCross = !modals.at(-1).opts.actions[0].disabled;
|
||||
|
||||
// ── 축척 칸(485-2) ─────────────────────────────────────────────────────
|
||||
const closedCalls = [];
|
||||
const scaleDeps = { ...deps, plan: { projectId: "p1", onClosed: (...a) => closedCalls.push(a) } };
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
const scaleOf = async (kind) => {
|
||||
openDrawingSettings(kind, scaleDeps);
|
||||
await tick();
|
||||
const modal = modals.at(-1);
|
||||
const section = byClass(modal.body, "b07-scale")[0];
|
||||
const inputs = all(section).filter(
|
||||
(n) => n.tagName === "INPUT" && !n.parent.classList.contains("ui-checkbox"),
|
||||
);
|
||||
const autos = byClass(section, "ui-checkbox").map((n) => n.children[0]);
|
||||
const btn = (label) =>
|
||||
all(section).find((n) => n.tagName === "BUTTON" && n.textContent === label);
|
||||
const type = (input, value) => {
|
||||
input.value = value;
|
||||
input.listeners.input.forEach((h) => h());
|
||||
};
|
||||
const toggle = (box) => {
|
||||
box.checked = !box.checked;
|
||||
box.listeners.change.forEach((h) => h());
|
||||
};
|
||||
const state = () => ({
|
||||
values: inputs.map((n) => n.value),
|
||||
errors: inputs.map((n) => n.error ?? null),
|
||||
disabled: inputs.map((n) => !!n.disabled),
|
||||
autos: autos.map((n) => n.checked),
|
||||
save: !btn("축척 저장").disabled,
|
||||
});
|
||||
return { modal, section, inputs, autos, btn, type, toggle, state };
|
||||
};
|
||||
const scale = {};
|
||||
current = drawings[2]; // longitudinal
|
||||
let s = await scaleOf("longitudinal");
|
||||
scale.longLabels = byClass(s.section, "ui-field__label").map((n) => n.textContent);
|
||||
scale.longStart = s.state();
|
||||
s.type(s.inputs[0], "50");
|
||||
scale.longLow = s.state();
|
||||
s.type(s.inputs[0], "500.5");
|
||||
scale.longFrac = s.state();
|
||||
s.type(s.inputs[0], "500");
|
||||
scale.longOk = s.state();
|
||||
calls.length = 0;
|
||||
answer = false;
|
||||
s.btn("축척 저장").click();
|
||||
await tick();
|
||||
scale.cancel = { calls: calls.map((c) => c[0]), open: s.modal.open, closed: closedCalls.length };
|
||||
answer = true;
|
||||
calls.length = 0;
|
||||
s.btn("축척 저장").click();
|
||||
await tick();
|
||||
await tick();
|
||||
scale.save = {
|
||||
ask: calls.find((c) => c[0] === "ask")?.[1],
|
||||
sent: calls.find((c) => c[0] === "scales")?.[1],
|
||||
open: s.modal.open,
|
||||
closed: [...closedCalls],
|
||||
};
|
||||
s = await scaleOf("longitudinal");
|
||||
s.type(s.inputs[1], "300");
|
||||
s.btn("기본값").click();
|
||||
scale.reset = s.state();
|
||||
calls.length = 0;
|
||||
const toastAt = toasts.length;
|
||||
s.btn("축척 저장").click();
|
||||
await tick();
|
||||
scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) };
|
||||
|
||||
s = await scaleOf("mass_haul");
|
||||
scale.massStart = s.state();
|
||||
s.toggle(s.autos[0]);
|
||||
scale.massManual = s.state();
|
||||
s.type(s.inputs[0], "2000");
|
||||
scale.massTyped = s.state();
|
||||
calls.length = 0;
|
||||
s.btn("축척 저장").click();
|
||||
await tick();
|
||||
await tick();
|
||||
scale.massSent = {
|
||||
ask: calls.filter((c) => c[0] === "ask").length,
|
||||
sent: calls.find((c) => c[0] === "scales")?.[1],
|
||||
};
|
||||
s = await scaleOf("mass_haul");
|
||||
s.toggle(s.autos[0]);
|
||||
s.toggle(s.autos[0]);
|
||||
scale.massBack = s.state();
|
||||
|
||||
s = await scaleOf("development");
|
||||
scale.dev = s.state();
|
||||
openDrawingSettings("structure", scaleDeps);
|
||||
scale.structure = byClass(modals.at(-1).body, "b07-scale").length;
|
||||
out.scale = scale;
|
||||
const Check = loadTs(path.join(B07, "logic", "B07_DesignDetail_Scale_Check.ts"));
|
||||
out.readAxis = [
|
||||
["1,000", false],
|
||||
[" 99 ", false],
|
||||
["10001", false],
|
||||
["12.5", false],
|
||||
["", false],
|
||||
["abc", false],
|
||||
["", true],
|
||||
].map(([raw, auto]) => Check.readScaleAxis(raw, auto, [100, 10000]));
|
||||
out.toSend = [
|
||||
Check.scaleToSend(200, 200),
|
||||
Check.scaleToSend(500, 200),
|
||||
Check.scaleToSend(null, null),
|
||||
];
|
||||
|
||||
// ── 저장 묶음 ────────────────────────────────────────────────────────────
|
||||
const tabsOf = (list, activeId) => ({
|
||||
tabs: () => list,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""요구 280-3 서버 — 옛 비정규 측점 이관 막이 · 이미 생긴 「기타 구조물」 알림 · 맞춤 실패 알림.
|
||||
|
||||
못박는 것(임시 폴더 · 공유 storage 안 씀)
|
||||
- 이름 빈 측점(추가 측점 정본 소관) = 이관 0 — 「기타 구조물」로 안 옮김.
|
||||
- 종단 정본이 지금 심는 자리를 다 못 세움(저장 도중 끊김 · 다른 탭 [저장] 도중) = 이관 0.
|
||||
- 정본 맞는 옛 자유 글(「대피로 2.0m」)은 종전대로 옮김.
|
||||
- 옛 이관이 만든 「기타 구조물」(이관 기록 · 이름 그대로 · 메모 없음) = `legacy_etc` · 안 지움.
|
||||
- 이관 = [저장]과 같은 프로젝트 잠금 안(앞 저장이 끝날 때까지 기다림).
|
||||
- 맞출 측점이 있는데 샘플링 조건 없음 = `StationsUnsynced` · 추가 측점 PUT `stations_synced` 거짓.
|
||||
판 = `test_452_server_keys_prune_migrate.migrate_client`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import test_452_server_keys_prune_migrate as base # noqa: E402
|
||||
|
||||
import B06_Section.B06_Section_Router_Stations as stations # noqa: E402
|
||||
from common_util import common_util_save_guard as guard # noqa: E402
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402
|
||||
|
||||
PROJECT_ID = base.PROJECT_ID
|
||||
migrate_client = base.migrate_client # 판 그대로
|
||||
router_module = base.router_module
|
||||
_write_longitudinal = base._write_longitudinal
|
||||
|
||||
URL = f"/api/projects/{PROJECT_ID}/route/structures/migrate"
|
||||
STRUCTURES = Path("B05_Profile") / "route" / "structures.json"
|
||||
|
||||
|
||||
def _pipe(project: Path, chainage: float) -> None:
|
||||
path = pipe_points_path_in(project)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"route_signature": "s", "points": [{"chainage_m": chainage, "id": "p1"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _line(*irregular: tuple[float, str]) -> list[dict]:
|
||||
return [
|
||||
{"chainage_m": 0.0, "kind": "bp"},
|
||||
*({"chainage_m": at, "kind": "irregular", "structure": name} for at, name in irregular),
|
||||
{"chainage_m": 400.0, "kind": "ep"},
|
||||
]
|
||||
|
||||
|
||||
def test_이름_빈_측점은_이관_안_함(migrate_client):
|
||||
client, project = migrate_client
|
||||
_write_longitudinal(project, _line((376.0, "")))
|
||||
response = client.post(URL, json={"route_id": None})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["migrated"] == 0
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
assert stored == []
|
||||
|
||||
|
||||
def test_낡은_종단은_이관_안_함(migrate_client):
|
||||
client, project = migrate_client
|
||||
_pipe(project, 351.0) # 지금 관 = 351 · 종단은 옛 자리 330 만(저장 도중 끊김)
|
||||
_write_longitudinal(project, _line((330.0, "파형강관 D800"), (600.0, "맹암거(구판)")))
|
||||
body = client.post(URL, json={"route_id": None}).json()
|
||||
assert body["migrated"] == 0 and body["skipped"] == "stale_longitudinal"
|
||||
assert not (project / STRUCTURES).exists()
|
||||
|
||||
|
||||
def test_정본_맞는_옛_자유글은_옮김(migrate_client):
|
||||
client, project = migrate_client
|
||||
_pipe(project, 351.0)
|
||||
_write_longitudinal(project, _line((300.0, "대피로 2.0m"), (351.0, "파형강관 D800")))
|
||||
body = client.post(URL, json={"route_id": None}).json()
|
||||
assert body["migrated"] == 1 and "skipped" not in body
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
assert [s["type_id"] for s in stored] == ["refuge"]
|
||||
|
||||
|
||||
def test_이미_생긴_기타_구조물은_알림만(migrate_client):
|
||||
client, project = migrate_client
|
||||
_write_longitudinal(project, _line((376.0, "")))
|
||||
path = project / STRUCTURES
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
etc = {
|
||||
"structure_id": "e1",
|
||||
"type_id": "etc",
|
||||
"placement": "point",
|
||||
"chainage_m": 376.0,
|
||||
"options": {"name": "기타 구조물"},
|
||||
}
|
||||
named = {**etc, "structure_id": "e2", "chainage_m": 200.0, "options": {"name": "돌망태"}}
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"revision": 1,
|
||||
"structures": [etc, named],
|
||||
"migrated_legacy": ["etc@376.0", "etc@200.0"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
body = client.post(URL, json={"route_id": None}).json()
|
||||
assert body["legacy_etc"] == [376.0] # 이름 단 것(돌망태)은 사용자 것
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
assert sorted(s["structure_id"] for s in stored) == ["e1", "e2"] # 지우지 않음
|
||||
|
||||
|
||||
def test_이관은_저장_잠금을_기다림(monkeypatch):
|
||||
seen: list[str] = []
|
||||
|
||||
async def body(project_id, payload):
|
||||
seen.append("migrate")
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(router_module, "_migrate_structures", body)
|
||||
|
||||
async def run() -> None:
|
||||
lock = guard.project_lock(PROJECT_ID)
|
||||
async with lock:
|
||||
task = asyncio.create_task(
|
||||
router_module.migrate_structures(
|
||||
UUID(PROJECT_ID), router_module.StructureMigrateRequest()
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
assert seen == [] # 저장 도중 = 기다림
|
||||
assert await task == "done" and seen == ["migrate"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_샘플링_조건_없으면_맞춤_실패_알림(monkeypatch, tmp_path):
|
||||
from B05_Profile import B05_Profile_Engine_Sections as sections
|
||||
from B05_Profile import B05_Profile_Plus_Stations as plus
|
||||
from B05_Profile import B05_Profile_Router_Confirm as confirm
|
||||
|
||||
async def paths(_project_id):
|
||||
return tmp_path, {"route_data_path": ""}
|
||||
|
||||
async def nothing(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(stations, "_project_paths", paths)
|
||||
monkeypatch.setattr(stations, "_missing_marks", lambda *_: [120.0])
|
||||
monkeypatch.setattr(stations, "_stale_marks", lambda *_: 0)
|
||||
monkeypatch.setattr(stations, "_curve_marks", lambda *_: 0)
|
||||
monkeypatch.setattr(stations, "run_with_connection", nothing)
|
||||
monkeypatch.setattr(stations, "_sampling_conditions", nothing)
|
||||
monkeypatch.setattr(confirm, "_section_options_from_stored", lambda *_: None)
|
||||
with pytest.raises(stations.StationsUnsynced):
|
||||
asyncio.run(stations.ensure_structure_stations(UUID(PROJECT_ID)))
|
||||
|
||||
monkeypatch.setattr(sections, "_load_route_polyline", lambda *_: [])
|
||||
monkeypatch.setattr(plus, "save_plus_stations", lambda _root, chainages, _xy: list(chainages))
|
||||
response = asyncio.run(
|
||||
stations.put_plus_stations(UUID(PROJECT_ID), stations.PlusStationsRequest(chainages=[120]))
|
||||
)
|
||||
body = json.loads(response.body)
|
||||
assert body["status"] == "success" and body["chainages"] == [120]
|
||||
assert body["stations_synced"] is False # 저장은 그대로 · 맞춤 실패 알림
|
||||
|
||||
monkeypatch.setattr(stations, "_missing_marks", lambda *_: [])
|
||||
assert (
|
||||
asyncio.run(stations.ensure_structure_stations(UUID(PROJECT_ID))) == 0
|
||||
) # 맞출 것 없음 = 조용
|
||||
@@ -83,6 +83,7 @@ def test_횡단_모달은_배치_칸_표_배율_안내선(out):
|
||||
assert cross["guideLast"] is True
|
||||
assert cross["head"] == [["원본에서 다시 그리기", False]]
|
||||
assert cross["notes"] == ["지금 도면: cross_s1"]
|
||||
assert cross["scaleTitle"] == "축척 — 횡단도 장(표준 횡단면도 뺌)"
|
||||
saved = out["crossSave"]
|
||||
assert saved["closed"] is True
|
||||
assert saved["saved"] == [[{"default": [4, 4], "sheets": {"1": [2, 2]}, "table_scale": 1.2}, 1]]
|
||||
@@ -93,7 +94,7 @@ def test_구조물_종단_평면도_모달(out):
|
||||
assert structure["title"] == "구조물 도면 설정"
|
||||
assert structure["notes"] == ["M02 그림 — 축척 없음"]
|
||||
assert structure["head"][0][1] is True # 다른 묶음 도면을 연 중 = 꺼짐
|
||||
assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 0} # 축척 칸은 485-2
|
||||
assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 1} # 축척 칸(485-2)
|
||||
assert out["planModal"] == {"viaManager": True, "head": [["원본에서 다시 그리기", False]]}
|
||||
assert out["planRedraw"] == {"redraws": 1, "closed": True}
|
||||
assert out["standardInCross"] is True
|
||||
@@ -216,3 +217,48 @@ def test_서버_단계_완료(monkeypatch, tmp_path: Path) -> None:
|
||||
|
||||
monkeypatch.setattr(router, "_confirmed_source", blocked)
|
||||
assert asyncio.run(router.complete_design_stage(project)).status_code == 409
|
||||
|
||||
|
||||
RANGE = "1/100~1/10,000 사이 정수"
|
||||
|
||||
|
||||
def test_485_2_축척_칸_판정(out):
|
||||
assert out["readAxis"] == [1000, RANGE, RANGE, RANGE, RANGE, RANGE, None] # 자동 = null
|
||||
assert out["toSend"] == [None, 500, None] # 기본과 같으면 null
|
||||
|
||||
|
||||
def test_485_2_종단_축척_저장(out):
|
||||
scale = out["scale"]
|
||||
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/"]
|
||||
assert scale["longStart"]["values"] == ["1000", "200"]
|
||||
assert scale["longStart"]["autos"] == [] # 종단 = 자동 없음
|
||||
for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐
|
||||
assert scale[bad]["errors"] == [RANGE, None]
|
||||
assert scale[bad]["save"] is False
|
||||
assert scale["longOk"]["errors"] == [None, None] and scale["longOk"]["save"] is True
|
||||
# 저장본 2장 — 물음 [취소] = 요청 없음 · 모달 그대로
|
||||
assert scale["cancel"] == {"calls": ["ask"], "open": True, "closed": 0}
|
||||
save = scale["save"]
|
||||
assert "저장본 2장" in save["ask"]
|
||||
assert save["sent"] == {"longitudinal": {"x": 500, "y": None}} # Y = 기본 → null
|
||||
assert save["open"] is False
|
||||
assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면
|
||||
assert scale["reset"]["values"] == ["1000", "200"] # [기본값] = 칸만
|
||||
assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]}
|
||||
|
||||
|
||||
def test_485_2_토적_전개도_자동(out):
|
||||
scale = out["scale"]
|
||||
assert scale["massStart"] == {
|
||||
"values": ["", "50000"],
|
||||
"errors": [None, None],
|
||||
"disabled": [True, False],
|
||||
"autos": [True],
|
||||
"save": True,
|
||||
}
|
||||
assert scale["massManual"]["errors"][0] and scale["massManual"]["save"] is False # 빈 칸
|
||||
assert scale["massTyped"]["save"] is True
|
||||
assert scale["massSent"] == {"ask": 0, "sent": {"mass_haul": {"x": 2000, "y": None}}}
|
||||
assert scale["massBack"]["autos"] == [True] and scale["massBack"]["save"] is True
|
||||
assert scale["dev"]["autos"] == [True, True] and scale["dev"]["disabled"] == [True, True]
|
||||
assert scale["structure"] == 0 # 구조물 = 축척 칸 없음
|
||||
|
||||
Reference in New Issue
Block a user