Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8
This commit is contained in:
@@ -24,6 +24,9 @@
|
||||
* 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다.
|
||||
* 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이
|
||||
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
|
||||
*
|
||||
* 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py`
|
||||
* `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
@@ -75,8 +78,31 @@ function hasExtras(attributes: FacilityAttributes): boolean {
|
||||
return !!attributes.options && Object.keys(attributes.options).length > 0;
|
||||
}
|
||||
|
||||
/** 관 이동·재계산 왕복에서 시설 정보를 잃지 않게 chainage 최근접으로 되붙이는 보관소.
|
||||
* 백엔드 `carry_facility_attributes`와 같은 기준(정확 일치 → 최근접)이다. */
|
||||
/** 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭.
|
||||
* 서버 `common_util_drainage_pipes.CARRY_TOLERANCE_M` 와 짝. */
|
||||
export const CARRY_TOLERANCE_M = 0.05;
|
||||
|
||||
/** 관 측점마다 속성을 가져올 원본 번호(못 붙이면 null) — 허용 오차 안 가장 가까운 원본 ·
|
||||
* 한 원본은 한 번만. 서버 `carry_indices` 와 짝(거울 시험 test_b05_facility_carry). */
|
||||
export function carryIndices(
|
||||
targets: readonly number[],
|
||||
sources: readonly number[],
|
||||
): Array<number | null> {
|
||||
const free = new Set(sources.keys());
|
||||
return targets.map((target) => {
|
||||
let best: number | null = null;
|
||||
for (const index of free) {
|
||||
const gap = Math.abs(sources[index] - target);
|
||||
if (gap <= CARRY_TOLERANCE_M && (best === null || gap < Math.abs(sources[best] - target)))
|
||||
best = index;
|
||||
}
|
||||
if (best !== null) free.delete(best);
|
||||
return best;
|
||||
});
|
||||
}
|
||||
|
||||
/** 재계산 왕복에서 시설 정보를 잃지 않게 측점으로 되붙이는 보관소 — 붙이기는 `carryIndices`
|
||||
* (서버 `carry_facility_attributes` 와 같은 규칙) · 관을 옮기면 `move` · `follow` 가 속성을 옮김. */
|
||||
export function createFacilityStore() {
|
||||
let entries: Array<{ chainage: number; attributes: FacilityAttributes }> = [];
|
||||
|
||||
@@ -114,22 +140,29 @@ export function createFacilityStore() {
|
||||
const hit = entries.find((entry) => Math.abs(entry.chainage - chainageM) <= 0.005);
|
||||
return hit ? hit.attributes : null;
|
||||
},
|
||||
/** 요청 목록에 시설 정보를 되붙인다. 관이 끌려 이동한 뒤에도(정확 일치 없음)
|
||||
* 가장 가까운 엔트리를 한 번씩만 소비해 승계한다. */
|
||||
/** 옮긴 관의 속성을 새 측점으로 옮긴다(지도에서 끌기). */
|
||||
move(fromM: number, toM: number): void {
|
||||
const hit = entries.find((entry) => Math.abs(entry.chainage - fromM) <= 0.005);
|
||||
if (hit) hit.chainage = toM;
|
||||
},
|
||||
/** 관 목록이 통째로 바뀔 때(되돌리기 · 구조물 폼) — 사라진 관 하나 · 새 관 하나면 옮긴 것으로
|
||||
* 보고 속성을 따라 옮김. 둘 이상이 한꺼번에 바뀌면 옮기지 않음(제자리 관만 속성 유지). */
|
||||
follow(before: readonly number[], after: readonly number[]): void {
|
||||
const near = (list: readonly number[], value: number): boolean =>
|
||||
list.some((other) => Math.abs(other - value) <= CARRY_TOLERANCE_M);
|
||||
const gone = before.filter((value) => !near(after, value));
|
||||
const added = after.filter((value) => !near(before, value));
|
||||
if (gone.length === 1 && added.length === 1) this.move(gone[0], added[0]);
|
||||
},
|
||||
/** 요청 목록에 시설 정보를 되붙인다 — 허용 오차 안의 원본만 · 못 붙인 관은 속성 없음. */
|
||||
attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] {
|
||||
const pool = [...entries];
|
||||
return points.map((point) => {
|
||||
if (!pool.length) return { ...point };
|
||||
let best = 0;
|
||||
for (let index = 1; index < pool.length; index += 1) {
|
||||
if (
|
||||
Math.abs(pool[index].chainage - point.chainage_m) <
|
||||
Math.abs(pool[best].chainage - point.chainage_m)
|
||||
)
|
||||
best = index;
|
||||
}
|
||||
const [entry] = pool.splice(best, 1);
|
||||
return { ...point, ...entry.attributes };
|
||||
const picks = carryIndices(
|
||||
points.map((point) => point.chainage_m),
|
||||
entries.map((entry) => entry.chainage),
|
||||
);
|
||||
return points.map((point, index) => {
|
||||
const pick = picks[index];
|
||||
return pick === null ? { ...point } : { ...point, ...entries[pick].attributes };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,6 +143,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
rememberPipes();
|
||||
void analyze();
|
||||
},
|
||||
(fromM, toM) => facilityStore.move(fromM, toM),
|
||||
);
|
||||
// 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다
|
||||
// (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시).
|
||||
@@ -588,8 +589,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
selectBasin(picked ? picked.index : null, false);
|
||||
},
|
||||
setPipeChainages(chainages) {
|
||||
const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m);
|
||||
const next = reconcilePipes(pipeEditor.pipes(), chainages);
|
||||
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
|
||||
// 되돌리기 · 구조물 폼으로 옮겨진 관도 속성을 데려감
|
||||
facilityStore.follow(
|
||||
before,
|
||||
next.map((pipe) => pipe.chainage_m),
|
||||
);
|
||||
pipeEditor.setPipes(next);
|
||||
void analyze();
|
||||
},
|
||||
|
||||
@@ -56,10 +56,12 @@ export interface PipeEditor {
|
||||
}
|
||||
|
||||
/** `onChange`는 화면 갱신용, `onCommit`은 배치가 실제로 바뀌었을 때(추가·삭제·이동 완료).
|
||||
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). */
|
||||
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시).
|
||||
* `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */
|
||||
export function createPipeEditor(
|
||||
onChange: () => void,
|
||||
onCommit: () => void = () => {},
|
||||
onMove: (fromM: number, toM: number) => void = () => {},
|
||||
): PipeEditor {
|
||||
let meta: VWorldMeta | null = null;
|
||||
let route: Array<{ x: number; y: number; chainage: number }> = [];
|
||||
@@ -68,6 +70,7 @@ export function createPipeEditor(
|
||||
let selectedIndex: number | null = null;
|
||||
let draggingIndex: number | null = null;
|
||||
let dragMoved = false;
|
||||
let dragFrom = 0;
|
||||
/** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다
|
||||
* (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */
|
||||
let dragDownAt: { x: number; y: number } | null = null;
|
||||
@@ -250,6 +253,7 @@ export function createPipeEditor(
|
||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) {
|
||||
selectedIndex = i;
|
||||
draggingIndex = i;
|
||||
dragFrom = pipeList[i].chainage_m;
|
||||
dragDownAt = { x: screenX, y: screenY };
|
||||
onChange();
|
||||
return true;
|
||||
@@ -276,9 +280,11 @@ export function createPipeEditor(
|
||||
},
|
||||
handleUp() {
|
||||
if (draggingIndex === null) return false;
|
||||
const dragged = pipeList[draggingIndex];
|
||||
draggingIndex = null;
|
||||
dragDownAt = null;
|
||||
if (dragMoved) {
|
||||
onMove(dragFrom, dragged.chainage_m);
|
||||
sortPipes();
|
||||
onChange();
|
||||
// 관을 옮겼으면 담당 구간이 달라진다 — 세부유역을 즉시 다시 나눈다.
|
||||
|
||||
@@ -171,15 +171,11 @@ export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
|
||||
);
|
||||
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
|
||||
// ⚠ 이동 키까지 넘긴다(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야
|
||||
// 한다). 종전에는 구간값 키만 넘겨, 옛 세션에 남은 관 이동 예약이 B05 [임시저장]에서는
|
||||
// 조용히 빠졌다. 관 목록 자체는 이제 두 페이지가 같은 스냅샷(`pipes`)을 쓴다.
|
||||
// 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄.
|
||||
if (latest?.route?.id != null) {
|
||||
const routeId = latest.route.id;
|
||||
await flushCulvertOptions(
|
||||
projectId,
|
||||
stateKey("culvertopt", projectId, routeId),
|
||||
stateKey("culvertmove", projectId, routeId),
|
||||
stateKey("culvertopt", projectId, latest.route.id),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
|
||||
|
||||
@@ -58,50 +58,27 @@ function writePending(sessionKey: string | null, pending: PendingMap): void {
|
||||
* 세션에 쌓인 구간값을 `pipe_points.json`에 한 번에 쓴다. 예약분이 없으면 아무것도
|
||||
* 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서
|
||||
* 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다.
|
||||
* 옛 「관 이동 예약」(culvertmove)은 읽지 않음 — 관 위치는 B05·B06 이 같은 관 목록 스냅샷으로만
|
||||
* 옮김. 옛 세션 잔값을 저장 때 적용하면 관이 이웃 측점으로 튀어 시설 속성이 넘어감.
|
||||
*/
|
||||
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m).
|
||||
* ⚠ **이제 쓰는 곳은 없다**(2026-09-12 일원화: 관 목록은 B05·B06 이 같은 스냅샷을 쓴다).
|
||||
* 옛 세션에 남아 있던 예약을 저장 때 한 번 흘려보내려고 읽기만 남겨 둔다. */
|
||||
type PendingMoves = Record<string, number>;
|
||||
|
||||
function readMoves(sessionKey: string | null): PendingMoves {
|
||||
if (!sessionKey) return {};
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(sessionKey);
|
||||
return raw ? (JSON.parse(raw) as PendingMoves) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushCulvertOptions(
|
||||
projectId: string | null,
|
||||
sessionKey: string | null,
|
||||
moveKey: string | null = null,
|
||||
): Promise<void> {
|
||||
const pending = readPending(sessionKey);
|
||||
const moves = readMoves(moveKey);
|
||||
if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return;
|
||||
if (!projectId || Object.keys(pending).length === 0) return;
|
||||
const current = await fetchDetailPipePoints(projectId);
|
||||
let touched = false;
|
||||
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
|
||||
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
|
||||
const { lonlat: _lonlat, ...input } = point;
|
||||
const key = keyOf(point.chainage_m);
|
||||
const patch = pending[key];
|
||||
const movedTo = moves[key];
|
||||
if (!patch && movedTo === undefined) return input;
|
||||
const patch = pending[keyOf(point.chainage_m)];
|
||||
if (!patch) return input;
|
||||
touched = true;
|
||||
const next: DetailPipeInput = patch
|
||||
? { ...input, options: { ...(input.options ?? {}), ...patch } }
|
||||
: { ...input };
|
||||
// 기준 측점 이동 — 관 위치가 바뀌면 서버가 세부 배수유역을 다시 나눠 응답한다.
|
||||
if (movedTo !== undefined) next.chainage_m = movedTo;
|
||||
return next;
|
||||
return { ...input, options: { ...(input.options ?? {}), ...patch } };
|
||||
});
|
||||
if (touched) await saveDetailPipePoints(projectId, points);
|
||||
writePending(sessionKey, {});
|
||||
writePending(moveKey, {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +89,6 @@ export function createCulvertOptionWriter(
|
||||
projectId: () => string | null,
|
||||
sessionKey: () => string | null,
|
||||
onError?: (message: string) => void,
|
||||
/** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */
|
||||
moveKey: () => string | null = () => null,
|
||||
): CulvertOptionWriter {
|
||||
return {
|
||||
queue(chainageM, patch) {
|
||||
@@ -124,7 +99,7 @@ export function createCulvertOptionWriter(
|
||||
},
|
||||
async flush() {
|
||||
try {
|
||||
await flushCulvertOptions(projectId(), sessionKey(), moveKey());
|
||||
await flushCulvertOptions(projectId(), sessionKey());
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
|
||||
throw error;
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface StationControlDeps {
|
||||
| "extrawall"
|
||||
| "extraspan"
|
||||
| "culvertopt"
|
||||
| "culvertmove"
|
||||
| "revetlink"
|
||||
| "fordadjust"
|
||||
| "boxadjust",
|
||||
@@ -429,7 +428,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
deps.projectId,
|
||||
() => deps.sessionKey("culvertopt"),
|
||||
deps.onSaveError,
|
||||
() => deps.sessionKey("culvertmove"),
|
||||
);
|
||||
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
|
||||
const linkDetached = linkSession.detached;
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
노선이 바뀌면(`route_signature` 불일치) 좌표가 있는 저장분은 새 노선에 투영해 이월하고,
|
||||
좌표가 없는 구 저장분만 버린다.
|
||||
|
||||
짝: 시설 속성 붙이기 `carry_indices` ↔ 화면 `B05_Profile/B05_Profile_UI_Drainage_Facility.ts`
|
||||
`carryIndices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -352,21 +355,46 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
|
||||
return points
|
||||
|
||||
|
||||
# 시설 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭.
|
||||
# 화면 `CARRY_TOLERANCE_M` 과 짝.
|
||||
CARRY_TOLERANCE_M = 0.05
|
||||
|
||||
|
||||
def carry_indices(targets: list[float], sources: list[float]) -> list[int | None]:
|
||||
"""관 측점마다 속성을 가져올 원본 번호(못 붙이면 None).
|
||||
|
||||
허용 오차 안 가장 가까운 원본만 · 한 원본은 한 번만 — 화면 `carryIndices` 와 같은 규칙.
|
||||
"""
|
||||
free = list(range(len(sources)))
|
||||
picked: list[int | None] = []
|
||||
for target in targets:
|
||||
best: int | None = None
|
||||
for index in free:
|
||||
gap = abs(sources[index] - target)
|
||||
if gap <= CARRY_TOLERANCE_M and (best is None or gap < abs(sources[best] - target)):
|
||||
best = index
|
||||
if best is not None:
|
||||
free.remove(best)
|
||||
picked.append(best)
|
||||
return picked
|
||||
|
||||
|
||||
def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) -> list[PipePoint]:
|
||||
"""계산기를 거쳐 재구성된 목록에 시설 종류·구간·옵션을 되붙인다.
|
||||
|
||||
세부유역 계산기는 chainage만 다루므로 계산에서 돌아온 목록은 전부 기본 배관이 된다.
|
||||
그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다. 좌표가 미세
|
||||
조정(스냅)될 수 있어 정확 일치가 없으면 가장 가까운 원본에서 승계한다 — 생성 사유를
|
||||
되붙이는 `_retag`(B04 라우터)과 같은 기준이다.
|
||||
세부유역 계산기는 chainage만 다루므로(반올림 0.01m) 계산에서 돌아온 목록은 전부 기본
|
||||
배관이 된다. 그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다.
|
||||
붙이기는 `carry_indices` — 허용 오차 밖의 관은 속성 없이 둔다(옆 관 속성을 가져가지 않음).
|
||||
"""
|
||||
if not reference:
|
||||
return base
|
||||
by_key = {round(ref.chainage_m, 2): ref for ref in reference}
|
||||
for point in base:
|
||||
ref = by_key.get(round(point.chainage_m, 2))
|
||||
if ref is None:
|
||||
ref = min(reference, key=lambda item: abs(item.chainage_m - point.chainage_m))
|
||||
picks = carry_indices(
|
||||
[point.chainage_m for point in base], [ref.chainage_m for ref in reference]
|
||||
)
|
||||
for point, pick in zip(base, picks):
|
||||
if pick is None:
|
||||
continue # 못 붙인 관 = 계산기가 돌려준 기본 배관 그대로(추천은 뒤에서 빈칸에만)
|
||||
ref = reference[pick]
|
||||
point.facility = ref.facility
|
||||
point.start_m = ref.start_m
|
||||
point.end_m = ref.end_m
|
||||
|
||||
@@ -170,14 +170,28 @@ def test_carry_attributes_restores_facility_after_rebuild():
|
||||
assert carried[1].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
def test_carry_attributes_uses_nearest_when_chainage_shifted():
|
||||
"""계산기가 좌표를 미세 조정해도(스냅) 가장 가까운 원본에서 승계한다 — _retag과 동일 기준."""
|
||||
def test_carry_attributes_only_within_tolerance():
|
||||
"""계산기 반올림(0.01m) 차이는 승계 · 허용 오차(0.05m) 밖은 옆 관 속성을 안 가져감."""
|
||||
requested = parse_pipe_points([{"chainage_m": 150.0, "facility": "box_culvert"}])
|
||||
rebuilt = [PipePoint(chainage_m=150.37)]
|
||||
near = carry_facility_attributes([PipePoint(chainage_m=150.004)], requested)
|
||||
far = carry_facility_attributes([PipePoint(chainage_m=150.37)], requested)
|
||||
|
||||
assert near[0].facility == PIPE_FACILITY_BOX
|
||||
assert far[0].facility == PIPE_FACILITY_PIPE and far[0].options is None
|
||||
|
||||
|
||||
def test_carry_attributes_new_pipe_before_box_keeps_box_in_place():
|
||||
"""옵션 없는 관을 BOX 앞에 두어도 BOX 속성은 제 측점에 남음 — 관 수 > 속성 원본 수."""
|
||||
requested = parse_pipe_points(
|
||||
[{"chainage_m": 100.0, "facility": "box_culvert", "options": {"body_width_m": 2.0}}]
|
||||
)
|
||||
rebuilt = [PipePoint(chainage_m=97.0), PipePoint(chainage_m=100.0)]
|
||||
|
||||
carried = carry_facility_attributes(rebuilt, requested)
|
||||
|
||||
assert carried[0].facility == PIPE_FACILITY_BOX
|
||||
assert carried[0].facility == PIPE_FACILITY_PIPE and carried[0].options is None
|
||||
assert carried[1].facility == PIPE_FACILITY_BOX
|
||||
assert carried[1].options == {"body_width_m": 2.0}
|
||||
|
||||
|
||||
def test_carry_attributes_without_reference_is_noop():
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""시설 속성 붙이기 거울 시험 — 화면 `carryIndices`(B05_Profile_UI_Drainage_Facility.ts) ↔
|
||||
서버 `carry_indices`(common_util_drainage_pipes.py) 가 같은 답을 내는지.
|
||||
|
||||
규칙: 허용 오차(0.05m) 안 가장 가까운 원본만 · 한 원본은 한 번만 · 못 붙인 관은 속성 없음.
|
||||
화면 보관소는 관을 끌면(`move`) · 되돌리기로 옮기면(`follow`) 속성을 따라 옮김.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_drainage_pipes import CARRY_TOLERANCE_M, carry_indices
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FACILITY = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts"
|
||||
CULVERT = ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts"
|
||||
|
||||
CASES = [
|
||||
([100.0, 200.0], [100.0, 200.0]), # 제자리
|
||||
([100.004], [100.0]), # 반올림 차이
|
||||
([95.0, 100.0], [100.0]), # 관 수 > 원본 수 — 앞 관이 BOX 를 가져가지 않음
|
||||
([150.37], [150.0]), # 허용 오차 밖
|
||||
([100.01, 100.02], [100.0]), # 한 원본은 한 번만
|
||||
([100.0], [99.97, 100.02]), # 허용 오차 안 가장 가까운 것
|
||||
([100.0, 100.0], [100.06, 100.04]), # 경계 — 0.06 은 밖
|
||||
([100.0], []), # 원본 없음
|
||||
]
|
||||
|
||||
|
||||
def _ts_block(body: str, head: str) -> str:
|
||||
"""최상위 선언 하나를 떼어 냄 — 상수는 한 줄 · 함수는 첫 칸 `}` 까지."""
|
||||
tail = r"[^\n]*;$" if head.startswith("const") else r"[\s\S]*?^\}$"
|
||||
found = re.search(rf"^export {head}{tail}", body, re.M)
|
||||
assert found, head
|
||||
return found.group(0).replace("export ", "")
|
||||
|
||||
|
||||
def _run_node(script: str) -> object:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_서버_규칙():
|
||||
assert CARRY_TOLERANCE_M == 0.05
|
||||
assert [carry_indices(t, s) for t, s in CASES] == [
|
||||
[0, 1],
|
||||
[0],
|
||||
[None, 0],
|
||||
[None],
|
||||
[0, None],
|
||||
[1],
|
||||
[1, None],
|
||||
[None],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_화면과_서버가_같은_답():
|
||||
body = FACILITY.read_text(encoding="utf-8")
|
||||
script = (
|
||||
_ts_block(body, "const CARRY_TOLERANCE_M")
|
||||
+ "\n"
|
||||
+ _ts_block(body, "function carryIndices")
|
||||
+ f"\nconst cases = {json.dumps(CASES)};"
|
||||
+ "\nconsole.log(JSON.stringify([CARRY_TOLERANCE_M,"
|
||||
+ " cases.map(([t, s]) => carryIndices(t, s))]));"
|
||||
)
|
||||
tolerance, picks = _run_node(script)
|
||||
assert tolerance == CARRY_TOLERANCE_M
|
||||
assert picks == [carry_indices(t, s) for t, s in CASES]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_화면_보관소_끌기_되돌리기_때_속성이_따라감():
|
||||
"""BOX(100) · 옵션 없는 관(80) — 관을 끌거나 되돌려도 BOX 속성은 BOX 관에만."""
|
||||
body = FACILITY.read_text(encoding="utf-8")
|
||||
script = (
|
||||
"type PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n"
|
||||
"interface FacilityAttributes { facility: string; start_m?: number; end_m?: number;"
|
||||
" options?: Record<string, string | number> }\n"
|
||||
+ "\n".join(
|
||||
_ts_block(body, head)
|
||||
for head in (
|
||||
"const CARRY_TOLERANCE_M",
|
||||
"function carryIndices",
|
||||
"function createFacilityStore",
|
||||
)
|
||||
)
|
||||
+ "\n"
|
||||
+ re.search(r"^function hasExtras[\s\S]*?^\}$", body, re.M).group(0)
|
||||
+ """
|
||||
const store = createFacilityStore();
|
||||
store.replaceFromResponse([{ chainage_m: 80 }, { chainage_m: 100, facility: "box_culvert" }]);
|
||||
const kinds = (list) => store.attach(list.map((c) => ({ chainage_m: c, source: "user" })))
|
||||
.map((p) => p.facility ?? "-");
|
||||
const out = {};
|
||||
out.관을_BOX_곁으로 = kinds([99, 100]); // 옵션 없는 관을 BOX 곁으로 끌기(보관소 변화 없음)
|
||||
store.move(100, 130); // BOX 를 끌기
|
||||
out.BOX_끌기 = kinds([80, 130]);
|
||||
store.follow([80, 130], [80, 100]); // 되돌리기 — BOX 가 제자리로
|
||||
out.되돌리기 = kinds([80, 100]);
|
||||
store.follow([80, 100], [81, 120]); // 둘이 한꺼번에 바뀜 — 옮기지 않음
|
||||
out.둘_바뀜 = kinds([81, 120]);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
)
|
||||
assert _run_node(script) == {
|
||||
"관을_BOX_곁으로": ["-", "box_culvert"],
|
||||
"BOX_끌기": ["-", "box_culvert"],
|
||||
"되돌리기": ["-", "box_culvert"],
|
||||
"둘_바뀜": ["-", "-"],
|
||||
}
|
||||
|
||||
|
||||
def test_옛_관_이동_예약은_저장_때_안_씀():
|
||||
body = CULVERT.read_text(encoding="utf-8")
|
||||
assert "readMoves" not in body and "movedTo" not in body and "moveKey" not in body
|
||||
@@ -7,8 +7,8 @@ B05 는 관 목록 전체를 세션 스냅샷(`pipes`)으로 담고, B06 은 바
|
||||
여기서 지키는 것은 둘이다.
|
||||
① 저장기(`createCulvertOptionWriter`)에는 **관을 넣고 빼는 길이 없다** — 남아 있으면
|
||||
관 목록을 담는 자리가 다시 둘이 된다.
|
||||
② 옛 세션에 남아 있던 **이동 예약은 저장 때 흘려보낸다** — 합치기 전에 만들어 둔
|
||||
예약이 조용히 사라지면 안 된다.
|
||||
② 옛 세션에 남아 있던 **이동 예약은 저장 때 안 씀** — 관 위치는 스냅샷으로만 옮김
|
||||
(적용하면 관이 이웃 측점으로 튀어 BOX · 세월교 속성이 넘어감).
|
||||
|
||||
실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식).
|
||||
"""
|
||||
@@ -62,21 +62,17 @@ global.window = {
|
||||
};
|
||||
|
||||
const api = require(process.argv[3]);
|
||||
const writer = api.createCulvertOptionWriter(
|
||||
() => "p1",
|
||||
() => "opt",
|
||||
undefined,
|
||||
() => "move",
|
||||
);
|
||||
const writer = api.createCulvertOptionWriter(() => "p1", () => "opt", undefined);
|
||||
|
||||
// ① 관을 넣고 빼는 길이 저장기에 남아 있으면 안 된다.
|
||||
const methods = Object.keys(writer).sort();
|
||||
|
||||
// ② 합치기 전 세션에 남아 있던 이동 예약은 저장 때 흘려보낸다.
|
||||
// ② 옛 세션의 이동 예약은 남아 있어도 안 씀 — 구간값만 제 측점에 나감.
|
||||
store.set("move", JSON.stringify({ "120.00": 135 }));
|
||||
store.set("opt", JSON.stringify({ "120.00": { pipe_count: 2 } }));
|
||||
|
||||
api
|
||||
.flushCulvertOptions("p1", "opt", "move")
|
||||
.flushCulvertOptions("p1", "opt")
|
||||
.then(() => {
|
||||
writeFileSync(
|
||||
process.argv[2],
|
||||
@@ -134,6 +130,5 @@ def test_관_목록을_담는_자리는_하나뿐이다(tmp_path: Path) -> None:
|
||||
# ① 저장기에는 구간값 예약과 내보내기만 있다 — 관을 넣고 빼는 길은 스냅샷 한 곳뿐이다.
|
||||
assert result["methods"] == ["flush", "queue"]
|
||||
|
||||
# ② 옛 세션의 이동 예약은 저장 때 반영되고 비워진다.
|
||||
assert result["savedChainages"] == [135, 200]
|
||||
assert result["moveLeft"] is None
|
||||
# ② 옛 세션의 이동 예약은 저장 때 안 씀 — 관은 제 측점 그대로.
|
||||
assert result["savedChainages"] == [120, 200]
|
||||
|
||||
Reference in New Issue
Block a user