fix(505-2 B05 · B06 저장 · 고름): 확정 중 고침 알림 · 저장 중 초기화 막음 + 서버 잠금 · 계산 중 누름 폄 · B06 목록 밂 · 추가 측점 data-plus · 옛 초안 간격 · 풀개 — 요구 309-3 · 310

- 1 가림막 저장(확정)도 도중 고침 검사 · 「저장 중 고친 값」 알림(키는 안 막음 · B07~B09 안 번짐)
- 2b snapOptions 간격 = 10 만 · 옛 초안 0.1 · 1 m = null(load_station_snap_step 거울)
- 3 추가 측점 = 측점 묶음 data-plus · B05 누름 · 가까이 표시가 그 속성으로
- 4d 숨은 동안 고른 카드 = 다음 다시 그림 끝에 밂 · 휠 · 누름이면 버림
- 7 pipe_calc set 다섯 = 풀개 · 횡단 · 종단 보기 onDispose
- 8 저장 중 [초기화] = [저장]과 같은 알림 + 막음 · /route/reset = 프로젝트 잠금 안
- 9 isUserPick 거름 = 묶음 띄운 그 누름만 · 계산 중 새 누름 = 폄
- 시험 test_505_2_save_pick 새 · 398_1 busyPick 1 · 390_5 글 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:44:25 +09:00
co-authored by Claude Opus 5.5
parent 1d0cc2f97f
commit 99a224c6c9
19 changed files with 289 additions and 38 deletions
+8 -3
View File
@@ -69,12 +69,17 @@ const notify = (projectId: string): void => {
busyListeners.get(projectId)?.forEach((fn) => fn(now));
};
/** 묶음 차례가 떠 있나 — 그 사이 폼이 다시 실려도 좌측 패널은 안 폄(`design_user_pick` · 398-1). */
export const pageBusy = (projectId: string): boolean => (busy.get(projectId) ?? 0) > 0;
const launchedBy = new Map<string, Event>();
/** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */
/** 이 누름이 묶음 차례를 띄웠나 — 그 누름 안 폼 다시 실음은 좌측 패널 안 폄(`design_user_pick` · 398-1 · 505-2). */
export const pageLaunchedBy = (projectId: string, event: Event): boolean =>
launchedBy.get(projectId) === event;
/** 묶음 차례 하나가 뜸 — 띄운 누름을 적음 · 끝나면(밀림 · 실패 포함) 내림. */
export function trackPageBusy(projectId: string, run: Promise<unknown> | null): void {
if (!run) return;
const event = (globalThis as { event?: Event }).event;
if (event) launchedBy.set(projectId, event);
busy.set(projectId, (busy.get(projectId) ?? 0) + 1);
notify(projectId);
const done = (): void => {
+8 -1
View File
@@ -18,6 +18,7 @@ import { clearResults, clearState, namesInBucket } from "./../b_page_state";
import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import { designSaveBusy, SAVE_BUSY_TEXT } from "./design_save";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -32,7 +33,13 @@ export async function resetDesign(
_routeId: number | null,
onCleared?: () => void,
): Promise<void> {
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
// 저장 중 = [저장]처럼 알림 + 막음(505-2) — 확인 창 동안 뒤 저장이 떴을 수 있어 뒤에도 봄
const saveBusy = (): boolean => {
if (designSaveBusy(projectId)) showToast(SAVE_BUSY_TEXT, "warning");
return designSaveBusy(projectId);
};
if (saveBusy() || !(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
if (saveBusy()) return;
showLoadingOverlay();
try {
await resetRouteDesign(projectId);
+8 -3
View File
@@ -78,6 +78,8 @@ export type PartOverride = (
const saving = new Set<string>();
/** 프로젝트마다 지금 도는 저장 한 번(498-3 D1) — 확정인가 · 끝 약속. */
const inflight = new Map<string, { confirm: boolean; done: Promise<unknown> }>();
/** 저장 중 막음 알림 — [저장] · [초기화] 같은 글(505-2). */
export const SAVE_BUSY_TEXT = "이전 수정을 저장 중입니다. 잠시 후 다시 해 주세요.";
/** 저장이 도는 중인가(498-3) — 뒤에서 도는 [저장] 포함. */
export function designSaveBusy(projectId: string): boolean {
@@ -95,7 +97,7 @@ export async function queueDesignSave<T>(
): Promise<T | false> {
const before = inflight.get(projectId);
if (before && (!confirm || before.confirm)) {
showToast("이전 수정을 저장 중입니다. 잠시 후 다시 해 주세요.", "warning");
showToast(SAVE_BUSY_TEXT, "warning");
return false;
}
if (before) showLoadingOverlay();
@@ -261,9 +263,12 @@ export async function saveDesign(
route ? { route, carry: carried?.carry } : {},
);
if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info");
// 잠금 없는 저장 도중 고침 — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸 본문을 저장함)
// 저장 도중 고침(가림막 동안 키 · 되돌리기 포함 · 505-2) — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸
// 본문을 저장함) · 노선 저장은 다시 세움이 맡음
// ponytail: 고침 하나라도 있으면 조각 정리를 통째 건너뜀 · 다음 저장이 초안 전부를 다시 냄
const touched = !locked && designSignature(ctx.projectId) !== sent;
const touched = !route && designSignature(ctx.projectId) !== sent;
if (touched && locked)
showToast("저장 중 고친 값은 아직 저장 안 됨 — 다시 [저장]해 주세요.", "warning");
if (route) routeSaved(ctx.projectId); // 가게 노선 초안 · 조회 결과 캐시 · 편집기 「저장됨」
if (!touched) for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(result.steps[key]);
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
@@ -24,20 +24,32 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu
/** 횡단 측점 목록 읽기 — 노면공 확폭 구간 상자(87-3)가 확폭을 읽음 · 자료 없으면 null. */
export const pipeSections = (): readonly CrossSection[] | null => sectionsSource?.() ?? null;
/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */
/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch.
* set 마다 풀개를 돌려줌(아직 제 값일 때만 비움) — 보기 onDispose 에 붙여 떠난 페이지를 놓음(505-2). */
let extraCountSource: ((section: CrossSection) => number) | null = null;
export function setPipeExtraCountSource(source: (section: CrossSection) => number): void {
export function setPipeExtraCountSource(source: (section: CrossSection) => number): () => void {
extraCountSource = source;
return () => {
if (extraCountSource === source) extraCountSource = null;
};
}
/** 유출 단 수를 쓰는 곳 — 횡단 보기의 단 수 제어(54-27) · 종단 보기는 횡단 보기를 숨긴 채 세워 씀. */
let extraCountSink: ((section: CrossSection, count: number) => void) | null = null;
let crossOpener: (() => Promise<void>) | null = null;
export function setPipeExtraCountSink(sink: (section: CrossSection, count: number) => void): void {
export function setPipeExtraCountSink(
sink: (section: CrossSection, count: number) => void,
): () => void {
extraCountSink = sink;
return () => {
if (extraCountSink === sink) extraCountSink = null;
};
}
export function setPipeCrossOpener(opener: () => Promise<void>): void {
export function setPipeCrossOpener(opener: () => Promise<void>): () => void {
crossOpener = opener;
return () => {
if (crossOpener === opener) crossOpener = null;
};
}
/** 좌측 「추가 흙막이(단)」 → 주인 측점 유출 단 수(B06 횡단 조정과 같은 저장 자리 · 54-27). */
@@ -50,8 +62,11 @@ export async function setPipeExtraCount(chainageM: number | null, count: number)
/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */
type InletChoice = NonNullable<CrossSection["design"]>["inlet_structure"];
let inletSource: ((section: CrossSection) => InletChoice) | null = null;
export function setPipeInletSource(source: (section: CrossSection) => InletChoice): void {
export function setPipeInletSource(source: (section: CrossSection) => InletChoice): () => void {
inletSource = source;
return () => {
if (inletSource === source) inletSource = null;
};
}
/** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */
@@ -137,8 +152,11 @@ interface RevetChoiceBridge {
) => void;
}
let revetChoice: RevetChoiceBridge | null = null;
export function setPipeRevetChoiceBridge(bridge: RevetChoiceBridge): void {
export function setPipeRevetChoiceBridge(bridge: RevetChoiceBridge): () => void {
revetChoice = bridge;
return () => {
if (revetChoice === bridge) revetChoice = null;
};
}
/** 그 관의 [연동] · [경사] 지금 값 — 횡단 자료가 없으면 null(모름). */
+5 -4
View File
@@ -5,12 +5,13 @@
*
* 사용자 고름 = 지금 브라우저가 진짜 누름(포인터 · 마우스 · 우클릭)을 처리하는 중 — 눈금 · 마름모 · 3D ·
* 종단 알약 · 배수유역도 · 횡단 카드 · 우클릭 추가가 다 그 누름 안에서 폼을 엶.
* 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음이 도는 동안
* 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음을 띄운 그 누름
* (교점 끌기 · 곡선 지우기 · 되돌리기가 띄운 차례의 관 · 구조물 따라가기 · 다시 칠하기).
* 「계산 중」 동안 새로 누른 고름은 폄(505-2).
* 폼이 고른 것을 다시 실을 때마다 부르는 쪽을 막지 않고 펴기 한 곳(`design_shared` onReveal)이 여기만 봄.
* ========================================================================== */
import { pageBusy } from "./design_page_data";
import { pageLaunchedBy } from "./design_page_data";
const PICK_EVENTS = new Set([
"pointerdown",
@@ -22,9 +23,9 @@ const PICK_EVENTS = new Set([
"contextmenu",
]);
/** 지금 사용자 누름 안이고 묶음이 안 도는가 — 좌측 패널을 펴도 되는가. */
/** 지금 사용자 누름 안이고 그 누름이 묶음을 안 띄웠나 — 좌측 패널을 펴도 되는가. */
export function isUserPick(projectId: string | null): boolean {
const event = (globalThis as { event?: Event }).event; // 처리 중인 이벤트(그 마이크로태스크까지)
if (!event?.isTrusted || !PICK_EVENTS.has(event.type)) return false;
return !projectId || !pageBusy(projectId);
return !projectId || !pageLaunchedBy(projectId, event);
}
+7 -4
View File
@@ -298,18 +298,21 @@ export async function mountCrossView(
},
});
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
host.onDispose(
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")),
);
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
setPipeExtraCountSink((owner, count) => {
const releaseSink = setPipeExtraCountSink((owner, count) => {
const picked = stationControls.revetOffset.highlightFor(owner) ?? "";
if (/^extra\d+$/.test(picked) && Number(picked.slice(5)) >= count)
stationControls.revetOffset.select(owner.chainage_m, "outlet");
stationControls.extraWalls.setCount(owner.chainage_m, count, "outlet");
});
host.onDispose(releaseSink); // set 넷 = 떠난 페이지를 놓음(505-2)
// 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선
setPipeInletSource((section) => stationControls.inletStructure.valueFor(section));
host.onDispose(setPipeInletSource((section) => stationControls.inletStructure.valueFor(section)));
// 좌측 관 보호공 [연동] · [경사] = 옛 연동 제어 그대로(54-30)
setPipeRevetChoiceBridge(pipeRevetChoiceBridge(stationControls.revetLink));
host.onDispose(setPipeRevetChoiceBridge(pipeRevetChoiceBridge(stationControls.revetLink)));
const sectionView = createSectionView(
(chainageM, change) => {
+1 -1
View File
@@ -222,7 +222,7 @@ export async function mountProfileView(
restoring: true,
};
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
setPipeCrossOpener(() => shared.ensureView("cross"));
host.onDispose(setPipeCrossOpener(() => shared.ensureView("cross"))); // 떠나면 놓음(505-2)
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
let selectionSyncing = false;
@@ -48,14 +48,17 @@ export interface SavedSnap {
station_snap_step?: number | null;
}
/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남). */
/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남).
* 간격 = 10 만 그 값 · 옛 초안 0.1 · 1 m 등 = null — 짝 `B05_Profile_Engine_Sections.py` load_station_snap_step(505-2). */
export function snapOptions(
draft: RouteDraft | null | undefined,
saved: SavedSnap | null | undefined,
): Pick<StationsOptions, "stationSnap" | "stationSnapStep"> {
return draft
? { stationSnap: draft.stationSnap, stationSnapStep: draft.stationSnapStep }
: { stationSnap: saved?.station_snap ?? false, stationSnapStep: saved?.station_snap_step };
const step = draft ? draft.stationSnapStep : saved?.station_snap_step;
return {
stationSnap: draft ? draft.stationSnap : (saved?.station_snap ?? false),
stationSnapStep: step === 10 ? 10 : null,
};
}
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */
@@ -108,6 +108,7 @@ def load_station_snap_step(project_root: Path) -> float | None:
"""스냅 간격(m) — 곡선표 `"station_snap_step"`(390-5 · [확인] 때 적힘).
10 만 그 값 · 칸 없음 · 옛 0.1 · 1 m 등 그 밖 = None(측점 No. · 280-4).
짝 = `A00_Common/design/logic/design_pipeline_stations.ts` snapOptions(505-2 · 거울 시험).
"""
step = _read_design_curve_table(project_root).get("station_snap_step")
return 10.0 if isinstance(step, int | float) and step == 10 else None
@@ -236,6 +236,15 @@ async def reset_route_design(
project_id: UUID,
session: dict[str, Any] | None = Depends(get_optional_session), # 사본 가져온이
) -> JSONResponse:
"""[초기화] — [저장] · [확정]과 같은 프로젝트 잠금 안(505-2 · 저장 도중 초기화가 끼어 서로 덮지 않음 ·
몸통 `_reset_route_design` · 안에서 같은 잠금 안 잡음)."""
from common_util import common_util_save_guard as guard
async with guard.project_lock(str(project_id)):
return await _reset_route_design(project_id, session)
async def _reset_route_design(project_id: UUID, session: dict[str, Any] | None) -> JSONResponse:
"""B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼).
**초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계
@@ -59,7 +59,7 @@ export function crossPickFromEvent(
): { chainageM: number; groundY: number | null } | null {
const target = event.target as Element;
const marker = target.closest(".b06-chart__station");
const plusRaw = marker?.querySelector(".b06-chart__station-line--plus")
const plusRaw = marker?.hasAttribute("data-plus")
? Number(marker.getAttribute("data-chainage"))
: null;
const hit: CrossPickHit =
+1 -2
View File
@@ -336,8 +336,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples);
if (at === null) return;
const marker = (event.target as Element).closest(".b06-chart__station");
if (marker && !marker.querySelector(".b06-chart__station-line--plus"))
event.stopPropagation();
if (marker && !marker.hasAttribute("data-plus")) event.stopPropagation();
openCrossModal(body.parentElement ?? body, {
chainageM: at.chainageM,
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
@@ -435,6 +435,8 @@ export function createLongitudinalProfile(
role: "button",
"aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`,
});
// 추가 측점 = 고른 뒤에도(선 클래스 --selected) B05 누름 판정이 읽음(505-2)
if (station.plus) marker.setAttribute("data-plus", "1");
marker.addEventListener("click", () => onSelectStation(station.station_id));
marker.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
@@ -100,6 +100,8 @@ export function createSectionView(
const cards = document.createElement("div");
cards.className = "b06-section__cards";
root.append(cards);
let pendingReveal: string | null = null; // 숨은 동안 고른 카드 — 다음 다시 그림 끝에 밂(505-2)
cards.onwheel = cards.onpointerdown = () => (pendingReveal = null); // 사용자 스크롤 · 누름 = 버림
let currentDetail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
/** 선택 카드에서 강조 중인 면적. 카드를 다시 그려도 그대로 살아나야 하므로 여기 둔다. */
@@ -211,6 +213,7 @@ export function createSectionView(
*/
const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => {
if (!stationId) return;
pendingReveal = stationId;
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "start" });
};
@@ -494,6 +497,8 @@ export function createSectionView(
const keepScrollTop = cards.scrollTop;
cards.replaceChildren(fillSlopeNotice.root, grid);
cards.scrollTop = keepScrollTop;
if (pendingReveal) revealCard(pendingReveal, "auto");
pendingReveal = null;
offscreen.drain();
syncProfile();
};
+1 -1
View File
@@ -70,7 +70,7 @@ const count = async (event, body) => {
await Promise.resolve();
onReveal(); // follow → 폼 다시 실음
});
out.busyPick = await count(trusted("click"), () => onReveal()); // 차례가 도는 사이 고름은 안 폄
out.busyPick = await count(trusted("click"), () => onReveal()); // 차례가 도는 사이 새 고름도 폄(505-2)
finish();
await new Promise((resolve) => setTimeout(resolve, 0));
out.afterRun = await count(trusted("click"), () => onReveal()); // 차례가 끝나면 다시 폄
@@ -0,0 +1,53 @@
/* PLAN 505-2 단위검증 헬퍼 — 진짜 `design_pipeline_stations.ts` snapOptions(옛 초안 간격) ·
* `design_structure_pipe_calc.ts` set 풀개(떠난 페이지를 놓음). 짝 = `test_505_2_save_pick.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function load(rel) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
return box.exports;
}
(async () => {
const out = {};
// 2b — 초안 간격 · 저장 간격 → 측점 셈 간격(서버 load_station_snap_step 과 같아야 함)
const { snapOptions } = load("A00_Common/design/logic/design_pipeline_stations.ts");
out.draft = [10, 0.1, 1, 20, null].map(
(step) => snapOptions({ stationSnap: true, stationSnapStep: step }, null).stationSnapStep,
);
out.saved = [10, 0.1, 1, null].map(
(step) => snapOptions(null, { station_snap: true, station_snap_step: step }).stationSnapStep,
);
out.snapKept = snapOptions({ stationSnap: true, stationSnapStep: 0.1 }, null).stationSnap;
// 7 — 풀개는 자기 값일 때만 비움
const calc = load("A00_Common/design/design_structure_pipe_calc.ts");
const opened = { a: 0, b: 0 };
const releaseA = calc.setPipeCrossOpener(async () => void (opened.a += 1));
releaseA();
await calc.setPipeExtraCount(null, 1);
out.afterRelease = opened.a; // 0 — 풀린 옛 페이지를 안 부름
const releaseA2 = calc.setPipeCrossOpener(async () => void (opened.a += 1));
calc.setPipeCrossOpener(async () => void (opened.b += 1));
releaseA2(); // 옛 것 풀개 — 새 것은 그대로
await calc.setPipeExtraCount(null, 1);
out.newKept = { ...opened };
let sunk = 0;
const releaseSink = calc.setPipeExtraCountSink(() => void (sunk += 1));
await calc.setPipeExtraCount(null, 1);
out.sinkOpened = opened.b; // sink 있으면 열지 않음 → 그대로 1
releaseSink();
await calc.setPipeExtraCount(null, 1);
out.sinkReleasedOpened = opened.b; // sink 풀림 → 새 횡단 보기를 엶 → 2
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
console.error(error);
process.exit(1);
});
+2 -2
View File
@@ -221,8 +221,8 @@ def test_서버_측점_셈이_저장_간격을_씀() -> None:
assert "stationSnapStep: routePlan.station_snap_step" in page
stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8")
assert "...snapOptions(ctx.inputs.route, saved?.())" in stage # 초안 · 없으면 저장 노선(441)
assert "stationSnapStep: draft.stationSnapStep" in stage
assert "stationSnapStep: saved?.station_snap_step" in stage
assert "draft ? draft.stationSnapStep : saved?.station_snap_step" in stage
assert "stationSnapStep: step === 10 ? 10 : null" in stage # 옛 초안 0.1 · 1 m = null(505-2)
assert "saved: () => plan" in page and "...snapOptions(inputs.route, plan)" in page
apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No.
@@ -3,7 +3,8 @@
못박는 것
- 좌측 패널 저절로 펴기는 사용자 고름만 — 진짜 누름(click · pointerup · contextmenu) 안 = 1.
- 다시 칠하기 · 누가거리 따라가기 · 되돌리기 · 묶음 다시 돎 = 0 — 누름 밖(배수유역 다시 나눔 응답) ·
방향키 · 끌기 도중 · 가짜 누름 · 묶음 차례가 떠 있는 동안(교점 놓음 · 곡선 지우기 · 되돌리기).
방향키 · 끌기 도중 · 가짜 누름 · 묶음 차례를 띄운 그 누름(교점 놓음 · 곡선 지우기 · 되돌리기) ·
「계산 중」 동안 새 누름 = 1(505-2).
- 뿌리 한 곳 — 폼(`selectPipeByChainage` · `selectById` · `addAt`)은 그대로 펴기를 부르고 `design_shared`
onReveal 하나가 `isUserPick` 으로 거름.
- 499-1 뒤집음 — 펴기 = 「구조물 배치」 카드만 · 좌측 패널은 안 엶(`revealLeft` · `setOptionsOpen(` 없음) ·
@@ -41,14 +42,12 @@ def r() -> dict:
return json.loads(proc.stdout)
@pytest.mark.parametrize("way", ["click", "pointerup", "contextmenu", "afterRun"])
@pytest.mark.parametrize("way", ["click", "pointerup", "contextmenu", "afterRun", "busyPick"])
def test_사용자_고름은_폄(r, way):
assert r[way] == 1
assert r[way] == 1 # busyPick = 「계산 중」 동안 새 누름(505-2)
@pytest.mark.parametrize(
"way", ["outside", "arrow", "drag", "synthetic", "pipelineRepaint", "busyPick"]
)
@pytest.mark.parametrize("way", ["outside", "arrow", "drag", "synthetic", "pipelineRepaint"])
def test_다시_칠하기는_안_폄(r, way):
assert r[way] == 0
+141
View File
@@ -0,0 +1,141 @@
"""PLAN 505-2 B05 · B06 저장 · 고름 흠(요구 309-3 · C1 1 · 2b · 3 · 4d · 7 · 8 · 9).
못박는 것
- 1 확정 · 노선 아닌 가림막 저장 도중 고침도 조각 정리 건너뜀 + 「저장 중 고친 값」 알림
(키는 안 막음 · B07~B09 안 번짐).
- 2b 옛 세션 초안 간격 0.1 · 1 m = null — 서버 `load_station_snap_step` 과 거울.
- 3 추가 측점 = 측점 묶음 `data-plus` — 고른 뒤(선 클래스 --selected)에도 B05 누름 판정이 읽음.
- 4d 숨은 동안 고른 카드 = 다음 다시 그림 끝에 밂 · 사용자 휠 · 누름이면 버림.
- 7 횡단 · 종단 보기가 건 set 넷 + 여는 이 = 풀개를 onDispose 에 — 자기 값일 때만 비움.
- 8 저장 중 [초기화] = [저장]과 같은 알림 + 막음 · 서버 /route/reset = 프로젝트 잠금 안.
- 9 = `test_398_1_reveal_user_pick.py` busyPick 1.
./venv/Scripts/python.exe -m pytest resources/tester/test_505_2_save_pick.py -q
"""
from __future__ import annotations
import asyncio
import json
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
HELPER = Path(__file__).resolve().parent / "helper_505_2_save_pick.cjs"
DESIGN = "A00_Common/design/"
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run( # noqa: S603, S607 — 고정 실행 파일
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def _server_step(tmp_path: Path, step: float | None) -> float | None:
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap_step
from common_util.common_util_route_geometry import planned_route_working_path
path = planned_route_working_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("x,y\n0,0\n10,0\n", encoding="utf-8")
curves = {"curves": [], "station_snap": True, "station_snap_step": step}
path.with_name(f"{path.stem}_curves.json").write_text(json.dumps(curves), encoding="utf-8")
return load_station_snap_step(tmp_path)
def test_2b_초안_간격_서버와_거울(out, tmp_path) -> None:
steps = [10, 0.1, 1, 20, None]
assert (
out["draft"] == [_server_step(tmp_path, s) for s in steps] == [10, None, None, None, None]
)
assert out["saved"] == [10, None, None, None]
assert out["snapKept"] is True # 스냅 켬은 그대로 · 간격만 측점 No.
def test_7_풀개는_자기_값만(out) -> None:
assert out["afterRelease"] == 0
assert out["newKept"] == {"a": 0, "b": 1}
assert out["sinkOpened"] == 1 and out["sinkReleasedOpened"] == 2
cross = _read(DESIGN + "design_view_cross.ts")
for call in ("setPipeExtraCountSource(", "setPipeInletSource(", "setPipeRevetChoiceBridge("):
assert f"host.onDispose(\n {call}" in cross or f"host.onDispose({call}" in cross, call
assert "const releaseSink = setPipeExtraCountSink(" in cross
assert "host.onDispose(releaseSink);" in cross
profile = _read(DESIGN + "design_view_profile.ts")
assert 'host.onDispose(setPipeCrossOpener(() => shared.ensureView("cross")));' in profile
def test_1_가림막_저장도_도중_고침_검사() -> None:
save = _read(DESIGN + "design_save.ts")
assert "const touched = !route && designSignature(ctx.projectId) !== sent;" in save
assert "if (touched && locked)" in save and "저장 중 고친 값은 아직 저장 안 됨" in save
assert "!locked && designSignature" not in save
elements = _read("ui_template/ui_template_elements.ts")
assert "keydown" not in elements[elements.index("function ensureOverlay") :][:1200]
def test_8_저장_중_초기화_막음() -> None:
reset = _read(DESIGN + "design_reset.ts")
body = reset[reset.index("export async function resetDesign(") :]
assert body.index("saveBusy()") < body.index("showConfirmDialog(")
assert "if (saveBusy()) return;" in body # 확인 창 동안 뜬 뒤 저장도
assert "designSaveBusy(projectId)" in body and "SAVE_BUSY_TEXT" in body
save = _read(DESIGN + "design_save.ts")
assert save.count("showToast(SAVE_BUSY_TEXT") == 1 # [저장]과 같은 글
def test_8_서버_초기화는_프로젝트_잠금_안(monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle
from common_util import common_util_save_guard as guard
pid = uuid4()
seen: list[bool] = []
async def body(project_id, session):
seen.append(guard.project_lock(str(project_id)).locked())
return "ok"
monkeypatch.setattr(lifecycle, "_reset_route_design", body)
assert asyncio.run(lifecycle.reset_route_design(pid, None)) == "ok"
assert seen == [True]
assert not guard.project_lock(str(pid)).locked()
src = _read("B05_Profile/B05_Profile_Router_Lifecycle.py")
inner = src[src.index("async def _reset_route_design(") :]
assert "project_lock" not in inner # 안에서 같은 잠금 다시 안 잡음(교착)
def test_3_추가_측점은_data_plus() -> None:
longi = _read("B06_Section/B06_Section_UI_Longitudinal.ts")
assert 'if (station.plus) marker.setAttribute("data-plus", "1");' in longi
modal = _read("B05_Profile/B05_Profile_UI_Profile_CrossModal.ts")
render = _read("B05_Profile/B05_Profile_UI_Profile_Render.ts")
assert 'marker?.hasAttribute("data-plus")' in modal
assert '!marker.hasAttribute("data-plus")' in render
assert "station-line--plus" not in modal + render
def test_4d_숨은_동안_고름은_다음_그림에_밂() -> None:
view = _read("B06_Section/B06_Section_UI_Section_View.ts")
assert len(view.splitlines()) <= 700
reveal = view[view.index("const revealCard = ") :][:300]
assert "pendingReveal = stationId;" in reveal
draw = view[view.index("cards.scrollTop = keepScrollTop;") :][:200]
assert 'if (pendingReveal) revealCard(pendingReveal, "auto");' in draw
assert "pendingReveal = null;" in draw
assert "cards.onwheel = cards.onpointerdown = () => (pendingReveal = null);" in view
def test_파이프라인_페이지_안_늘림() -> None:
assert len(_read(DESIGN + "design_pipeline_page.ts").splitlines()) <= 699