refactor(B05·B06): 부름 0 옛 줄 · 늘 건너뛰는 시험 지움 (PLAN 472-8 ② ③ · 요구 409)
지움
- flushDesignStore(design_store · 부름 0) — 시험 344-4 의 flush 대조 한 줄도
- b_page_state 옛 세션 열쇠 옮김(legacy · migrate) · b_project_switch 옛 키 머리
— 옛 열쇠는 세션 저장소(탭 닫으면 사라짐) · 지금 코드가 안 씀 · storage · DB 에 없음
· 시험 290-1 · 401 · 384-1 의 옛 키 줄 뺌
- 시험만 부르던 TS — planFootprint(346-2 거울은 footprintRecord + slopePieces 로 그대로)
· shiftSegment · shiftMovingPoints(쉬프트 단추는 09-02 지움 · min_cover_guard 쉬프트 시험 뺌)
· dropStationsNear(test_b05_station_merge.mjs 지움) · createIrregularStationsSection(부름 0)
· generalStations(234-1 은 기본 맞춤 값으로 같은 것 잼) · sameSummaryItem
- test_b05_b06_save_roundtrip + helper_b05_save_payload 둘 — 프로젝트 936be972 storage 없어 늘 skip
· 같은 저장 왕복은 test_472_6_design_save(창구 원자성) · test_449_save_pipes(관 PUT)
· test_238_1_save_keeps_structure_stations(구조물 측점)가 잼
남김
- migrateLegacyStations — 부르는 곳 있음(design_view_profile_sections) · storage 에 비정규 측점 남음
- common_util_excavation 셋 — 지금은 운영이 부름(design_calc_node)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+27
-54
@@ -32,8 +32,6 @@ export interface StateEntry {
|
|||||||
scope: StateScope;
|
scope: StateScope;
|
||||||
/** 형식이 바뀌면 올린다 — 옛 값을 읽지 않고 기본값으로 시작한다. */
|
/** 형식이 바뀌면 올린다 — 옛 값을 읽지 않고 기본값으로 시작한다. */
|
||||||
version?: number;
|
version?: number;
|
||||||
/** 옛 키(있으면 한 번 읽어 옮기고 지운다). `project`·`route` 범위는 함수로 받는다. */
|
|
||||||
legacy?: (projectId?: string, routeId?: number | string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,14 +46,13 @@ export const STATE_REGISTRY = {
|
|||||||
"profile-collapsed": {
|
"profile-collapsed": {
|
||||||
bucket: "pref",
|
bucket: "pref",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
legacy: () => "b05-route-profile-collapsed",
|
|
||||||
},
|
},
|
||||||
"profile-height": { bucket: "pref", scope: "global", legacy: () => "b05-route-profile-height" },
|
"profile-height": { bucket: "pref", scope: "global" },
|
||||||
/** B06 위 평면 겹침 패널 높이(436) */
|
/** B06 위 평면 겹침 패널 높이(436) */
|
||||||
"plan-peek-height": { bucket: "pref", scope: "global" },
|
"plan-peek-height": { bucket: "pref", scope: "global" },
|
||||||
/** 배수유역 판 접힘 — 기억 없음 = 닫힘(392-1 · 옛 "false" 기억 버림). */
|
/** 배수유역 판 접힘 — 기억 없음 = 닫힘(392-1 · 옛 "false" 기억 버림). */
|
||||||
"drainage-collapsed": { bucket: "pref", scope: "global", version: 2 },
|
"drainage-collapsed": { bucket: "pref", scope: "global", version: 2 },
|
||||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
"drainage-width": { bucket: "pref", scope: "global" },
|
||||||
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
||||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||||
@@ -77,26 +74,22 @@ export const STATE_REGISTRY = {
|
|||||||
"masshaul-open": {
|
"masshaul-open": {
|
||||||
bucket: "pref",
|
bucket: "pref",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
legacy: () => "b05-route-profile-masshaul-open",
|
|
||||||
},
|
},
|
||||||
"masshaul-height": {
|
"masshaul-height": {
|
||||||
bucket: "pref",
|
bucket: "pref",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
legacy: () => "b05-route-profile-masshaul-height",
|
|
||||||
},
|
},
|
||||||
/** 유토곡선 범례 — B05·B06 이 같은 값을 본다(예전에도 키를 공유했다). */
|
/** 유토곡선 범례 — B05·B06 이 같은 값을 본다(예전에도 키를 공유했다). */
|
||||||
"masshaul-visible": {
|
"masshaul-visible": {
|
||||||
bucket: "pref",
|
bucket: "pref",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
version: 6,
|
version: 6,
|
||||||
legacy: () => "b06:masshaul-visible-v4",
|
|
||||||
},
|
},
|
||||||
"table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" },
|
"table-open": { bucket: "pref", scope: "global" },
|
||||||
"table-height": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:height" },
|
"table-height": { bucket: "pref", scope: "global" },
|
||||||
"section-panel-collapsed": {
|
"section-panel-collapsed": {
|
||||||
bucket: "pref",
|
bucket: "pref",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
legacy: () => "b06:profile-panel-collapsed",
|
|
||||||
},
|
},
|
||||||
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
|
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
|
||||||
"cross-columns": { bucket: "pref", scope: "global" },
|
"cross-columns": { bucket: "pref", scope: "global" },
|
||||||
@@ -109,7 +102,6 @@ export const STATE_REGISTRY = {
|
|||||||
"section-panel-height": {
|
"section-panel-height": {
|
||||||
bucket: "pref",
|
bucket: "pref",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
legacy: () => "b06:profile-panel-height",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
|
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
|
||||||
@@ -120,21 +112,20 @@ export const STATE_REGISTRY = {
|
|||||||
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
|
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
|
||||||
"detail-table": { bucket: "draft", scope: "route" },
|
"detail-table": { bucket: "draft", scope: "route" },
|
||||||
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
|
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
|
||||||
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
|
structures: { bucket: "draft", scope: "project" },
|
||||||
/** 아직 정본에 안 넣은 추가 측점(정수 m 목록 · 368-2) — 정본 `plus_stations.json` · [저장]이 내보냄. */
|
/** 아직 정본에 안 넣은 추가 측점(정수 m 목록 · 368-2) — 정본 `plus_stations.json` · [저장]이 내보냄. */
|
||||||
"plus-stations": { bucket: "draft", scope: "project" },
|
"plus-stations": { bucket: "draft", scope: "project" },
|
||||||
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
|
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
|
||||||
uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` },
|
uphill: { bucket: "draft", scope: "project" },
|
||||||
/** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림.
|
/** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림.
|
||||||
* 예전에는 패널 메모리에만 있어 B06 으로 넘어가면 편집이 사라졌다(대응표 조사).
|
* 예전에는 패널 메모리에만 있어 B06 으로 넘어가면 편집이 사라졌다(대응표 조사).
|
||||||
* 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정] 한 본문(`pipesPart`)이
|
* 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정] 한 본문(`pipesPart`)이
|
||||||
* 내보낸 뒤 비운다. */
|
* 내보낸 뒤 비운다. */
|
||||||
pipes: { bucket: "draft", scope: "project", legacy: (p) => `b05:pipes:${p}` },
|
pipes: { bucket: "draft", scope: "project" },
|
||||||
/** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */
|
/** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */
|
||||||
"structure-pick": {
|
"structure-pick": {
|
||||||
bucket: "draft",
|
bucket: "draft",
|
||||||
scope: "project",
|
scope: "project",
|
||||||
legacy: (p) => `aislo:structure-pick:${p}`,
|
|
||||||
},
|
},
|
||||||
/** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */
|
/** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */
|
||||||
"pipe-ids": { bucket: "draft", scope: "project" },
|
"pipe-ids": { bucket: "draft", scope: "project" },
|
||||||
@@ -147,9 +138,9 @@ export const STATE_REGISTRY = {
|
|||||||
/** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */
|
/** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */
|
||||||
kindreset: { bucket: "draft", scope: "project" },
|
kindreset: { bucket: "draft", scope: "project" },
|
||||||
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
||||||
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
culvertopt: { bucket: "draft", scope: "route" },
|
||||||
/** 암 경계선 오프셋(측점별). */
|
/** 암 경계선 오프셋(측점별). */
|
||||||
rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` },
|
rockb: { bucket: "draft", scope: "route" },
|
||||||
/** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07).
|
/** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07).
|
||||||
* 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야
|
* 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야
|
||||||
* 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */
|
* 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */
|
||||||
@@ -164,24 +155,23 @@ export const STATE_REGISTRY = {
|
|||||||
* (`B06_Section_Api_Fetch` seedStandardCross) — 같은 응답의 `standard_cross_section` 은
|
* (`B06_Section_Api_Fetch` seedStandardCross) — 같은 응답의 `standard_cross_section` 은
|
||||||
* config 기본값. 고침으로 안 본다 — [폐기]에도 안 지움(`design_leave` NOT_EDITS).
|
* config 기본값. 고침으로 안 본다 — [폐기]에도 안 지움(`design_leave` NOT_EDITS).
|
||||||
* [초기화] · 추천 노선 [적용]에서 버린다. */
|
* [초기화] · 추천 노선 [적용]에서 버린다. */
|
||||||
"std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` },
|
"std-cross": { bucket: "draft", scope: "project" },
|
||||||
/** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */
|
/** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */
|
||||||
"cross-display": {
|
"cross-display": {
|
||||||
bucket: "draft",
|
bucket: "draft",
|
||||||
scope: "route",
|
scope: "route",
|
||||||
legacy: (p, r) => `b06:cross-display:${p}:${r}`,
|
|
||||||
},
|
},
|
||||||
/* 측점 조정창이 쌓는 4축·단수·연동 값 — 전부 노선 단위 초안이고 [저장]에서 함께 나간다.
|
/* 측점 조정창이 쌓는 4축·단수·연동 값 — 전부 노선 단위 초안이고 [저장]에서 함께 나간다.
|
||||||
옛 키는 `b06:{이름}:{프로젝트}:{노선}` 한 규칙이었다. */
|
옛 키는 `b06:{이름}:{프로젝트}:{노선}` 한 규칙이었다. */
|
||||||
crossw: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:crossw:${p}:${r}` },
|
crossw: { bucket: "draft", scope: "route" },
|
||||||
revetx: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetx:${p}:${r}` },
|
revetx: { bucket: "draft", scope: "route" },
|
||||||
revetlink: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetlink:${p}:${r}` },
|
revetlink: { bucket: "draft", scope: "route" },
|
||||||
basinadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:basinadjust:${p}:${r}` },
|
basinadjust: { bucket: "draft", scope: "route" },
|
||||||
inletstruct: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:inletstruct:${p}:${r}` },
|
inletstruct: { bucket: "draft", scope: "route" },
|
||||||
extrawall: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extrawall:${p}:${r}` },
|
extrawall: { bucket: "draft", scope: "route" },
|
||||||
fordadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:fordadjust:${p}:${r}` },
|
fordadjust: { bucket: "draft", scope: "route" },
|
||||||
boxadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:boxadjust:${p}:${r}` },
|
boxadjust: { bucket: "draft", scope: "route" },
|
||||||
extraspan: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extraspan:${p}:${r}` },
|
extraspan: { bucket: "draft", scope: "route" },
|
||||||
/** 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택 — 2026-09-06.
|
/** 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택 — 2026-09-06.
|
||||||
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
||||||
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
||||||
@@ -215,17 +205,17 @@ export const STATE_REGISTRY = {
|
|||||||
/** D04 연도별 지정 업체 목록(531-3) — [저장] 때 PUT plan/assignees. */
|
/** D04 연도별 지정 업체 목록(531-3) — [저장] 때 PUT plan/assignees. */
|
||||||
"d04-assignees": { bucket: "draft", scope: "project" },
|
"d04-assignees": { bucket: "draft", scope: "project" },
|
||||||
/* D02 · E 페이지 캐시(401) — 진입 · 떠남 비우기가 같은 표를 봄 · 옛 키는 한 번 옮김. */
|
/* D02 · E 페이지 캐시(401) — 진입 · 떠남 비우기가 같은 표를 봄 · 옛 키는 한 번 옮김. */
|
||||||
"d02-points": { bucket: "draft", scope: "project", legacy: (p) => `d02_points_${p}` },
|
"d02-points": { bucket: "draft", scope: "project" },
|
||||||
"e02-ground": { bucket: "draft", scope: "project", legacy: (p) => `e02_ground_cache:${p}` },
|
"e02-ground": { bucket: "draft", scope: "project" },
|
||||||
"e03-prob": { bucket: "draft", scope: "project", legacy: (p) => `e03_prob_cache:${p}` },
|
"e03-prob": { bucket: "draft", scope: "project" },
|
||||||
"e04-settings": { bucket: "draft", scope: "project", legacy: (p) => `e04_settings:${p}` },
|
"e04-settings": { bucket: "draft", scope: "project" },
|
||||||
"e05-report": { bucket: "draft", scope: "project", legacy: (p) => `e05_report_cache:${p}` },
|
"e05-report": { bucket: "draft", scope: "project" },
|
||||||
"e06-rock": { bucket: "draft", scope: "project", legacy: (p) => `e06_rock_cache:${p}` },
|
"e06-rock": { bucket: "draft", scope: "project" },
|
||||||
"e07-settings": { bucket: "draft", scope: "project", legacy: (p) => `e07_settings:${p}` },
|
"e07-settings": { bucket: "draft", scope: "project" },
|
||||||
|
|
||||||
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
|
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
|
||||||
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
|
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
|
||||||
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
|
latest: { bucket: "result", scope: "project" },
|
||||||
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
|
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
|
||||||
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
|
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
|
||||||
* 노선이 바뀌면 `latest` 와 함께 버린다. */
|
* 노선이 바뀌면 `latest` 와 함께 버린다. */
|
||||||
@@ -236,13 +226,11 @@ export const STATE_REGISTRY = {
|
|||||||
"std-cross-default": {
|
"std-cross-default": {
|
||||||
bucket: "result",
|
bucket: "result",
|
||||||
scope: "project",
|
scope: "project",
|
||||||
legacy: (p) => `b06:std-cross-default:${p}`,
|
|
||||||
},
|
},
|
||||||
/** 서버가 준 암 경계선 기본 오프셋 — 위와 같은 성격. */
|
/** 서버가 준 암 경계선 기본 오프셋 — 위와 같은 성격. */
|
||||||
"rock-boundary-default": {
|
"rock-boundary-default": {
|
||||||
bucket: "result",
|
bucket: "result",
|
||||||
scope: "project",
|
scope: "project",
|
||||||
legacy: (p) => `b06:rock-boundary-default:${p}`,
|
|
||||||
},
|
},
|
||||||
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
|
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
|
||||||
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
|
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
|
||||||
@@ -487,20 +475,6 @@ function setOrMakeRoom(store: Storage, key: string, value: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */
|
|
||||||
function migrate(
|
|
||||||
name: StateName,
|
|
||||||
key: string,
|
|
||||||
projectId?: string | null,
|
|
||||||
routeId?: number | string | null,
|
|
||||||
): void {
|
|
||||||
const legacy = entryOf(name)?.legacy?.(projectId ?? undefined, routeId ?? undefined);
|
|
||||||
if (!legacy || legacy === key) return;
|
|
||||||
const old = readRaw(legacy);
|
|
||||||
if (old !== null && readRaw(key) === null) writeRaw(key, old);
|
|
||||||
if (old !== null) writeRaw(legacy, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 문자열 그대로 읽는다(숫자·참거짓처럼 JSON 이 아닌 값). */
|
/** 문자열 그대로 읽는다(숫자·참거짓처럼 JSON 이 아닌 값). */
|
||||||
export function readStateRaw(
|
export function readStateRaw(
|
||||||
name: StateName,
|
name: StateName,
|
||||||
@@ -509,7 +483,6 @@ export function readStateRaw(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const key = stateKey(name, projectId, routeId);
|
const key = stateKey(name, projectId, routeId);
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
migrate(name, key, projectId, routeId);
|
|
||||||
return readRaw(key);
|
return readRaw(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ export function reloadIfOtherProject(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MARK_P = "\u0001";
|
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`). 초안 여부를 함께. */
|
||||||
const MARK_R = "\u0002";
|
|
||||||
|
|
||||||
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */
|
|
||||||
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
|
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
|
||||||
const out: Array<{ prefix: string; draft: boolean }> = [];
|
const out: Array<{ prefix: string; draft: boolean }> = [];
|
||||||
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
|
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
|
||||||
@@ -76,9 +73,6 @@ function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
|
|||||||
const draft = entry.bucket === "draft";
|
const draft = entry.bucket === "draft";
|
||||||
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
||||||
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
|
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
|
||||||
const legacy = entry.legacy?.(MARK_P, MARK_R);
|
|
||||||
const at = legacy?.indexOf(MARK_P) ?? -1;
|
|
||||||
if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft });
|
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,14 +173,10 @@ type MemoryKey = keyof typeof MEMORY_SLOTS;
|
|||||||
/** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */
|
/** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */
|
||||||
export const PERSIST_DELAY_MS = 1000;
|
export const PERSIST_DELAY_MS = 1000;
|
||||||
const memory = new Map<string, unknown>();
|
const memory = new Map<string, unknown>();
|
||||||
const persistTimers = new Map<
|
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
string,
|
|
||||||
{ timer: ReturnType<typeof setTimeout>; write: () => void }
|
|
||||||
>();
|
|
||||||
|
|
||||||
function cancelPersist(storageKey: string): void {
|
function cancelPersist(storageKey: string): void {
|
||||||
const pending = persistTimers.get(storageKey);
|
clearTimeout(persistTimers.get(storageKey));
|
||||||
if (pending) clearTimeout(pending.timer);
|
|
||||||
persistTimers.delete(storageKey);
|
persistTimers.delete(storageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,14 +185,6 @@ onStateRemoved((storageKey) => {
|
|||||||
cancelPersist(storageKey);
|
cancelPersist(storageKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 미뤄 둔 세션 쓰기를 지금 — [저장] 앞 · 시험. */
|
|
||||||
export function flushDesignStore(): void {
|
|
||||||
const writes = [...persistTimers.values()];
|
|
||||||
persistTimers.forEach((pending) => clearTimeout(pending.timer));
|
|
||||||
persistTimers.clear();
|
|
||||||
writes.forEach((pending) => pending.write());
|
|
||||||
}
|
|
||||||
|
|
||||||
function memorySlot<T>(key: MemoryKey, projectId: string | null): Slot<T> {
|
function memorySlot<T>(key: MemoryKey, projectId: string | null): Slot<T> {
|
||||||
const name = MEMORY_SLOTS[key];
|
const name = MEMORY_SLOTS[key];
|
||||||
const storageKey = name ? stateKey(name, projectId) : projectId && `memory:${key}:${projectId}`;
|
const storageKey = name ? stateKey(name, projectId) : projectId && `memory:${key}:${projectId}`;
|
||||||
@@ -223,7 +211,7 @@ function memorySlot<T>(key: MemoryKey, projectId: string | null): Slot<T> {
|
|||||||
persistTimers.delete(storageKey);
|
persistTimers.delete(storageKey);
|
||||||
writeState(name, memory.get(storageKey) ?? null, projectId);
|
writeState(name, memory.get(storageKey) ?? null, projectId);
|
||||||
};
|
};
|
||||||
persistTimers.set(storageKey, { timer: setTimeout(write, PERSIST_DELAY_MS), write });
|
persistTimers.set(storageKey, setTimeout(write, PERSIST_DELAY_MS));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,20 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B05_Profile_UI_IrregularStations.ts
|
* B05_Profile_UI_IrregularStations.ts
|
||||||
* 비정규 측점(구조물 측점) 입력·목록·편집 사이드바 섹션.
|
* 비정규 측점(구조물 측점) 꼴 · 배수관 판정 · 표시 라벨 — 종단 그래프 · 표 · 3D 가 같이 씀.
|
||||||
*
|
*
|
||||||
* 사용자가 규칙 격자(측점 간격) 밖의 임의 위치(측점번호 X + 잔여거리 XX)에 구조물 측점을
|
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m).
|
||||||
* 추가한다. 지금은 구조물을 자유 텍스트로 받고, 값은 클라이언트에만 보관한다(백엔드 미영속).
|
|
||||||
* 추가/수정/삭제/리셋이 일어날 때마다 `onChange`로 전체 목록을 알려 Page가 그래프·테이블·3D에
|
|
||||||
* 반영한다. 목록에서 항목을 고르면 `onSelect`로 chainage를 알려 하이라이트에 쓴다.
|
|
||||||
*
|
|
||||||
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
|
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
|
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
|
||||||
import {
|
import {
|
||||||
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
|
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
|
||||||
ESCAPE_ROUTE_WIDTHS_M,
|
|
||||||
PIPE_DEFAULT_DIAMETER_MM,
|
PIPE_DEFAULT_DIAMETER_MM,
|
||||||
PIPE_DEFAULT_TYPE,
|
PIPE_DEFAULT_TYPE,
|
||||||
PIPE_DIAMETERS_MM,
|
|
||||||
PIPE_TYPES,
|
|
||||||
STRUCTURE_ETC_DEFAULT_NAME,
|
STRUCTURE_ETC_DEFAULT_NAME,
|
||||||
STRUCTURE_TYPES,
|
|
||||||
pickPipeDiameter,
|
|
||||||
type StructureType,
|
type StructureType,
|
||||||
} from "@config/config_frontend";
|
} from "@config/config_frontend";
|
||||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
|
||||||
|
|
||||||
// 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
// 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||||
import { irregularLabel } from "./logic/B05_Profile_Profile_Planted";
|
|
||||||
export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted";
|
export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted";
|
||||||
|
|
||||||
export interface IrregularStation {
|
export interface IrregularStation {
|
||||||
@@ -98,473 +86,3 @@ export function structureLabel(entry: {
|
|||||||
return entry.structure ?? "";
|
return entry.structure ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IrregularStationsSection {
|
|
||||||
root: HTMLElement;
|
|
||||||
getStations: () => IrregularStation[];
|
|
||||||
/** chainage로 목록 항목을 골라 폼에 로드한다(그래프·3D에서 선택 시). null이면 선택 해제. */
|
|
||||||
selectByChainage: (chainageM: number | null) => void;
|
|
||||||
/** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */
|
|
||||||
setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void;
|
|
||||||
/** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다.
|
|
||||||
* effective_diameter_mm(배수 유효직경)를 주면 그 이상 첫 규격을 자동 지정한다(기본 D800). */
|
|
||||||
setPipeStations: (
|
|
||||||
pipes: ReadonlyArray<number | { chainage_m: number; effective_diameter_mm?: number | null }>,
|
|
||||||
) => void;
|
|
||||||
/** 우클릭 메뉴에서 배관 외 구조물을 기본 옵션으로 추가한다(종단·배수유역도 공용). */
|
|
||||||
addStructure: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => void;
|
|
||||||
/** 누가거리로 항목을 찾아 지운다(종단 테이블 우클릭). 지웠으면 그 항목을 돌려준다. */
|
|
||||||
removeByChainage: (chainageM: number) => IrregularStation | null;
|
|
||||||
/** 누가거리로 항목을 옮긴다(종단 테이블 라인 드래그). 옮겼으면 true. */
|
|
||||||
moveByChainage: (fromChainageM: number, toChainageM: number) => boolean;
|
|
||||||
clear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IrregularStationsCallbacks {
|
|
||||||
/** 측점간격(m). chainage 환산에 쓴다. */
|
|
||||||
getInterval: () => number;
|
|
||||||
/** 목록이 바뀔 때(추가·수정·삭제·리셋) 전체 목록을 넘긴다. */
|
|
||||||
onChange: (stations: IrregularStation[]) => void;
|
|
||||||
/** 목록에서 항목을 선택/해제할 때 해당 측점(또는 null)을 넘긴다. */
|
|
||||||
onSelect: (station: IrregularStation | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
|
||||||
const wrapper = document.createElement("label");
|
|
||||||
wrapper.className = "b05-route__field";
|
|
||||||
const caption = document.createElement("span");
|
|
||||||
caption.textContent = labelText;
|
|
||||||
wrapper.append(caption, input);
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
function numberInput(step: string, min: string): HTMLInputElement {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.className = "ui-input";
|
|
||||||
input.type = "number";
|
|
||||||
input.step = step;
|
|
||||||
input.min = min;
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectInput(options: ReadonlyArray<string>): HTMLSelectElement {
|
|
||||||
const select = document.createElement("select");
|
|
||||||
select.className = "ui-select";
|
|
||||||
select.replaceChildren(...options.map((value) => new Option(value, value)));
|
|
||||||
return select;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createIrregularStationsSection(
|
|
||||||
callbacks: IrregularStationsCallbacks,
|
|
||||||
): IrregularStationsSection {
|
|
||||||
const root = document.createElement("section");
|
|
||||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
|
||||||
root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section";
|
|
||||||
const heading = document.createElement("h3");
|
|
||||||
heading.className = "ui-collapsible__title";
|
|
||||||
heading.textContent = "구조물 배치";
|
|
||||||
const body = document.createElement("div");
|
|
||||||
body.className = "b05-route__panel-body";
|
|
||||||
root.append(heading, body);
|
|
||||||
|
|
||||||
const stationField = numberInput("1", "0");
|
|
||||||
stationField.placeholder = "측점번호";
|
|
||||||
const remainderField = numberInput("0.1", "0");
|
|
||||||
remainderField.placeholder = "잔여거리";
|
|
||||||
|
|
||||||
// 구조물 종류 + 종류별 하위 옵션 (2026-08-05 사용자 확정. 목록은 config_frontend가 정본).
|
|
||||||
// 첫 항목은 빈칸 — 리셋 시 "선택 안 됨" 상태를 표현한다(빈칸이면 하위 옵션 전체 숨김).
|
|
||||||
const typeSelect = selectInput(["", ...STRUCTURE_TYPES]);
|
|
||||||
const pipeTypeSelect = selectInput(PIPE_TYPES);
|
|
||||||
const diameterSelect = document.createElement("select");
|
|
||||||
diameterSelect.className = "ui-select";
|
|
||||||
const widthSelect = selectInput(ESCAPE_ROUTE_WIDTHS_M.map((width) => width.toFixed(1)));
|
|
||||||
const nameField = document.createElement("input");
|
|
||||||
nameField.className = "ui-input";
|
|
||||||
nameField.type = "text";
|
|
||||||
nameField.placeholder = STRUCTURE_ETC_DEFAULT_NAME;
|
|
||||||
|
|
||||||
const pipeTypeField = field("관종", pipeTypeSelect);
|
|
||||||
const diameterField = field("직경", diameterSelect);
|
|
||||||
const widthField = field("폭 (m)", widthSelect);
|
|
||||||
const nameFieldWrap = field("이름", nameField);
|
|
||||||
const subRow = document.createElement("div");
|
|
||||||
subRow.className = "b05-route__irregular-row";
|
|
||||||
subRow.append(pipeTypeField, diameterField, widthField, nameFieldWrap);
|
|
||||||
|
|
||||||
/** 관종에 맞는 직경 목록으로 갈아 끼운다. 가능하면 현재 선택을 유지한다. */
|
|
||||||
function syncDiameterOptions(keep?: number): void {
|
|
||||||
const sizes = PIPE_DIAMETERS_MM[pipeTypeSelect.value] ?? PIPE_DIAMETERS_MM[PIPE_DEFAULT_TYPE];
|
|
||||||
const wanted = keep ?? (Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM);
|
|
||||||
diameterSelect.replaceChildren(
|
|
||||||
...sizes.map((size) => new Option(`D${size}`, String(size), false, size === wanted)),
|
|
||||||
);
|
|
||||||
if (!sizes.includes(wanted))
|
|
||||||
diameterSelect.value = String(pickPipeDiameter(pipeTypeSelect.value, wanted));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 종류에 따라 보이는 하위 옵션을 바꾼다 — 배관: 관종+직경 / 대피로: 폭 / 기타: 이름.
|
|
||||||
* 빈칸(선택 안 됨)이면 하위 옵션 전체를 숨긴다(리셋 직후 상태). */
|
|
||||||
function syncSubOptions(): void {
|
|
||||||
const type = typeSelect.value as StructureType | "";
|
|
||||||
pipeTypeField.hidden = diameterField.hidden = type !== "배관";
|
|
||||||
widthField.hidden = type !== "대피로";
|
|
||||||
nameFieldWrap.hidden = type !== "기타";
|
|
||||||
// 기성막이는 하위 옵션 미정(추가반영 예정), 빈칸은 아무것도 안 보인다.
|
|
||||||
subRow.hidden = type === "기성막이" || type === "";
|
|
||||||
}
|
|
||||||
typeSelect.addEventListener("change", () => {
|
|
||||||
syncSubOptions();
|
|
||||||
// 편집 중 구조물 타입을 바꾸면 기존 항목 수정이 아니라 **신규 추가**로 전환한다
|
|
||||||
// (2026-08-05 사용자 지시). 입력값은 그대로 두고 편집 상태만 푼다.
|
|
||||||
if (editingId) {
|
|
||||||
editingId = null;
|
|
||||||
syncButtons();
|
|
||||||
renderList();
|
|
||||||
callbacks.onSelect(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
pipeTypeSelect.addEventListener("change", () => syncDiameterOptions());
|
|
||||||
typeSelect.value = ""; // 초기·리셋 상태는 빈칸(2026-08-05 사용자 지시)
|
|
||||||
pipeTypeSelect.value = PIPE_DEFAULT_TYPE;
|
|
||||||
widthSelect.value = ESCAPE_ROUTE_DEFAULT_WIDTH_M.toFixed(1);
|
|
||||||
syncDiameterOptions(PIPE_DEFAULT_DIAMETER_MM);
|
|
||||||
syncSubOptions();
|
|
||||||
|
|
||||||
const stationRow = document.createElement("div");
|
|
||||||
stationRow.className = "b05-route__irregular-row";
|
|
||||||
stationRow.append(field("측점번호", stationField), field("잔여거리 (m)", remainderField));
|
|
||||||
|
|
||||||
const primary = document.createElement("button");
|
|
||||||
primary.type = "button";
|
|
||||||
primary.className = "ui-btn ui-btn--filled b05-route__irregular-btn is-primary";
|
|
||||||
const remove = document.createElement("button");
|
|
||||||
remove.type = "button";
|
|
||||||
remove.className = "ui-btn ui-btn--danger-ghost b05-route__irregular-btn is-danger";
|
|
||||||
remove.textContent = "삭제";
|
|
||||||
const reset = document.createElement("button");
|
|
||||||
reset.type = "button";
|
|
||||||
reset.className = "ui-btn ui-btn--ghost b05-route__irregular-btn";
|
|
||||||
reset.textContent = "리셋";
|
|
||||||
const actions = document.createElement("div");
|
|
||||||
actions.className = "b05-route__irregular-actions";
|
|
||||||
actions.append(primary, remove, reset);
|
|
||||||
|
|
||||||
const list = document.createElement("ul");
|
|
||||||
list.className = "b05-route__irregular-list";
|
|
||||||
const help = document.createElement("p");
|
|
||||||
help.className = "ui-text ui-text--muted b05-route__note"; // 부품 보조 글(553-2)
|
|
||||||
help.textContent =
|
|
||||||
"구조물을 설치할 위치를 측점번호+잔여거리로 추가합니다. 목록에서 고르면 수정·삭제할 수 있습니다.";
|
|
||||||
|
|
||||||
// 측점번호/잔여거리를 최상단에 둔다(위치가 먼저, 그다음 구조물 종류·하위 옵션).
|
|
||||||
body.append(stationRow, field("구조물", typeSelect), subRow, actions, list, help);
|
|
||||||
|
|
||||||
const stations: IrregularStation[] = [];
|
|
||||||
let editingId: string | null = null;
|
|
||||||
let nextId = 1;
|
|
||||||
|
|
||||||
function chainageOf(station: number, remainder: number): number {
|
|
||||||
const interval = callbacks.getInterval();
|
|
||||||
return station * (interval > 0 ? interval : 20) + remainder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 누가거리를 측점번호+잔여거리로 되돌린다(외부 주입·이동 공용). */
|
|
||||||
function splitChainage(chainageM: number): { station: number; remainder: number } {
|
|
||||||
const interval = intervalMax();
|
|
||||||
const station = Math.floor((chainageM + 1e-6) / interval);
|
|
||||||
return {
|
|
||||||
station,
|
|
||||||
remainder: Number((chainageM - station * interval).toFixed(3)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncButtons(): void {
|
|
||||||
primary.textContent = editingId ? "수정" : "추가";
|
|
||||||
remove.disabled = editingId === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadForm(target: IrregularStation | null): void {
|
|
||||||
editingId = target?.id ?? null;
|
|
||||||
stationField.value = target ? String(target.station) : "";
|
|
||||||
remainderField.value = target ? String(target.remainder) : "";
|
|
||||||
// 구버전 항목(structureType 없음)은 "기타"로 열고 이름칸에 기존 텍스트를 옮긴다.
|
|
||||||
// 선택 해제·리셋이면 종류도 빈칸으로 — 하위 옵션이 전부 숨는다(2026-08-05 사용자 지시).
|
|
||||||
const type = target ? (target.structureType ?? "기타") : "";
|
|
||||||
typeSelect.value = type;
|
|
||||||
pipeTypeSelect.value = target?.pipeType ?? PIPE_DEFAULT_TYPE;
|
|
||||||
syncDiameterOptions(target?.diameterMm ?? PIPE_DEFAULT_DIAMETER_MM);
|
|
||||||
widthSelect.value = (target?.escapeWidthM ?? ESCAPE_ROUTE_DEFAULT_WIDTH_M).toFixed(1);
|
|
||||||
nameField.value = target
|
|
||||||
? (target.customName ?? (target.structureType ? "" : target.structure))
|
|
||||||
: "";
|
|
||||||
// 배수유역 자동 배관(정본 투영)은 관종·직경만 바꿀 수 있다 — 타입 자체는 잠근다
|
|
||||||
// (2026-08-05 사용자 지시. 배관을 다른 구조물로 바꾸면 관 지점 정본과 어긋난다).
|
|
||||||
typeSelect.disabled = !!target && isPipeStation(target);
|
|
||||||
syncSubOptions();
|
|
||||||
syncButtons();
|
|
||||||
// 선택 강조(is-selected)가 클릭 즉시 따라오도록 목록을 다시 그린다(토글 해제 포함).
|
|
||||||
renderList();
|
|
||||||
callbacks.onSelect(target ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderList(): void {
|
|
||||||
list.replaceChildren();
|
|
||||||
if (!stations.length) {
|
|
||||||
// 부품 빈 상태 작은 꼴(553-2)
|
|
||||||
const empty = document.createElement("li");
|
|
||||||
empty.className = "ui-empty ui-empty--small b05-route__irregular-empty";
|
|
||||||
const text = document.createElement("p");
|
|
||||||
text.className = "ui-empty__text";
|
|
||||||
text.textContent = "추가된 비정규 측점이 없습니다.";
|
|
||||||
empty.append(text);
|
|
||||||
list.append(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
[...stations]
|
|
||||||
.sort((a, b) => a.chainage_m - b.chainage_m)
|
|
||||||
.forEach((station) => {
|
|
||||||
const item = document.createElement("li");
|
|
||||||
item.className = "b05-route__irregular-item";
|
|
||||||
item.classList.toggle("is-selected", station.id === editingId);
|
|
||||||
const name = document.createElement("strong");
|
|
||||||
name.textContent = irregularLabel(station);
|
|
||||||
const info = document.createElement("span");
|
|
||||||
info.textContent = station.structure || "(구조물 미입력)";
|
|
||||||
item.append(name, info);
|
|
||||||
// 같은 항목 재클릭 = 선택 해제(2026-08-04 사용자 지시) — 그래프·3D 강조도 함께 풀린다.
|
|
||||||
item.addEventListener("click", () => loadForm(station.id === editingId ? null : station));
|
|
||||||
list.append(item);
|
|
||||||
// 목록이 5개를 넘으면 내부 스크롤이 생긴다 — 선택(=수정 중) 항목이 스크롤 밖에
|
|
||||||
// 있으면 안 보이므로, 렌더 직후 그 항목만 보이는 위치로 데려온다.
|
|
||||||
if (station.id === editingId) item.scrollIntoView({ block: "nearest" });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 잔여거리는 측점 간격을 넘을 수 없다(넘으면 다음 측점이 됨). max·검증에 함께 쓴다. */
|
|
||||||
function intervalMax(): number {
|
|
||||||
const interval = callbacks.getInterval();
|
|
||||||
return interval > 0 ? interval : 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
function commit(): void {
|
|
||||||
const station = Number.parseInt(stationField.value, 10);
|
|
||||||
const remainder = Number.parseFloat(remainderField.value || "0");
|
|
||||||
if (!Number.isFinite(station) || station < 0) {
|
|
||||||
stationField.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 잔여거리는 [0, 측점간격] 범위로 클램프한다.
|
|
||||||
const safeRemainder = Number.isFinite(remainder)
|
|
||||||
? Math.min(Math.max(remainder, 0), intervalMax())
|
|
||||||
: 0;
|
|
||||||
// 종류 미선택(빈칸)이면 추가하지 않는다 — 리셋 직후 상태.
|
|
||||||
if (!typeSelect.value) {
|
|
||||||
typeSelect.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 드롭다운 선택값 → 옵션 필드 + 표시 라벨. 종류에 안 쓰는 필드는 넣지 않는다.
|
|
||||||
const structureType = typeSelect.value as StructureType;
|
|
||||||
const options: Partial<IrregularStation> = { structureType };
|
|
||||||
if (structureType === "배관") {
|
|
||||||
options.pipeType = pipeTypeSelect.value;
|
|
||||||
options.diameterMm = Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM;
|
|
||||||
} else if (structureType === "대피로") {
|
|
||||||
options.escapeWidthM = Number(widthSelect.value) || ESCAPE_ROUTE_DEFAULT_WIDTH_M;
|
|
||||||
} else if (structureType === "기타") {
|
|
||||||
options.customName = nameField.value.trim() || STRUCTURE_ETC_DEFAULT_NAME;
|
|
||||||
}
|
|
||||||
const structure = structureLabel(options);
|
|
||||||
const chainage_m = chainageOf(station, safeRemainder);
|
|
||||||
if (editingId) {
|
|
||||||
// 그 자리에서 변경(Object.assign)하면 Page가 보관한 이전 목록의 객체도 함께 바뀌어
|
|
||||||
// "이동 전 chainage"를 잃는다 → 옛 위치의 계획고·곡선 편집을 정리하지 못한다.
|
|
||||||
// 새 객체로 교체해, Page가 이전 위치를 감지하고 그 편집을 지우게 한다.
|
|
||||||
const index = stations.findIndex((entry) => entry.id === editingId);
|
|
||||||
if (index >= 0) {
|
|
||||||
const previous = stations[index];
|
|
||||||
// 배관 관종·직경을 손으로 바꿨으면 자동 지정이 다시 덮지 않게 표시한다.
|
|
||||||
const userSized =
|
|
||||||
structureType === "배관" &&
|
|
||||||
(previous.userSized ||
|
|
||||||
previous.pipeType !== options.pipeType ||
|
|
||||||
previous.diameterMm !== options.diameterMm);
|
|
||||||
stations[index] = {
|
|
||||||
...previous,
|
|
||||||
id: editingId,
|
|
||||||
station,
|
|
||||||
remainder: safeRemainder,
|
|
||||||
chainage_m,
|
|
||||||
structure,
|
|
||||||
...options,
|
|
||||||
userSized,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stations.push({
|
|
||||||
id: String(nextId++),
|
|
||||||
station,
|
|
||||||
remainder: safeRemainder,
|
|
||||||
chainage_m,
|
|
||||||
structure,
|
|
||||||
...options,
|
|
||||||
origin: "user",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
loadForm(null);
|
|
||||||
renderList();
|
|
||||||
callbacks.onChange([...stations]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 측점 간격은 옵션에서 바뀔 수 있으므로, 잔여거리의 max를 입력 직전에 현재 간격으로 맞춘다.
|
|
||||||
const syncRemainderMax = (): void => {
|
|
||||||
remainderField.max = String(intervalMax());
|
|
||||||
};
|
|
||||||
remainderField.addEventListener("focus", syncRemainderMax);
|
|
||||||
remainderField.addEventListener("input", syncRemainderMax);
|
|
||||||
syncRemainderMax();
|
|
||||||
|
|
||||||
primary.addEventListener("click", commit);
|
|
||||||
remove.addEventListener("click", () => {
|
|
||||||
if (!editingId) return;
|
|
||||||
const index = stations.findIndex((entry) => entry.id === editingId);
|
|
||||||
if (index >= 0) stations.splice(index, 1);
|
|
||||||
loadForm(null);
|
|
||||||
renderList();
|
|
||||||
callbacks.onChange([...stations]);
|
|
||||||
});
|
|
||||||
reset.addEventListener("click", () => loadForm(null));
|
|
||||||
|
|
||||||
syncButtons();
|
|
||||||
renderList();
|
|
||||||
|
|
||||||
return {
|
|
||||||
root,
|
|
||||||
getStations: () => [...stations],
|
|
||||||
selectByChainage(chainageM) {
|
|
||||||
if (chainageM === null) {
|
|
||||||
loadForm(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = stations.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6);
|
|
||||||
loadForm(target ?? null);
|
|
||||||
},
|
|
||||||
setStations(seed) {
|
|
||||||
stations.length = 0;
|
|
||||||
seed.forEach((entry) => {
|
|
||||||
const { station, remainder } = splitChainage(entry.chainage_m);
|
|
||||||
stations.push({
|
|
||||||
id: String(nextId++),
|
|
||||||
station,
|
|
||||||
remainder,
|
|
||||||
chainage_m: entry.chainage_m,
|
|
||||||
structure: entry.structure,
|
|
||||||
origin: "user",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
loadForm(null);
|
|
||||||
renderList();
|
|
||||||
callbacks.onChange([...stations]);
|
|
||||||
},
|
|
||||||
setPipeStations(pipes) {
|
|
||||||
// 배관 항목은 통째로 갈아 끼운다 — 정본은 배수유역도의 관 목록이다.
|
|
||||||
// 단, 사용자가 관종·직경을 손으로 바꾼 항목(userSized)은 같은 자리에 다시 올 때 유지한다.
|
|
||||||
const previousPipes = stations.filter(isPipeStation);
|
|
||||||
const incomingChainages = pipes.map((pipe) =>
|
|
||||||
typeof pipe === "number" ? pipe : pipe.chainage_m,
|
|
||||||
);
|
|
||||||
for (let index = stations.length - 1; index >= 0; index -= 1) {
|
|
||||||
const entry = stations[index];
|
|
||||||
// 경로확정 정본(종단 병합분)에서 복원된 항목은 구조물 종류(structureType)가 없다.
|
|
||||||
// 그 중 정본 관과 같은 자리(±0.5m)인 것은 관의 **복원 유령**이다 — 지금 투영되는
|
|
||||||
// 정본 관이 진짜이므로 걷어낸다. 같은 배관이 목록에 2개씩 뜨던 원인(2026-08-06
|
|
||||||
// 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다.
|
|
||||||
const restoredGhost =
|
|
||||||
entry.structureType === undefined &&
|
|
||||||
incomingChainages.some(
|
|
||||||
(chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
|
|
||||||
);
|
|
||||||
if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1);
|
|
||||||
}
|
|
||||||
pipes.forEach((pipe) => {
|
|
||||||
const chainage = typeof pipe === "number" ? pipe : pipe.chainage_m;
|
|
||||||
const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null);
|
|
||||||
const { station, remainder } = splitChainage(chainage);
|
|
||||||
const kept = previousPipes.find(
|
|
||||||
(entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
|
|
||||||
);
|
|
||||||
const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE;
|
|
||||||
const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective);
|
|
||||||
const entry: IrregularStation = {
|
|
||||||
id: String(nextId++),
|
|
||||||
station,
|
|
||||||
remainder,
|
|
||||||
chainage_m: chainage,
|
|
||||||
structure: "",
|
|
||||||
structureType: PIPE_STRUCTURE_NAME,
|
|
||||||
pipeType,
|
|
||||||
diameterMm,
|
|
||||||
userSized: kept?.userSized ?? false,
|
|
||||||
origin: "pipe",
|
|
||||||
};
|
|
||||||
entry.structure = structureLabel(entry);
|
|
||||||
stations.push(entry);
|
|
||||||
});
|
|
||||||
if (editingId && !stations.some((entry) => entry.id === editingId)) loadForm(null);
|
|
||||||
renderList();
|
|
||||||
callbacks.onChange([...stations]);
|
|
||||||
},
|
|
||||||
addStructure(chainageM, structureType) {
|
|
||||||
const { station, remainder } = splitChainage(chainageM);
|
|
||||||
const entry: IrregularStation = {
|
|
||||||
id: String(nextId++),
|
|
||||||
station,
|
|
||||||
remainder,
|
|
||||||
chainage_m: chainageM,
|
|
||||||
structure: "",
|
|
||||||
structureType,
|
|
||||||
origin: "user",
|
|
||||||
};
|
|
||||||
if (structureType === "대피로") entry.escapeWidthM = ESCAPE_ROUTE_DEFAULT_WIDTH_M;
|
|
||||||
if (structureType === "기타") entry.customName = STRUCTURE_ETC_DEFAULT_NAME;
|
|
||||||
entry.structure = structureLabel(entry);
|
|
||||||
stations.push(entry);
|
|
||||||
renderList();
|
|
||||||
callbacks.onChange([...stations]);
|
|
||||||
},
|
|
||||||
removeByChainage(chainageM) {
|
|
||||||
const index = stations.findIndex(
|
|
||||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
|
||||||
);
|
|
||||||
if (index < 0) return null;
|
|
||||||
const [removed] = stations.splice(index, 1);
|
|
||||||
if (editingId === removed.id) loadForm(null);
|
|
||||||
renderList();
|
|
||||||
callbacks.onChange([...stations]);
|
|
||||||
return removed;
|
|
||||||
},
|
|
||||||
moveByChainage(fromChainageM, toChainageM) {
|
|
||||||
const index = stations.findIndex(
|
|
||||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
|
||||||
);
|
|
||||||
if (index < 0) return false;
|
|
||||||
const { station, remainder } = splitChainage(toChainageM);
|
|
||||||
// 그 자리에서 고치지 않고 새 객체로 교체한다 — Page가 옛 위치의 계획고 편집을 정리해야 한다.
|
|
||||||
stations[index] = {
|
|
||||||
...stations[index],
|
|
||||||
station,
|
|
||||||
remainder,
|
|
||||||
chainage_m: toChainageM,
|
|
||||||
};
|
|
||||||
renderList();
|
|
||||||
callbacks.onChange([...stations]);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
stations.length = 0;
|
|
||||||
loadForm(null);
|
|
||||||
renderList();
|
|
||||||
callbacks.onChange([]);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -608,20 +608,6 @@ export function slopePieces(
|
|||||||
return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks };
|
return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 측점들 → 평면 발자국(도로 끝이 없는 측점은 뺌). */
|
|
||||||
export function planFootprint(
|
|
||||||
route: Array<[number, number, number]>,
|
|
||||||
stations: FootprintStation[],
|
|
||||||
startM: number,
|
|
||||||
endM: number,
|
|
||||||
stepM: number = TICK_STEP_M,
|
|
||||||
): SlopePieces {
|
|
||||||
const records = stations
|
|
||||||
.map(footprintRecord)
|
|
||||||
.filter((record): record is FootprintRecord => record !== null);
|
|
||||||
return slopePieces(route, records, startM, endM, stepM);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 계약 `PlanFootprint` + 비탈 끝 점마다 깎기 여부 · 눈금 · 측점 줄(다음 계산이 범위 밖을 재사용). */
|
/** 계약 `PlanFootprint` + 비탈 끝 점마다 깎기 여부 · 눈금 · 측점 줄(다음 계산이 범위 밖을 재사용). */
|
||||||
export interface FootprintLines extends PlanFootprint {
|
export interface FootprintLines extends PlanFootprint {
|
||||||
slopeLeftCut: boolean[];
|
slopeLeftCut: boolean[];
|
||||||
|
|||||||
@@ -1,87 +1,15 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts
|
* B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts
|
||||||
* 종단 계획선 편집 도구 — 구간 평행이동(쉬프트) · 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts`
|
* 종단 계획선 편집 도구 — 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts`
|
||||||
* 에서 나눔(700줄 · 511-B B-3) · 선형 짓기(`buildAlignment`)는 그 파일.
|
* 에서 나눔(700줄 · 511-B B-3) · 선형 짓기(`buildAlignment`)는 그 파일.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildAlignment,
|
|
||||||
chainageKey,
|
chainageKey,
|
||||||
interpolate,
|
|
||||||
planElevationAt,
|
|
||||||
type AlignmentBase,
|
|
||||||
type AlignmentCurve,
|
type AlignmentCurve,
|
||||||
type AlignmentEdits,
|
type AlignmentEdits,
|
||||||
type AlignmentSegment,
|
|
||||||
type ProfileAlignment,
|
|
||||||
} from "./B05_Profile_UI_Profile_Alignment";
|
} from "./B05_Profile_UI_Profile_Alignment";
|
||||||
|
|
||||||
const FEEDBACK_PASSES = 4;
|
|
||||||
const FEEDBACK_TOLERANCE_M = 1e-4;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
|
|
||||||
*
|
|
||||||
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
|
|
||||||
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
|
|
||||||
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
|
|
||||||
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
|
|
||||||
* 안쪽 미틸트 측점 2개 미만).
|
|
||||||
*/
|
|
||||||
export function shiftMovingPoints(
|
|
||||||
alignment: ProfileAlignment,
|
|
||||||
segment: AlignmentSegment,
|
|
||||||
): [number, number] | null {
|
|
||||||
const offsets = alignment.edits.station_offsets;
|
|
||||||
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
|
|
||||||
const interior = alignment.stations
|
|
||||||
.map((station) => station.chainage_m)
|
|
||||||
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
|
|
||||||
const left = movable(segment.from_m) ? segment.from_m : interior[0];
|
|
||||||
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
|
|
||||||
if (left === undefined || right === undefined) return null;
|
|
||||||
if (right - left < 1e-6) return null;
|
|
||||||
return [left, right];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
|
|
||||||
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
|
|
||||||
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
|
|
||||||
*/
|
|
||||||
export function shiftSegment(
|
|
||||||
base: AlignmentBase,
|
|
||||||
edits: AlignmentEdits,
|
|
||||||
segment: AlignmentSegment,
|
|
||||||
delta: number,
|
|
||||||
): AlignmentEdits {
|
|
||||||
const current = buildAlignment(base, edits);
|
|
||||||
const moving = shiftMovingPoints(current, segment);
|
|
||||||
if (!moving) return edits;
|
|
||||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
|
||||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
|
||||||
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
|
|
||||||
const offsets = { ...edits.station_offsets };
|
|
||||||
moving.forEach((chainage) => {
|
|
||||||
const key = chainageKey(chainage);
|
|
||||||
const seed =
|
|
||||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
|
||||||
offsets[key] = Number((seed + delta).toFixed(6));
|
|
||||||
});
|
|
||||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
|
||||||
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
|
|
||||||
let worst = 0;
|
|
||||||
moving.forEach((chainage, index) => {
|
|
||||||
const key = chainageKey(chainage);
|
|
||||||
const error = targets[index] - planElevationAt(trial, chainage);
|
|
||||||
worst = Math.max(worst, Math.abs(error));
|
|
||||||
offsets[key] = Number((offsets[key] + error).toFixed(6));
|
|
||||||
});
|
|
||||||
if (worst < FEEDBACK_TOLERANCE_M) break;
|
|
||||||
}
|
|
||||||
return { ...edits, station_offsets: offsets };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
||||||
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
||||||
|
|||||||
@@ -33,24 +33,3 @@ export function chainageToStation(
|
|||||||
}
|
}
|
||||||
return { station, remainder };
|
return { station, remainder };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 구조물 측점과 겹쳤다고 볼 거리(m) — 이보다 가까우면 규칙 측점을 지운다. */
|
|
||||||
export const STATION_MERGE_TOLERANCE_M = 0.1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구조물 측점과 **겹치는 규칙 측점을 지운다**(2026-09-06).
|
|
||||||
*
|
|
||||||
* 구조물은 보조말뚝을 박아 그 자리가 정본 측점이 된다. 규칙 격자(20m)와 0.1m 안에서
|
|
||||||
* 만나면 세로선·라벨이 두 겹으로 겹쳐 읽히지 않으므로 구조물 쪽만 남긴다.
|
|
||||||
*/
|
|
||||||
export function dropStationsNear<T extends { chainage_m: number }>(
|
|
||||||
stations: readonly T[],
|
|
||||||
anchors: ReadonlyArray<{ chainage_m: number }>,
|
|
||||||
toleranceM: number = STATION_MERGE_TOLERANCE_M,
|
|
||||||
): T[] {
|
|
||||||
if (anchors.length === 0) return [...stations];
|
|
||||||
return stations.filter(
|
|
||||||
(station) =>
|
|
||||||
!anchors.some((anchor) => Math.abs(anchor.chainage_m - station.chainage_m) <= toleranceM),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ export function setGeneralStations(
|
|||||||
listed = (stations ?? []).map((station) => station.chainage_m);
|
listed = (stations ?? []).map((station) => station.chainage_m);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generalStations = (): readonly number[] => general;
|
|
||||||
|
|
||||||
let plusSource: () => readonly number[] = () => [];
|
let plusSource: () => readonly number[] = () => [];
|
||||||
|
|
||||||
/** 추가 측점 읽는 곳을 둔다(368-2) — 부를 때마다 읽음(되돌리기 · 다른 보기가 바꿔도 같은 값). */
|
/** 추가 측점 읽는 곳을 둔다(368-2) — 부를 때마다 읽음(되돌리기 · 다른 보기가 바꿔도 같은 값). */
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ export function isSummaryItem(value: unknown): value is SummaryItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 같은 항목인가 — 층 · 주인 · 키가 같으면 같음(이름은 바뀔 수 있어 안 봄). */
|
|
||||||
export function sameSummaryItem(left: SummaryItem | null, right: SummaryItem | null): boolean {
|
|
||||||
if (!left || !right) return left === right;
|
|
||||||
return left.layer === right.layer && left.owner === right.owner && left.key === right.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 서버 · 세션에서 온 값을 참조 또는 null 로 — 모양이 틀리면 null(미지정). */
|
/** 서버 · 세션에서 온 값을 참조 또는 null 로 — 모양이 틀리면 null(미지정). */
|
||||||
export function readSummaryItem(value: unknown): SummaryItem | null {
|
export function readSummaryItem(value: unknown): SummaryItem | null {
|
||||||
return isSummaryItem(value) ? value : null;
|
return isSummaryItem(value) ? value : null;
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ station.setGeneralStations(
|
|||||||
{ chainage_m: 80.17, kind: "irregular" },
|
{ chainage_m: 80.17, kind: "irregular" },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
out.general = [...station.generalStations()];
|
|
||||||
out.defaultSnap = station.snapStructureStation(80.4);
|
out.defaultSnap = station.snapStructureStation(80.4);
|
||||||
|
|
||||||
// ② 좌측 폼 칸 — 친 값 80.17 → 80 · 안 고친 옛 값은 그대로
|
// ② 좌측 폼 칸 — 친 값 80.17 → 80 · 안 고친 옛 값은 그대로
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ const C = "cccc3333";
|
|||||||
const SEED = {
|
const SEED = {
|
||||||
[`aislo:draft:structures:${A}`]: '[{"id":1}]',
|
[`aislo:draft:structures:${A}`]: '[{"id":1}]',
|
||||||
[`aislo:draft:crossw:${A}:12`]: '{"10":1}',
|
[`aislo:draft:crossw:${A}:12`]: '{"10":1}',
|
||||||
[`b06:culvertopt:${A}:12`]: '{"x":1}', // 옛 키(초안)
|
|
||||||
[`aislo:result:latest:${A}`]: '{"r":1}',
|
[`aislo:result:latest:${A}`]: '{"r":1}',
|
||||||
[`aislo:draft:structures:${B}`]: '[{"id":2}]',
|
[`aislo:draft:structures:${B}`]: '[{"id":2}]',
|
||||||
[`aislo:result:section-detail:${B}:7`]: '{"d":1}',
|
[`aislo:result:section-detail:${B}:7`]: '{"d":1}',
|
||||||
@@ -55,8 +54,6 @@ const SEED = {
|
|||||||
[`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음
|
[`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음
|
||||||
[`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸
|
[`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸
|
||||||
"aislo.otp-pending": "x", // 프로젝트와 무관
|
"aislo.otp-pending": "x", // 프로젝트와 무관
|
||||||
[`d02_points_${A}`]: '{"base":1}', // D02 옛 키(401 · 등록표 옛 키 머리로 잡음)
|
|
||||||
[`e03_prob_cache:${C}`]: "{}", // E 옛 키 · 빈 값 = 고침 없음
|
|
||||||
};
|
};
|
||||||
const seed = (extra = {}) => {
|
const seed = (extra = {}) => {
|
||||||
global.window.sessionStorage = fakeStorage();
|
global.window.sessionStorage = fakeStorage();
|
||||||
|
|||||||
@@ -302,8 +302,6 @@ async function run() {
|
|||||||
await tick(storeMod.PERSIST_DELAY_MS + 50);
|
await tick(storeMod.PERSIST_DELAY_MS + 50);
|
||||||
const afterDelay = JSON.parse(session.getItem(key("design-stations")));
|
const afterDelay = JSON.parse(session.getItem(key("design-stations")));
|
||||||
store.set("profile", { schema_version: 1 });
|
store.set("profile", { schema_version: 1 });
|
||||||
storeMod.flushDesignStore();
|
|
||||||
const flushed = JSON.parse(session.getItem(key("design-profile")));
|
|
||||||
store.set("generation", 7);
|
store.set("generation", 7);
|
||||||
const generationSession = [...Array(session.length).keys()]
|
const generationSession = [...Array(session.length).keys()]
|
||||||
.map((i) => session.key(i))
|
.map((i) => session.key(i))
|
||||||
@@ -336,7 +334,6 @@ async function run() {
|
|||||||
beforeFlush,
|
beforeFlush,
|
||||||
memoryRead,
|
memoryRead,
|
||||||
afterDelay,
|
afterDelay,
|
||||||
flushed,
|
|
||||||
generation: store.get("generation"),
|
generation: store.get("generation"),
|
||||||
generationSession,
|
generationSession,
|
||||||
cleared,
|
cleared,
|
||||||
|
|||||||
@@ -115,16 +115,9 @@ const keys = (win) => Object.keys(win.sessionStorage.dump()).sort();
|
|||||||
`aislo:draft:structures:${X}`,
|
`aislo:draft:structures:${X}`,
|
||||||
`aislo:draft:crossw:${X}:12`,
|
`aislo:draft:crossw:${X}:12`,
|
||||||
`aislo:result:section-detail:${X}:12`,
|
`aislo:result:section-detail:${X}:12`,
|
||||||
`b06:culvertopt:${X}:12`,
|
|
||||||
`aislo:draft:d03-plan:${X}`,
|
`aislo:draft:d03-plan:${X}`,
|
||||||
`aislo:draft:d04-assignees:${X}`,
|
`aislo:draft:d04-assignees:${X}`,
|
||||||
`aislo:draft:e05-report:${X}`,
|
`aislo:draft:e05-report:${X}`,
|
||||||
`d02_points_${X}`,
|
|
||||||
`e02_ground_cache:${X}`,
|
|
||||||
`e03_prob_cache:${X}`,
|
|
||||||
`e04_settings:${X}`,
|
|
||||||
`e06_rock_cache:${X}`,
|
|
||||||
`e07_settings:${X}`,
|
|
||||||
];
|
];
|
||||||
const keep = [
|
const keep = [
|
||||||
`aislo:draft:structures:${Y}`,
|
`aislo:draft:structures:${Y}`,
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
// 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다.
|
|
||||||
// node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록
|
|
||||||
// {op:"attach", entries:[관 응답], moves:[[옛, 새]], points:[{chainage_m, source}]} → attach 결과
|
|
||||||
// {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값
|
|
||||||
// {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
const root = path.resolve(process.argv[2] ?? ".");
|
|
||||||
const { build } = await import(
|
|
||||||
pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href
|
|
||||||
);
|
|
||||||
|
|
||||||
// 화면 칸 · 세션만 흉내 — 측점 칸은 값 · 클래스 · 이벤트만 쓴다
|
|
||||||
class Node {
|
|
||||||
constructor() {
|
|
||||||
this.value = "";
|
|
||||||
this.children = [];
|
|
||||||
this.classList = { toggle() {}, add() {}, remove() {} };
|
|
||||||
this.style = {};
|
|
||||||
this.dataset = {};
|
|
||||||
}
|
|
||||||
append(...kids) {
|
|
||||||
this.children.push(...kids);
|
|
||||||
}
|
|
||||||
appendChild(kid) {
|
|
||||||
this.children.push(kid);
|
|
||||||
return kid;
|
|
||||||
}
|
|
||||||
addEventListener() {}
|
|
||||||
setAttribute() {}
|
|
||||||
}
|
|
||||||
const session = new Map();
|
|
||||||
globalThis.document = { createElement: () => new Node(), createTextNode: () => new Node() };
|
|
||||||
globalThis.window = {
|
|
||||||
sessionStorage: {
|
|
||||||
getItem: (k) => (session.has(k) ? session.get(k) : null),
|
|
||||||
setItem: (k, v) => session.set(k, String(v)),
|
|
||||||
removeItem: (k) => session.delete(k),
|
|
||||||
},
|
|
||||||
location: { origin: "http://localhost" },
|
|
||||||
setTimeout: (...a) => setTimeout(...a),
|
|
||||||
clearTimeout: (id) => clearTimeout(id),
|
|
||||||
};
|
|
||||||
globalThis.sessionStorage = globalThis.window.sessionStorage;
|
|
||||||
globalThis.localStorage = globalThis.window.sessionStorage;
|
|
||||||
|
|
||||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-save-payload-"));
|
|
||||||
try {
|
|
||||||
await build({
|
|
||||||
configFile: false,
|
|
||||||
logLevel: "silent",
|
|
||||||
root,
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@ui": path.join(root, "ui_template"),
|
|
||||||
"@config": path.join(root, "config"),
|
|
||||||
"@util": path.join(root, "common_util"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir,
|
|
||||||
minify: false,
|
|
||||||
target: "es2022",
|
|
||||||
lib: {
|
|
||||||
entry: path.join(root, "resources/tester/helper_b05_save_payload_entry.ts"),
|
|
||||||
formats: ["es"],
|
|
||||||
fileName: "entry",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href);
|
|
||||||
const jobs = JSON.parse(fs.readFileSync(0, "utf-8"));
|
|
||||||
const results = [];
|
|
||||||
for (const job of jobs) {
|
|
||||||
if (job.op === "attach") {
|
|
||||||
const store = mod.createFacilityStore();
|
|
||||||
store.replaceFromResponse(job.entries);
|
|
||||||
// 지도에서 끈 관 — 화면이 끌기를 마치면 속성을 새 측점으로 옮김(onMove)
|
|
||||||
for (const [from, to] of job.moves ?? []) store.move(from, to);
|
|
||||||
results.push(store.attach(job.points));
|
|
||||||
} else if (job.op === "station") {
|
|
||||||
const fields = mod.stationFields("측점", () => job.interval);
|
|
||||||
fields.write(job.chainage, job.interval);
|
|
||||||
results.push(fields.read(job.interval, true));
|
|
||||||
} else if (job.op === "culvert") {
|
|
||||||
session.clear();
|
|
||||||
session.set("opt", JSON.stringify(job.pending ?? {}));
|
|
||||||
session.set("move", JSON.stringify(job.moves ?? {}));
|
|
||||||
let sent = null;
|
|
||||||
globalThis.fetch = async (_url, init = {}) => {
|
|
||||||
const body = init.method === "PUT" ? JSON.parse(init.body) : null;
|
|
||||||
if (body) sent = body.points;
|
|
||||||
const payload = body ? { pipe_points: body.points } : { pipe_points: job.server };
|
|
||||||
return new Response(JSON.stringify(payload), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
await mod.flushCulvertOptions("p", "opt", "move");
|
|
||||||
results.push(sent);
|
|
||||||
} else {
|
|
||||||
throw new Error(`모르는 작업 ${job.op}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(JSON.stringify(results));
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(outDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/* =============================================================================
|
|
||||||
* helper_b05_save_payload_entry.ts
|
|
||||||
* 저장 왕복 시험(`test_b05_b06_save_roundtrip.py`)이 **화면 코드 그대로** 저장 본문을 만들 때
|
|
||||||
* 묶는 진입점 — 관 시설 되붙이기 · 구조물 측점 칸(표시 → 다시 읽기) · 구간값 예약 흘려보내기.
|
|
||||||
* ========================================================================== */
|
|
||||||
|
|
||||||
export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility";
|
|
||||||
export { stationFields } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
|
|
||||||
export { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
|
||||||
@@ -177,12 +177,6 @@ out.isItem = [
|
|||||||
item.isSummaryItem({ ...A, key: "" }),
|
item.isSummaryItem({ ...A, key: "" }),
|
||||||
];
|
];
|
||||||
out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")];
|
out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")];
|
||||||
out.same = [
|
|
||||||
item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }),
|
|
||||||
item.sameSummaryItem(A, B),
|
|
||||||
item.sameSummaryItem(null, null),
|
|
||||||
item.sameSummaryItem(A, null),
|
|
||||||
];
|
|
||||||
out.label = [item.summaryLabel(null), item.summaryLabel(A)];
|
out.label = [item.summaryLabel(null), item.summaryLabel(A)];
|
||||||
out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)];
|
out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)];
|
||||||
|
|
||||||
|
|||||||
@@ -128,8 +128,7 @@ def test_arrow_step_one_metre(ts) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_general_registry_skips_irregular(ts) -> None:
|
def test_general_registry_skips_irregular(ts) -> None:
|
||||||
assert ts["general"] == STATIONS # 구조물 측점 80.17(irregular)은 일반 측점 아님
|
assert ts["defaultSnap"] == 80 # 구조물 측점 80.17(irregular)은 일반 측점 아님
|
||||||
assert ts["defaultSnap"] == 80
|
|
||||||
|
|
||||||
|
|
||||||
def test_form_input_integer(ts) -> None:
|
def test_form_input_integer(ts) -> None:
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ def test_남기기_그_프로젝트_초안만_둠(out):
|
|||||||
kept = [
|
kept = [
|
||||||
f"aislo:draft:crossw:{A}:12",
|
f"aislo:draft:crossw:{A}:12",
|
||||||
f"aislo:draft:structures:{A}",
|
f"aislo:draft:structures:{A}",
|
||||||
f"b06:culvertopt:{A}:12",
|
|
||||||
f"d02_points_{A}", # D02 옛 키도 초안(401)
|
|
||||||
]
|
]
|
||||||
assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept)
|
assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept)
|
||||||
assert out["keep"]["asked"] == 1
|
assert out["keep"]["asked"] == 1
|
||||||
@@ -54,7 +52,7 @@ def test_남기기_그_프로젝트_초안만_둠(out):
|
|||||||
|
|
||||||
def test_같은_프로젝트_다시_들어오면_안_지움(out):
|
def test_같은_프로젝트_다시_들어오면_안_지움(out):
|
||||||
assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0
|
assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0
|
||||||
assert len(out["same"]["keys"]) == 13
|
assert len(out["same"]["keys"]) == 10
|
||||||
|
|
||||||
|
|
||||||
def test_빈_초안은_고침_아님_취향은_둠(out):
|
def test_빈_초안은_고침_아님_취향은_둠(out):
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ def test_가게_결과_칸_메모리_우선(r):
|
|||||||
s = r["store"]
|
s = r["store"]
|
||||||
assert s["beforeFlush"] is None and s["memoryRead"] is True # 끌기마다 세션에 안 씀
|
assert s["beforeFlush"] is None and s["memoryRead"] is True # 끌기마다 세션에 안 씀
|
||||||
assert s["afterDelay"] == [{"key": "0", "chainageM": 0}] # 멈춘 뒤 한 번
|
assert s["afterDelay"] == [{"key": "0", "chainageM": 0}] # 멈춘 뒤 한 번
|
||||||
assert s["flushed"] == {"schema_version": 1}
|
|
||||||
assert s["generationSession"] == [] # 차례 번호는 메모리만
|
assert s["generationSession"] == [] # 차례 번호는 메모리만
|
||||||
assert s["cleared"] == {"stations": None, "profile": None} # clearResults = 메모리도 버림
|
assert s["cleared"] == {"stations": None, "profile": None} # clearResults = 메모리도 버림
|
||||||
assert s["footprintRevived"] is None # 지운 뒤 미뤄 둔 쓰기가 되살리지 않음
|
assert s["footprintRevived"] is None # 지운 뒤 미뤄 둔 쓰기가 되살리지 않음
|
||||||
|
|||||||
@@ -128,10 +128,10 @@ def _py_record(station: dict) -> dict | None:
|
|||||||
|
|
||||||
DRIVER = """
|
DRIVER = """
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
import { planFootprint } from "./B05_Profile_PlanFootprint.js";
|
import { footprintRecord, slopePieces } from "./B05_Profile_PlanFootprint.js";
|
||||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf-8"));
|
const cases = JSON.parse(readFileSync(process.argv[2], "utf-8"));
|
||||||
writeFileSync(process.argv[3], JSON.stringify(cases.map((c) =>
|
writeFileSync(process.argv[3], JSON.stringify(cases.map((c) =>
|
||||||
planFootprint(c.route, c.stations, c.start, c.end, c.step))));
|
slopePieces(c.route, c.stations.map(footprintRecord).filter(Boolean), c.start, c.end, c.step))));
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CASES = [(0.0, 400.0, 2.0), (35.0, 130.0, 2.0), (0.0, 400.0, 10.0)]
|
CASES = [(0.0, 400.0, 2.0), (35.0, 130.0, 2.0), (0.0, 400.0, 10.0)]
|
||||||
|
|||||||
@@ -73,5 +73,5 @@ def test_등록부_옛_키는_프로젝트_품음_사진_칸은_그_프로젝트
|
|||||||
) # fmt: skip
|
) # fmt: skip
|
||||||
assert run.returncode == 0, run.stderr
|
assert run.returncode == 0, run.stderr
|
||||||
out = json.loads(run.stdout)
|
out = json.loads(run.stdout)
|
||||||
assert out["legacy"] and all("PX" in key for _, key in out["legacy"])
|
assert out["legacy"] == [] # 옛 키 옮김 지움(472-8)
|
||||||
assert out["keys"] == ["aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1"]
|
assert out["keys"] == ["aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1"]
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ def test_다른_프로젝트_화면은_새로_엶(out):
|
|||||||
|
|
||||||
def test_떠남은_그_프로젝트_몫만_지움(out):
|
def test_떠남은_그_프로젝트_몫만_지움(out):
|
||||||
leave = out["leave"]
|
leave = out["leave"]
|
||||||
assert leave["keys"] == leave["expected"] # B · D · E · 옛 키 · 준비 표식 0 · 밖 칸 그대로
|
assert leave["keys"] == leave["expected"] # B · D · E · 준비 표식 0 · 밖 칸 그대로
|
||||||
assert leave["entered"] == X # 탭 프로젝트 표식은 남김
|
assert leave["entered"] == X # 탭 프로젝트 표식은 남김
|
||||||
assert leave["local"] == ["1", "1"] # 취향 · CAD 복구본
|
assert leave["local"] == ["1", "1"] # 취향 · CAD 복구본
|
||||||
assert leave["idb"] == [f"{Y}|u1"] # 보관함 = X 줄만 지움
|
assert leave["idb"] == [f"{Y}|u1"] # 보관함 = X 줄만 지움
|
||||||
assert leave["otherTabKept"] == "1" # 다른 탭 세션은 안 건드림
|
assert leave["otherTabKept"] == "1" # 다른 탭 세션은 안 건드림
|
||||||
assert leave["notified"] == 13 # 지운 칸마다 지움 알림 — 설계 가게가 미룬 쓰기(1 s)를 거둠
|
assert leave["notified"] == 6 # 지운 칸마다 지움 알림 — 설계 가게가 미룬 쓰기(1 s)를 거둠
|
||||||
|
|
||||||
|
|
||||||
def test_떠남_물음_한_번(out):
|
def test_떠남_물음_한_번(out):
|
||||||
|
|||||||
@@ -1,518 +0,0 @@
|
|||||||
"""B05 · B06 저장 왕복 — 사용자가 바꾼 값이 저장 · 다시 읽기 뒤 그대로인가(PLAN 19장).
|
|
||||||
|
|
||||||
환경 배정 storage 프로젝트(랩탑_보조)의 **사본**에서 화면 저장 흐름과 같은 차례로 서버 API 를
|
|
||||||
부르고, 다시 읽은 값(pipe_points.json · structures.json · 종단 파일 · 응답)을 사용자가 고른 값과
|
|
||||||
0 차이로 대조. 저장 본문은 화면 코드 그대로(`helper_b05_save_payload.mjs` — 시설 되붙이기 ·
|
|
||||||
구조물 측점 칸 · 구간값 예약).
|
|
||||||
|
|
||||||
- 파일: 프로젝트 폴더를 임시 폴더로 복사(큰 원본 · 점군 제외) · `STORAGE_BASE_DIR` 를 그리로 돌림.
|
|
||||||
- DB: 읽기는 진짜 DB(같은 프로젝트 행) · 세션을 읽기 전용으로 열어 쓰기가 새지 않음 ·
|
|
||||||
쓰는 함수는 받은 값을 적어 두는 가짜로 갈음(DB 에 남기는 값도 보낸 값과 대조).
|
|
||||||
- 원본 프로젝트 · 사용자 프로젝트 · DB 는 바뀌지 않음.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
|
|
||||||
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
|
|
||||||
# 저장 흐름이 읽고 쓰는 것만 — 원본 입력(B03) · 점군(structured.npz 등)은 안 씀
|
|
||||||
_COPY_DIRS = ("B04_PreProcess/drainage", "B05_Profile", "B06_Section")
|
|
||||||
_COPY_MAX_BYTES = 20 * 1024 * 1024
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.skipif(not SOURCE.is_dir(), reason="배정 storage 프로젝트가 없음")
|
|
||||||
|
|
||||||
|
|
||||||
def _copy_project(dest: Path) -> None:
|
|
||||||
for rel in _COPY_DIRS:
|
|
||||||
shutil.copytree(SOURCE / rel, dest / rel)
|
|
||||||
for rel in ("B04_PreProcess/models", "B04_PreProcess/processed"):
|
|
||||||
(dest / rel).mkdir(parents=True, exist_ok=True)
|
|
||||||
for item in (SOURCE / rel).iterdir():
|
|
||||||
if item.is_file() and item.stat().st_size <= _COPY_MAX_BYTES:
|
|
||||||
shutil.copy2(item, dest / rel / item.name)
|
|
||||||
for item in SOURCE.iterdir():
|
|
||||||
if item.is_file():
|
|
||||||
shutil.copy2(item, dest / item.name)
|
|
||||||
|
|
||||||
|
|
||||||
class Recorder:
|
|
||||||
"""DB 에 쓰는 함수 대신 — 받은 값을 적어 둠."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
|
||||||
|
|
||||||
def fake(self, name: str, result: Any = None):
|
|
||||||
async def _fake(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
self.calls.append((name, {"args": args[1:], **kwargs}))
|
|
||||||
return result
|
|
||||||
|
|
||||||
return _fake
|
|
||||||
|
|
||||||
def of(self, name: str) -> list[dict[str, Any]]:
|
|
||||||
return [call for key, call in self.calls if key == name]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def api(tmp_path, monkeypatch):
|
|
||||||
aiomysql = pytest.importorskip("aiomysql")
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
import B04_PreProcess.B04_PreProcess_Router_Basins as basins
|
|
||||||
import B05_Profile.B05_Profile_Router as route
|
|
||||||
import B05_Profile.B05_Profile_Router_Confirm as route_confirm
|
|
||||||
import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle
|
|
||||||
import B05_Profile.B05_Profile_Structures_Router as structures
|
|
||||||
import B06_Section.B06_Section_Repository_Bulk as bulk
|
|
||||||
import B06_Section.B06_Section_Router_Confirm as sections
|
|
||||||
import B06_Section.B06_Section_Router_Stations as station_router
|
|
||||||
import common_util.common_util_storage as storage
|
|
||||||
import config.config_db as config_db
|
|
||||||
from config import config_system as cfg
|
|
||||||
|
|
||||||
storage_root = tmp_path / "storage"
|
|
||||||
_copy_project(storage_root / "1" / "3" / PROJECT_ID)
|
|
||||||
monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root))
|
|
||||||
|
|
||||||
rec = Recorder()
|
|
||||||
monkeypatch.setattr(route, "update_longitudinal_grade_summary", rec.fake("grade_summary"))
|
|
||||||
monkeypatch.setattr(structures, "_invalidate_downstream", rec.fake("invalidate", True))
|
|
||||||
monkeypatch.setattr(lifecycle, "confirm_route", rec.fake("confirm_route"))
|
|
||||||
monkeypatch.setattr(lifecycle, "complete_stage", rec.fake("complete_stage"))
|
|
||||||
monkeypatch.setattr(bulk, "merge_cross_section_designs", rec.fake("designs", 0))
|
|
||||||
monkeypatch.setattr(sections, "merge_cross_section_designs", rec.fake("designs", 0))
|
|
||||||
monkeypatch.setattr(sections, "merge_longitudinal_section_options", rec.fake("options"))
|
|
||||||
monkeypatch.setattr(sections, "merge_longitudinal_section_data", rec.fake("data"))
|
|
||||||
monkeypatch.setattr(sections, "confirm_sections_for_route", rec.fake("confirm_sections"))
|
|
||||||
monkeypatch.setattr(sections, "confirm_route_status", rec.fake("confirm_route"))
|
|
||||||
monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage"))
|
|
||||||
monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage"))
|
|
||||||
monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute", True))
|
|
||||||
# 272 — 측점을 다시 맞춘 뒤 정본에 없는 측점의 횡단 행 지우기
|
|
||||||
monkeypatch.setattr(route_confirm, "sync_cross_section_rows", rec.fake("sync_rows", 0))
|
|
||||||
monkeypatch.setattr(
|
|
||||||
route_confirm, "update_longitudinal_grade_summary", rec.fake("grade_summary")
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
station_router, "update_longitudinal_grade_summary", rec.fake("grade_summary")
|
|
||||||
)
|
|
||||||
real_known = sections.get_cross_section_chainages
|
|
||||||
|
|
||||||
async def spy_known(connection, route_id):
|
|
||||||
rows = await real_known(connection, route_id)
|
|
||||||
rec.calls.append(("known", {"chainages": list(rows)}))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
monkeypatch.setattr(sections, "get_cross_section_chainages", spy_known)
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(_app):
|
|
||||||
try:
|
|
||||||
pool = await aiomysql.create_pool(
|
|
||||||
host=cfg.DB_HOST,
|
|
||||||
port=cfg.DB_PORT,
|
|
||||||
db=cfg.DB_NAME,
|
|
||||||
user=cfg.DB_USER,
|
|
||||||
password=cfg.DB_PASSWORD,
|
|
||||||
minsize=1,
|
|
||||||
maxsize=6,
|
|
||||||
autocommit=True,
|
|
||||||
charset="utf8mb4",
|
|
||||||
# 읽기 전용 세션 — 가짜로 못 갈음한 쓰기가 있으면 DB 를 바꾸지 않고 터짐
|
|
||||||
init_command="SET time_zone='+00:00', SESSION tx_read_only=1",
|
|
||||||
)
|
|
||||||
except Exception as exc: # DB 가 안 닿는 곳
|
|
||||||
pytest.skip(f"DB 에 안 닿음: {exc}")
|
|
||||||
config_db.db_pool = pool
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
config_db.db_pool = None
|
|
||||||
pool.close()
|
|
||||||
await pool.wait_closed()
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
|
||||||
for module in (basins, route, lifecycle, structures, sections):
|
|
||||||
app.include_router(module.router)
|
|
||||||
with TestClient(app) as client:
|
|
||||||
client.rec = rec # type: ignore[attr-defined]
|
|
||||||
client.root = storage_root / "1" / "3" / PROJECT_ID # type: ignore[attr-defined]
|
|
||||||
yield client
|
|
||||||
|
|
||||||
|
|
||||||
# ── 화면 코드 그대로 본문 만들기(node) ────────────────────────────────────────
|
|
||||||
HELPER = Path(__file__).with_name("helper_b05_save_payload.mjs")
|
|
||||||
STATION_INTERVAL_M = 20.0
|
|
||||||
# 화면이 관 옵션 수정 끝에 측점을 다시 읽어 이만큼 넘게 다르면 관을 옮김
|
|
||||||
# (`B05_Profile_UI_Drainage_Panel.ts` `updatePipeFacility`)
|
|
||||||
PIPE_MOVE_THRESHOLD_M = 0.005
|
|
||||||
|
|
||||||
|
|
||||||
def _screen(jobs: list[dict[str, Any]]) -> list[Any]:
|
|
||||||
if shutil.which("node") is None:
|
|
||||||
pytest.skip("node 가 없음")
|
|
||||||
out = subprocess.run(
|
|
||||||
["node", str(HELPER), str(ROOT)],
|
|
||||||
input=json.dumps(jobs),
|
|
||||||
cwd=ROOT,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
timeout=300,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
|
||||||
|
|
||||||
|
|
||||||
# ── 비교 모양 ─────────────────────────────────────────────────────────────────
|
|
||||||
LONGITUDINAL = "B06_Section/longitudinal/longitudinal.json"
|
|
||||||
PIPES_FILE = "B04_PreProcess/drainage/edits/pipe_points.json"
|
|
||||||
STRUCTURES_FILE = "B05_Profile/route/structures.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _key(chainage: float) -> str:
|
|
||||||
return f"{chainage:.2f}"
|
|
||||||
|
|
||||||
|
|
||||||
def _pipes_view(points: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
||||||
"""관 — 측점(0.01) → 시설 · 구간 · 옵션(좌표 · 생성 사유는 뺌)."""
|
|
||||||
return {
|
|
||||||
_key(p["chainage_m"]): {
|
|
||||||
"facility": p.get("facility", "pipe"),
|
|
||||||
"span": (p.get("start_m"), p.get("end_m")),
|
|
||||||
"options": p.get("options") or {},
|
|
||||||
}
|
|
||||||
for p in points
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _read(root: Path, rel: str) -> Any:
|
|
||||||
return json.loads((root / rel).read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def _strip(point: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""화면이 서버에 되보내는 관 — 좌표는 서버가 다시 냄(`DetailPipeInput`)."""
|
|
||||||
return {k: v for k, v in point.items() if k != "lonlat"}
|
|
||||||
|
|
||||||
|
|
||||||
class Flow:
|
|
||||||
"""화면 저장 흐름 하나(B05 [임시저장] · B06 [저장]).
|
|
||||||
|
|
||||||
사용자가 고른 값(`want_*`)을 따로 적어 두고, 화면 코드가 만든 본문을 서버에 보낸 뒤
|
|
||||||
다시 읽은 값을 고른 값과 대조. 바꿀 대상은 사본에 있는 것 중 자리 순번으로 고름.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, api) -> None:
|
|
||||||
self.api = api
|
|
||||||
self.base = f"/api/projects/{PROJECT_ID}"
|
|
||||||
latest = api.get(f"{self.base}/route/latest").json()
|
|
||||||
self.route_id = latest["route"]["id"]
|
|
||||||
self.surface = latest["surface_params"]
|
|
||||||
self.model_id = latest["route"]["surface_model_id"]
|
|
||||||
self.diffs: list[str] = []
|
|
||||||
self.sent_irregular: list[float] = []
|
|
||||||
self._plan()
|
|
||||||
|
|
||||||
def server_pipes(self) -> list[dict[str, Any]]:
|
|
||||||
got = self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
|
||||||
return [_strip(p) for p in got]
|
|
||||||
|
|
||||||
# ── 사용자가 고른 값 ──
|
|
||||||
def _plan(self) -> None:
|
|
||||||
pipes = self.server_pipes()
|
|
||||||
if len(pipes) < 9:
|
|
||||||
pytest.skip(f"관이 {len(pipes)} 개 — 시험에 9 개 넘게 필요")
|
|
||||||
self.pipes0 = pipes
|
|
||||||
ch = [p["chainage_m"] for p in pipes]
|
|
||||||
self.pipe_move = (ch[2], round(ch[2] + 5.35, 2)) # 관 옮김 — 시설 · 옵션이 따라가야 함
|
|
||||||
self.pipe_option = (ch[5], {"pipe_diameter_mm": 1350}) # 옵션만 — 자리 그대로
|
|
||||||
self.culvert = (ch[8], {"pipe_diameter_mm": 1200}) # B06 조정창 구간값 예약
|
|
||||||
self.stale_move = (ch[4], round(ch[4] + 4.76, 2)) # 옛 세션에 남은 관 이동 예약
|
|
||||||
self.pipe_new = round((ch[6] + ch[7]) / 2, 2) # 새 관 하나(맨 배관)
|
|
||||||
want = []
|
|
||||||
for p in pipes:
|
|
||||||
item = dict(p)
|
|
||||||
if item["chainage_m"] == self.pipe_move[0]:
|
|
||||||
item.update(chainage_m=self.pipe_move[1], source="user")
|
|
||||||
elif item["chainage_m"] == self.pipe_option[0]:
|
|
||||||
item["options"] = {**(item.get("options") or {}), **self.pipe_option[1]}
|
|
||||||
elif item["chainage_m"] == self.culvert[0]:
|
|
||||||
item["options"] = {**(item.get("options") or {}), **self.culvert[1]}
|
|
||||||
want.append(item)
|
|
||||||
want.append({"chainage_m": self.pipe_new, "source": "user"})
|
|
||||||
self.want_pipes = sorted(want, key=lambda x: x["chainage_m"])
|
|
||||||
|
|
||||||
got = self.api.get(f"{self.base}/route/structures").json()
|
|
||||||
self.revision0, items = got["revision"], got["structures"]
|
|
||||||
spans = [s for s in items if s.get("start_m") is not None and s.get("end_m") is not None]
|
|
||||||
if len(spans) < 3:
|
|
||||||
pytest.skip("구간 구조물이 3 개 넘게 필요")
|
|
||||||
moved, optioned, memoed = spans[0], spans[1], spans[2]
|
|
||||||
width = moved["end_m"] - moved["start_m"]
|
|
||||||
to = round(moved["start_m"] + 1.37, 2) # 측점 칸에 소수 2자리로 쳐 넣음
|
|
||||||
self.struct_move = (moved["structure_id"], to, round(to + width, 2))
|
|
||||||
self.want_structures = json.loads(json.dumps(items))
|
|
||||||
for s in self.want_structures:
|
|
||||||
if s["structure_id"] == moved["structure_id"]:
|
|
||||||
s.update(chainage_m=to, start_m=to, end_m=round(to + width, 2))
|
|
||||||
s["memo"] = "옮긴 뒤 메모" # 옮긴 칸에서 메모만 한 번 더 고침
|
|
||||||
elif s["structure_id"] == optioned["structure_id"]:
|
|
||||||
s["options"] = {**s["options"], "height_m": 3}
|
|
||||||
s["memo"] = "왕복 시험 메모"
|
|
||||||
elif s["structure_id"] == memoed["structure_id"]:
|
|
||||||
s["memo"] = "메모만 바꿈"
|
|
||||||
|
|
||||||
lon = _read(self.api.root, LONGITUDINAL)
|
|
||||||
grid = [s for s in lon["stations"] if s.get("kind") == "regular"]
|
|
||||||
g0, g1 = grid[4], grid[len(grid) // 2]
|
|
||||||
self.offsets = {f"{g0['chainage_m']:.3f}": 0.35, f"{g1['chainage_m']:.3f}": -0.2}
|
|
||||||
side = grid[3].get("uphill_side")
|
|
||||||
self.uphill = (grid[3]["chainage_m"], "left" if side != "left" else "right")
|
|
||||||
self.cross_patches = [
|
|
||||||
{
|
|
||||||
"chainage_m": g0["chainage_m"],
|
|
||||||
"rock_boundary_offset_m": -0.8,
|
|
||||||
"cut_slope_ratio_user": 0.5,
|
|
||||||
},
|
|
||||||
{"chainage_m": self.pipe_move[1], "pipe_length_m": 9.0},
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── 화면 코드가 만드는 본문 ──
|
|
||||||
def b05_pipe_payload(self) -> list[dict[str, Any]]:
|
|
||||||
"""B05 `savePipes` — 화면 관 목록(측점 · 사유) + 시설 보관소 `attach`."""
|
|
||||||
(station,) = _screen(
|
|
||||||
[{"op": "station", "chainage": self.pipe_option[0], "interval": STATION_INTERVAL_M}]
|
|
||||||
)
|
|
||||||
entries = [dict(p) for p in self.pipes0]
|
|
||||||
screen_ch = {}
|
|
||||||
for p in self.want_pipes:
|
|
||||||
c = p["chainage_m"]
|
|
||||||
if c == self.pipe_option[0] and abs(station - c) > PIPE_MOVE_THRESHOLD_M:
|
|
||||||
c = station # 옵션 폼 확정이 측점을 표시값(0.1)에서 다시 읽어 관을 옮김
|
|
||||||
screen_ch[p["chainage_m"]] = c
|
|
||||||
for e in entries: # 폼에서 고친 시설 정보는 그 관의 (새) 자리로 보관소에 들어감
|
|
||||||
if e["chainage_m"] == self.pipe_option[0]:
|
|
||||||
e["chainage_m"] = screen_ch[self.pipe_option[0]]
|
|
||||||
e["options"] = {**(e.get("options") or {}), **self.pipe_option[1]}
|
|
||||||
points = [
|
|
||||||
{
|
|
||||||
"chainage_m": screen_ch[p["chainage_m"]],
|
|
||||||
"source": "confirmed" if p.get("source") == "user" else p.get("source", "user"),
|
|
||||||
}
|
|
||||||
for p in self.want_pipes
|
|
||||||
]
|
|
||||||
job = {"op": "attach", "entries": entries, "moves": [self.pipe_move], "points": points}
|
|
||||||
(attached,) = _screen([job])
|
|
||||||
return attached
|
|
||||||
|
|
||||||
def b06_pipe_payload(self) -> list[dict[str, Any]]:
|
|
||||||
"""B06 `persistPipes` — 화면 관 상태 그대로(구간값은 예약으로 따로 나감)."""
|
|
||||||
before = {p["chainage_m"]: p.get("options") for p in self.pipes0}
|
|
||||||
out = []
|
|
||||||
for p in self.want_pipes:
|
|
||||||
item = dict(p)
|
|
||||||
if p["chainage_m"] == self.culvert[0]:
|
|
||||||
item.pop("options", None)
|
|
||||||
if before[self.culvert[0]]:
|
|
||||||
item["options"] = before[self.culvert[0]]
|
|
||||||
out.append(item)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def screen_structures(self, *, reread: bool) -> list[dict[str, Any]]:
|
|
||||||
"""구조물 — 옮긴 칸에서 메모를 고치면 `commit` 이 측점 칸(표시 0.1)을 다시 읽음."""
|
|
||||||
items = json.loads(json.dumps(self.want_structures))
|
|
||||||
if not reread:
|
|
||||||
return items
|
|
||||||
sid, start, end = self.struct_move
|
|
||||||
ch = next(s for s in items if s["structure_id"] == sid)
|
|
||||||
a, b, c = _screen(
|
|
||||||
[
|
|
||||||
{"op": "station", "chainage": v, "interval": STATION_INTERVAL_M}
|
|
||||||
for v in (start, ch["chainage_m"], end)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
ch.update(start_m=a, chainage_m=b, end_m=c)
|
|
||||||
return items
|
|
||||||
|
|
||||||
def culvert_payload(self, current: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
|
||||||
"""`flushCulvertOptions` — 구간값 예약 + 옛 세션 이동 예약을 서버 목록에 얹음."""
|
|
||||||
pending = {_key(self.culvert[0]): self.culvert[1]}
|
|
||||||
moves = {_key(self.stale_move[0]): self.stale_move[1]}
|
|
||||||
(body,) = _screen(
|
|
||||||
[{"op": "culvert", "server": current, "pending": pending, "moves": moves}]
|
|
||||||
)
|
|
||||||
return body
|
|
||||||
|
|
||||||
# ── API(화면 Api_Fetch 와 같은 경로 · 본문) ──
|
|
||||||
def put_pipes(self, pipes: list[dict[str, Any]]) -> None:
|
|
||||||
res = self.api.put(f"{self.base}/drainage/pipe-points", json={"points": pipes})
|
|
||||||
assert res.status_code == 200, res.text
|
|
||||||
|
|
||||||
def flush_culvert(self) -> None:
|
|
||||||
body = self.culvert_payload(
|
|
||||||
self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
|
||||||
)
|
|
||||||
if body is not None:
|
|
||||||
self.put_pipes(body)
|
|
||||||
|
|
||||||
def put_alignment(self) -> None:
|
|
||||||
body = {"route_id": self.route_id, "station_offsets": self.offsets, "curve_radii": {}}
|
|
||||||
res = self.api.put(f"{self.base}/route/profile-alignment", json=body)
|
|
||||||
assert res.status_code == 200, res.text
|
|
||||||
|
|
||||||
def put_structures(self, items: list[dict[str, Any]]) -> None:
|
|
||||||
revision = self.api.get(f"{self.base}/route/structures").json()["revision"]
|
|
||||||
body = {"base_revision": revision, "structures": items}
|
|
||||||
res = self.api.put(f"{self.base}/route/structures", json=body)
|
|
||||||
assert res.status_code == 200, res.text
|
|
||||||
|
|
||||||
def confirm(self, *, with_irregular: bool) -> None:
|
|
||||||
body: dict[str, Any] = {
|
|
||||||
"uphill_overrides": [{"chainage_m": self.uphill[0], "side": self.uphill[1]}]
|
|
||||||
}
|
|
||||||
if with_irregular: # B05 — 관 투영(물넘이포장 뺌) · A군 구조물
|
|
||||||
self.sent_irregular = [
|
|
||||||
p["chainage_m"] for p in self.server_pipes() if p.get("facility") != "ford_pavement"
|
|
||||||
]
|
|
||||||
body.update(
|
|
||||||
filter_key=self.surface["source_filter"],
|
|
||||||
method=self.surface["method"],
|
|
||||||
smooth=self.surface["smooth"],
|
|
||||||
surface_model_id=self.model_id,
|
|
||||||
irregular_stations=[
|
|
||||||
{"chainage_m": c, "structure": "배관"} for c in self.sent_irregular
|
|
||||||
],
|
|
||||||
)
|
|
||||||
res = self.api.post(f"{self.base}/route/confirm?mark_stage_complete=false", json=body)
|
|
||||||
assert res.status_code == 200, res.text
|
|
||||||
|
|
||||||
def save_sections(self) -> None:
|
|
||||||
body = {"cross_patches": self.cross_patches}
|
|
||||||
res = self.api.post(f"{self.base}/sections/{self.route_id}/save", json=body)
|
|
||||||
assert res.status_code == 200, res.text
|
|
||||||
|
|
||||||
# ── 다시 읽기 · 대조(사용자가 고른 값 기준) ──
|
|
||||||
def check(self) -> None:
|
|
||||||
root, d = self.api.root, self.diffs
|
|
||||||
want = _pipes_view(self.want_pipes)
|
|
||||||
for where, got in (
|
|
||||||
("응답", _pipes_view(self.server_pipes())),
|
|
||||||
("pipe_points.json", _pipes_view(_read(root, PIPES_FILE)["points"])),
|
|
||||||
):
|
|
||||||
for k in sorted(set(want) | set(got), key=float):
|
|
||||||
if want.get(k) != got.get(k):
|
|
||||||
d.append(f"관 {k} ({where}) — 고름 {want.get(k)} · 읽음 {got.get(k)}")
|
|
||||||
saved = {s["structure_id"]: s for s in _read(root, STRUCTURES_FILE)["structures"]}
|
|
||||||
for s in self.want_structures:
|
|
||||||
got_s = saved.get(s["structure_id"], {})
|
|
||||||
for field in ("chainage_m", "start_m", "end_m", "options", "memo", "placement_source"):
|
|
||||||
if s.get(field) != got_s.get(field):
|
|
||||||
d.append(
|
|
||||||
f"구조물 {s['type_id']} {field} — "
|
|
||||||
f"고름 {s.get(field)!r} · 읽음 {got_s.get(field)!r}"
|
|
||||||
)
|
|
||||||
lon = _read(root, LONGITUDINAL)
|
|
||||||
edits = (lon["profile_alignment"].get("edits") or {}).get("station_offsets") or {}
|
|
||||||
if edits != self.offsets:
|
|
||||||
d.append(f"계획고 수정 — 고름 {self.offsets} · 읽음 {edits}")
|
|
||||||
up = next((s for s in lon["stations"] if abs(s["chainage_m"] - self.uphill[0]) < 0.005), {})
|
|
||||||
if (up.get("uphill_side"), up.get("uphill_side_source")) != (self.uphill[1], "user"):
|
|
||||||
d.append(
|
|
||||||
f"상단측 {self.uphill[0]} — 고름 {self.uphill[1]} · "
|
|
||||||
f"읽음 {up.get('uphill_side')}/{up.get('uphill_side_source')}"
|
|
||||||
)
|
|
||||||
for c in self.sent_irregular:
|
|
||||||
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
|
|
||||||
if not any(abs(n - c) < 0.005 for n in near):
|
|
||||||
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
|
|
||||||
# 소수 측점 횡단(PLAN 20장) — 그 자리 횡단 파일이 소수 2자리 이름으로 따로 있음
|
|
||||||
name = cross_filename(c)
|
|
||||||
if not (root / "B06_Section" / "cross_sections" / name).is_file():
|
|
||||||
d.append(f"관 측점 {c} — 횡단 파일 {name} 없음")
|
|
||||||
self._check_designs()
|
|
||||||
|
|
||||||
def _check_designs(self) -> None:
|
|
||||||
"""횡단 patch — DB 로 가는 값이 고른 값과 같고, 받을 행이 있어야 함(없으면 DB 가 버림)."""
|
|
||||||
rec = self.api.rec
|
|
||||||
known = [c for call in rec.of("known") for c in call["chainages"]]
|
|
||||||
designs = rec.of("designs")
|
|
||||||
made = [e[0] for call in designs if call.get("replace") for e in call["entries"]]
|
|
||||||
patched = {
|
|
||||||
round(e[0], 2): e[1]
|
|
||||||
for call in designs
|
|
||||||
if call.get("replace") is False
|
|
||||||
for e in call["entries"]
|
|
||||||
}
|
|
||||||
for patch in self.cross_patches:
|
|
||||||
ch = patch["chainage_m"]
|
|
||||||
body = {k: v for k, v in patch.items() if k != "chainage_m"}
|
|
||||||
got = patched.get(round(ch, 2))
|
|
||||||
if got is None or any(got.get(k) != v for k, v in body.items()):
|
|
||||||
self.diffs.append(f"횡단 patch {ch} — 고름 {body} · DB 로 {got}")
|
|
||||||
elif not any(abs(float(k) - ch) < 0.01 for k in [*known, *made]):
|
|
||||||
self.diffs.append(f"횡단 patch {ch} — 받을 행이 없어 DB 가 버림")
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(root: Path) -> dict[str, Any]:
|
|
||||||
lon = _read(root, LONGITUDINAL)
|
|
||||||
return {
|
|
||||||
"pipe_points.json": _read(root, PIPES_FILE)["points"],
|
|
||||||
"structures.json": _read(root, STRUCTURES_FILE)["structures"],
|
|
||||||
"계획고 수정": lon["profile_alignment"].get("edits"),
|
|
||||||
"종단 측점": [
|
|
||||||
(s["chainage_m"], s.get("kind"), s.get("uphill_side")) for s in lon["stations"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resave_unchanged(flow: Flow) -> None:
|
|
||||||
"""다시 열어 아무것도 안 바꾸고 [저장] — 저장본이 한 글자도 안 바뀌어야 함."""
|
|
||||||
flow.put_pipes(flow.server_pipes())
|
|
||||||
flow.put_alignment()
|
|
||||||
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
|
|
||||||
|
|
||||||
|
|
||||||
# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 —
|
|
||||||
# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a).
|
|
||||||
def _b05_save(flow: Flow) -> None:
|
|
||||||
"""B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정."""
|
|
||||||
flow.put_pipes(flow.b05_pipe_payload())
|
|
||||||
flow.put_structures(flow.screen_structures(reread=True))
|
|
||||||
flow.put_alignment()
|
|
||||||
flow.flush_culvert()
|
|
||||||
flow.confirm(with_irregular=True)
|
|
||||||
flow.save_sections()
|
|
||||||
|
|
||||||
|
|
||||||
def _b06_save(flow: Flow) -> None:
|
|
||||||
"""B06 [저장] — 오르막 차례는 상단측만 확정."""
|
|
||||||
flow.put_pipes(flow.b06_pipe_payload())
|
|
||||||
flow.put_structures(flow.screen_structures(reread=False))
|
|
||||||
flow.put_alignment()
|
|
||||||
flow.flush_culvert()
|
|
||||||
flow.confirm(with_irregular=False)
|
|
||||||
flow.save_sections()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("save", [_b05_save, _b06_save], ids=["B05", "B06"])
|
|
||||||
def test_저장_왕복_0_차이(api, save):
|
|
||||||
flow = Flow(api)
|
|
||||||
save(flow)
|
|
||||||
flow.check()
|
|
||||||
before = _snapshot(api.root)
|
|
||||||
_resave_unchanged(flow)
|
|
||||||
after = _snapshot(api.root)
|
|
||||||
flow.diffs += [f"다시 열어 그대로 저장 — {k} 가 바뀜" for k in before if before[k] != after[k]]
|
|
||||||
assert not flow.diffs, "\n".join(flow.diffs)
|
|
||||||
@@ -97,56 +97,3 @@ def test_source_old_diameter_formula_removed():
|
|||||||
assert "diameterMm ?? 1000" not in RENDER
|
assert "diameterMm ?? 1000" not in RENDER
|
||||||
assert "findMinCoverViolations" in GUARD
|
assert "findMinCoverViolations" in GUARD
|
||||||
assert "minCoverTargets: () => minCoverTargets" in PANEL
|
assert "minCoverTargets: () => minCoverTargets" in PANEL
|
||||||
|
|
||||||
|
|
||||||
# 구간 쉬프트 · R 입력 = `_Alignment_Edit.ts`(700줄 나눔 · 511-B B-3) — 두 파일을 한 글로 봄
|
|
||||||
ALIGNMENT = "".join(
|
|
||||||
(PROJECT_ROOT / "B05_Profile" / "logic" / name).read_text(encoding="utf-8")
|
|
||||||
for name in ("B05_Profile_UI_Profile_Alignment.ts", "B05_Profile_UI_Profile_Alignment_Edit.ts")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_shift_moves_hinges_not_fixed_ends():
|
|
||||||
"""쉬프트 재정의(2026-08-23 사용자 개념 확정) 소스 잠금:
|
|
||||||
고정점(BP·EP·배수 앵커)은 안 움직이고 안쪽 미틸트 측점 외곽 2개가 힌지로
|
|
||||||
승격(라운드 생성)된다. 힌지 부족이면 쉬프트 불가(버튼 숨김)."""
|
|
||||||
assert "export function shiftMovingPoints" in ALIGNMENT
|
|
||||||
assert "export function controlElevationAt" in ALIGNMENT
|
|
||||||
body = ALIGNMENT[ALIGNMENT.index("export function shiftSegment") :]
|
|
||||||
assert "shiftMovingPoints(current, segment)" in body
|
|
||||||
assert "if (!moving) return edits;" in body
|
|
||||||
assert "FEEDBACK_PASSES" in body # 라운드(중앙종거) 낀 자리도 1클릭 = 정확 delta.
|
|
||||||
EDIT = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Edit.ts").read_text(
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
# 구간 쉬프트 버튼(⬆⬇)과 그 노출 조건(canShift)은 2026-09-02 지시로 삭제됐다.
|
|
||||||
# 기하 함수는 남겨 두기로 했으므로(PLAN.md) 위 존재 검사는 그대로 두고 배선만 잠근다.
|
|
||||||
assert "canShift" not in EDIT
|
|
||||||
assert "canShift:" not in RENDER
|
|
||||||
|
|
||||||
|
|
||||||
def _moving_points(stations, edited, seg_from, seg_to):
|
|
||||||
"""TS shiftMovingPoints와 같은 규칙 — 끝점이 틸팅점이면 그 점, 아니면 안쪽 외곽 측점."""
|
|
||||||
interior = [s for s in stations if seg_from + 1e-6 < s < seg_to - 1e-6]
|
|
||||||
left = seg_from if seg_from in edited else (interior[0] if interior else None)
|
|
||||||
right = seg_to if seg_to in edited else (interior[-1] if interior else None)
|
|
||||||
if left is None or right is None or right - left < 1e-6:
|
|
||||||
return None
|
|
||||||
return (left, right)
|
|
||||||
|
|
||||||
|
|
||||||
def test_moving_points_promote_interior_hinges():
|
|
||||||
"""앵커-앵커 구간: 안쪽 미틸트 측점 외곽 2개(첫·끝)가 힌지가 된다."""
|
|
||||||
assert _moving_points([20, 40, 60, 80], set(), 0, 84.3) == (20, 80)
|
|
||||||
|
|
||||||
|
|
||||||
def test_moving_points_need_two_hinges():
|
|
||||||
"""안쪽 측점이 1개 이하면 힌지 둘을 못 만든다 — 쉬프트 불가."""
|
|
||||||
assert _moving_points([20], set(), 0, 30) is None
|
|
||||||
assert _moving_points([], set(), 0, 30) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_moving_points_reuse_tilted_ends():
|
|
||||||
"""사용자가 틸팅한 끝점은 직접 움직인다 — 같은 구간 반복 쉬프트 경로."""
|
|
||||||
assert _moving_points([40, 60], {20, 80}, 20, 80) == (20, 80)
|
|
||||||
assert _moving_points([40, 60], {80}, 20, 80) == (40, 80)
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/* 구조물 측점과 겹치는 규칙 측점 제거 — 2026-09-06.
|
|
||||||
TS 를 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다. */
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { mkdtempSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
const out = mkdtempSync(join(tmpdir(), "aislo-station-"));
|
|
||||||
execFileSync(
|
|
||||||
process.execPath,
|
|
||||||
[
|
|
||||||
"./config/node_modules/typescript/bin/tsc",
|
|
||||||
"B05_Profile/logic/B05_Profile_Util_Station.ts",
|
|
||||||
"--outDir", out,
|
|
||||||
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
const { dropStationsNear, STATION_MERGE_TOLERANCE_M } = await import(
|
|
||||||
pathToFileURL(join(out, "B05_Profile_Util_Station.js")).href
|
|
||||||
);
|
|
||||||
|
|
||||||
const regular = [0, 20, 40, 60, 80].map((chainage_m) => ({ chainage_m, kind: "regular" }));
|
|
||||||
const structures = [{ chainage_m: 40.05 }, { chainage_m: 63.0 }];
|
|
||||||
const kept = dropStationsNear(regular, structures);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
kept.map((s) => s.chainage_m),
|
|
||||||
[0, 20, 60, 80],
|
|
||||||
"0.1m 안에서 겹친 40m 규칙 측점만 빠져야 한다",
|
|
||||||
);
|
|
||||||
assert.equal(STATION_MERGE_TOLERANCE_M, 0.1);
|
|
||||||
// 구조물이 없으면 아무것도 지우지 않는다.
|
|
||||||
assert.equal(dropStationsNear(regular, []).length, regular.length);
|
|
||||||
console.log("OK — 구조물 측점과 겹친 규칙 측점만 제거");
|
|
||||||
@@ -66,7 +66,6 @@ def test_종류를_바꾸면_통째로_새_값(out):
|
|||||||
def test_참조_모양_검사(out):
|
def test_참조_모양_검사(out):
|
||||||
assert out["isItem"] == [True, False, False, False]
|
assert out["isItem"] == [True, False, False, False]
|
||||||
assert out["readItem"] == [A_KEY, None]
|
assert out["readItem"] == [A_KEY, None]
|
||||||
assert out["same"] == [True, False, True, False] # 이름만 바뀌면 같음
|
|
||||||
|
|
||||||
|
|
||||||
def test_키는_앞에_안_내세움_툴팁에만(out):
|
def test_키는_앞에_안_내세움_툴팁에만(out):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 DB 행이 없다. 저장 · 확정이 그 자리 수정을 받으면
|
옮긴 관 · 구조물 측점은 B05 확정 전이면 DB 행이 없다. 저장 · 확정이 그 자리 수정을 받으면
|
||||||
`ensure_structure_stations`([측점 만들기]와 같은 길)를 불러 측점을 세운 뒤 행을 만든다.
|
`ensure_structure_stations`([측점 만들기]와 같은 길)를 불러 측점을 세운 뒤 행을 만든다.
|
||||||
DB · 파일 전체 왕복은 test_b05_b06_save_roundtrip [B06] 이 봄 — 여기서는 부르는 조건만.
|
여기서는 부르는 조건만.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user