chore(tester): 시험을 resources/tester/ 로 옮김 — 창끼리 건너가게

⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt`
가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는
시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음.

- `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침**
- `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시)
- 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q`
  옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0

⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 17:12:30 +09:00
co-authored by Claude Opus 5
parent 5e8acb1312
commit 0ef32b5279
127 changed files with 14175 additions and 0 deletions
@@ -0,0 +1,50 @@
/**
* 블록 라이브러리 저장·해제 검증 (조사표 4절 BLOCK/INSERT).
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config vitest.tmp.config.ts
* jsdom을 새로 깔지 않으려고 이 모듈이 쓰는 브라우저 전역 둘만 흉내 낸다.
*/
import { Point, Segment } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
const store = new Map<string, string>();
Object.assign(globalThis, {
window: { dispatchEvent: () => true },
CustomEvent: class {
constructor(public type: string) {}
},
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
},
});
const { deleteBlock, deserializeBlock, getBlocks, saveBlockFromEntities } = await import(
'../../../B07_DesignDetail/openwebcad/src/blocks/block-library'
);
const { LineEntity } = await import(
'../../../B07_DesignDetail/openwebcad/src/entities/LineEntity'
);
describe('블록 라이브러리', () => {
it('기준점은 선택 영역 중심이고, 다시 풀어 삽입점으로 옮기면 그 점이 중심이 된다', async () => {
const line = new LineEntity('layer-1', new Segment(new Point(0, 0), new Point(100, 50)));
await saveBlockFromEntities('테스트블록', [line]);
const block = getBlocks().find((candidate) => candidate.name === '테스트블록');
expect(block).toBeDefined();
if (!block) return;
expect(block.basePoint).toEqual({ x: 50, y: 25 });
const [copy] = await deserializeBlock(block);
expect(copy.id).not.toBe(line.id);
expect(copy.groupId).toBeTruthy();
// 삽입점 (200, 100)으로 옮기면 원본 크기 100×50이 그 점을 중심으로 놓인다
copy.move(200 - block.basePoint.x, 100 - block.basePoint.y);
const box = copy.getBoundingBox();
expect([box.xmin, box.ymin, box.xmax, box.ymax]).toEqual([150, 75, 250, 125]);
deleteBlock('테스트블록');
expect(getBlocks().find((candidate) => candidate.name === '테스트블록')).toBeUndefined();
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* B07 CAD 기하 순수 함수 검증 (조사표 1·2절 명령의 계산 부분).
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --root ../.. tmp/tests/cad
*/
import { Point } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import { hatchSpans, isPointInPolygon } from '../../../B07_DesignDetail/openwebcad/src/helpers/geometry/hatch-lines';
import {
dividePoints,
measurePoints,
pointAtDistance,
polylineLength,
} from '../../../B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity';
import {
arcThroughThreePoints,
ellipsePoints,
intersectLines,
offsetPolylinePoints,
regularPolygonPoints,
revisionCloudPoints,
splinePoints,
} from '../../../B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points';
const p = (x: number, y: number) => new Point(x, y);
describe('3점 호 (ARC)', () => {
it('단위원 위의 세 점이면 중심은 원점, 반지름은 1이다', () => {
const arc = arcThroughThreePoints(p(1, 0), p(0, 1), p(-1, 0));
expect(arc).not.toBeNull();
expect(arc?.center.x).toBeCloseTo(0, 6);
expect(arc?.center.y).toBeCloseTo(0, 6);
expect(arc?.radius).toBeCloseTo(1, 6);
});
it('일직선이면 호가 성립하지 않는다', () => {
expect(arcThroughThreePoints(p(0, 0), p(1, 0), p(2, 0))).toBeNull();
});
});
describe('정다각형 (POLYGON)', () => {
it('변 수만큼 꼭짓점을 만들고 마지막에 닫는다', () => {
const points = regularPolygonPoints(p(0, 0), p(1, 0), 6);
expect(points).toHaveLength(7); // 꼭짓점 6 + 닫는 점
expect(points[6].x).toBeCloseTo(points[0].x, 9);
for (const point of points) {
expect(Math.hypot(point.x, point.y)).toBeCloseTo(1, 9);
}
});
});
describe('타원 (ELLIPSE)', () => {
it('장축·단축 반지름이 지정한 값과 같다', () => {
const points = ellipsePoints(p(0, 0), p(2, 0), 1, 4);
expect(points[0].x).toBeCloseTo(2, 9); // 장축 방향
expect(points[1].y).toBeCloseTo(1, 9); // 90°에서 단축 반지름
});
});
describe('간격띄우기 (OFFSET·MLINE)', () => {
it('수평선을 왼쪽으로 밀면 y가 커진다', () => {
const offset = offsetPolylinePoints([p(0, 0), p(10, 0)], 2);
expect(offset[0].y).toBeCloseTo(2, 9);
expect(offset[offset.length - 1].y).toBeCloseTo(2, 9);
});
it('꺾인 폴리선은 이음매에서 두 평행선의 교점을 쓴다', () => {
const offset = offsetPolylinePoints([p(0, 0), p(10, 0), p(10, 10)], 1);
expect(offset).toHaveLength(3);
expect(offset[1].x).toBeCloseTo(9, 9);
expect(offset[1].y).toBeCloseTo(1, 9);
});
});
describe('직선 교점 (FILLET·CHAMFER·EXTEND)', () => {
it('직교하는 두 직선의 교점을 찾는다', () => {
const crossing = intersectLines(p(0, 0), p(10, 0), p(4, -5), p(4, 5));
expect(crossing?.x).toBeCloseTo(4, 9);
expect(crossing?.y).toBeCloseTo(0, 9);
});
it('평행하면 교점이 없다', () => {
expect(intersectLines(p(0, 0), p(10, 0), p(0, 3), p(10, 3))).toBeNull();
});
});
describe('점렬 길이와 분할 (DIVIDE·MEASURE)', () => {
const line = [p(0, 0), p(10, 0)];
it('길이를 누적해서 잰다', () => {
expect(polylineLength([p(0, 0), p(3, 4), p(3, 4)])).toBeCloseTo(5, 9);
});
it('지정 거리 위치의 점을 찾는다', () => {
expect(pointAtDistance(line, 2.5)?.x).toBeCloseTo(2.5, 9);
expect(pointAtDistance(line, 20)).toBeNull();
});
it('등분은 내부 점만 만든다', () => {
const points = dividePoints(line, 4);
expect(points).toHaveLength(3);
expect(points.map((point) => point.x)).toEqual([2.5, 5, 7.5]);
});
it('길이분할은 지정 간격마다 점을 둔다', () => {
expect(measurePoints(line, 4).map((point) => point.x)).toEqual([4, 8]);
});
});
describe('해치 스캔선 (HATCH)', () => {
const square = [p(0, 0), p(10, 0), p(10, 10), p(0, 10)];
it('사각형 내부를 가로지르는 선분을 만든다', () => {
const spans = hatchSpans(square, 0, 2);
expect(spans.length).toBeGreaterThan(3);
for (const [start, end] of spans) {
expect(start.x).toBeCloseTo(0, 6);
expect(end.x).toBeCloseTo(10, 6);
expect(start.y).toBeGreaterThan(0);
expect(start.y).toBeLessThan(10);
}
});
it('점이 다각형 안에 있는지 가른다', () => {
expect(isPointInPolygon(square, p(5, 5))).toBe(true);
expect(isPointInPolygon(square, p(15, 5))).toBe(false);
});
});
describe('스플라인·구름형 (SPLINE·REVCLOUD)', () => {
it('스플라인은 시작·끝 조정점을 지난다', () => {
const curve = splinePoints([p(0, 0), p(5, 5), p(10, 0)], 8);
expect(curve[0].x).toBeCloseTo(0, 9);
expect(curve[curve.length - 1].x).toBeCloseTo(10, 9);
expect(curve.length).toBeGreaterThan(10);
});
it('구름형은 경로 길이에 맞춰 스캘럽을 채운다', () => {
const cloud = revisionCloudPoints([p(0, 0), p(10, 0)], 1);
expect(cloud.length).toBeGreaterThan(20);
expect(cloud[0].x).toBeCloseTo(0, 6);
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* 그립 편집 검증 (조사표 11절 그립 편집 · 다기능 그립).
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config vitest.tmp.config.ts
*/
import { Point, Segment } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import {
applyGrip,
findGripAt,
getGrips,
removePolylineVertex,
} from '../../../B07_DesignDetail/openwebcad/src/helpers/grips';
import { LineEntity } from '../../../B07_DesignDetail/openwebcad/src/entities/LineEntity';
import { PolyLineEntity } from '../../../B07_DesignDetail/openwebcad/src/entities/PolyLineEntity';
import { RectangleEntity } from '../../../B07_DesignDetail/openwebcad/src/entities/RectangleEntity';
const line = (x1: number, y1: number, x2: number, y2: number) =>
new LineEntity('layer-1', new Segment(new Point(x1, y1), new Point(x2, y2)));
const box = (entity: { getBoundingBox: () => { xmin: number; ymin: number; xmax: number; ymax: number } }) => {
const bounds = entity.getBoundingBox();
return [bounds.xmin, bounds.ymin, bounds.xmax, bounds.ymax];
};
describe('그립', () => {
it('선은 끝점 2개와 중점 1개를 갖고, 끝점을 옮기면 그 끝만 움직인다', () => {
const entity = line(0, 0, 100, 0);
const grips = getGrips(entity);
expect(grips.map((grip) => grip.kind)).toEqual(['vertex', 'vertex', 'midpoint']);
expect(grips[2].point.x).toBe(50);
const edited = applyGrip(entity, grips[1], new Point(100, 60));
expect(edited).toBeTruthy();
expect(box(edited as LineEntity)).toEqual([0, 0, 100, 60]);
// 같은 객체로 바뀌어 끼워지도록 id를 물려받는다
expect((edited as LineEntity).id).toBe(entity.id);
// 원본은 그대로다
expect(box(entity)).toEqual([0, 0, 100, 0]);
});
it('선 중점을 옮기면 선 전체가 따라간다', () => {
const entity = line(0, 0, 100, 0);
const edited = applyGrip(entity, getGrips(entity)[2], new Point(50, 40));
expect(box(edited as LineEntity)).toEqual([0, 40, 100, 40]);
});
it('사각형 모서리를 끌면 마주 보는 모서리를 잡은 채 크기가 바뀐다', () => {
const entity = new RectangleEntity('layer-1', new Point(0, 0), new Point(100, 50));
const corner = getGrips(entity).find((grip) => grip.point.x === 0 && grip.point.y === 0);
expect(corner).toBeTruthy();
const edited = applyGrip(entity, corner as never, new Point(-20, -10));
expect(box(edited as RectangleEntity)).toEqual([-20, -10, 100, 50]);
});
it('폴리선 세그먼트 중점을 끌면 정점이 하나 늘고, Ctrl 제거로 다시 줄어든다', () => {
const polyline = new PolyLineEntity('layer-1', [line(0, 0, 100, 0), line(100, 0, 200, 0)]);
const grips = getGrips(polyline);
expect(grips.filter((grip) => grip.kind === 'vertex')).toHaveLength(3);
const firstMidpoint = grips.find((grip) => grip.kind === 'midpoint');
const grown = applyGrip(polyline, firstMidpoint as never, new Point(50, 30));
expect(getGrips(grown as PolyLineEntity).filter((grip) => grip.kind === 'vertex')).toHaveLength(
4
);
expect(box(grown as PolyLineEntity)).toEqual([0, 0, 200, 30]);
const middleVertex = getGrips(grown as PolyLineEntity).filter(
(grip) => grip.kind === 'vertex'
)[1];
const shrunk = removePolylineVertex(grown as PolyLineEntity, middleVertex);
expect(
getGrips(shrunk as PolyLineEntity).filter((grip) => grip.kind === 'vertex')
).toHaveLength(3);
});
it('클릭 지점 가까이에 그립이 없으면 아무것도 집지 않는다', () => {
const entity = line(0, 0, 100, 0);
expect(findGripAt([entity], new Point(50, 0), 5)).toBeTruthy(); // 중점
expect(findGripAt([entity], new Point(50, 40), 5)).toBeNull();
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* 표 객체 검증 (조사표 5절 표 · 12절 테이블 셀).
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config vitest.tmp.config.ts
*/
import { Point } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import {
cellAt,
cellRect,
normalizeCells,
tableBorders,
type TableCells,
} from '../../../B07_DesignDetail/openwebcad/src/helpers/table-geometry';
import { TableEntity } from '../../../B07_DesignDetail/openwebcad/src/entities/TableEntity';
const origin = { x: 0, y: 100 };
const widths = [20, 30, 10];
const heights = [10, 10];
describe('표 기하', () => {
it('병합이 없으면 바깥 4 + 안쪽 세로 2×2 + 안쪽 가로 3 = 11개 경계선', () => {
const cells = normalizeCells([], 2, 3);
expect(tableBorders(origin, widths, heights, cells)).toHaveLength(11);
});
it('가로 병합은 그 자리의 세로선을 지운다', () => {
const cells: TableCells = normalizeCells(
[[{ text: '머리', colSpan: 3 }, null, null], [{ text: 'a' }, { text: 'b' }, { text: 'c' }]],
2,
3
);
const borders = tableBorders(origin, widths, heights, cells);
// 첫 행의 세로선 2개가 사라진다
expect(borders).toHaveLength(9);
const topRowVerticals = borders.filter(
(border) => border.x1 === border.x2 && border.y1 === 100 && border.x1 !== 0 && border.x1 !== 60
);
expect(topRowVerticals).toHaveLength(0);
});
it('세로 병합은 그 자리의 가로선을 지우고 셀 사각형이 두 행을 덮는다', () => {
const cells = normalizeCells(
[
[{ text: '그룹', rowSpan: 2 }, { text: 'b' }, { text: 'c' }],
[null, { text: 'e' }, { text: 'f' }],
],
2,
3
);
const borders = tableBorders(origin, widths, heights, cells);
const middleHorizontals = borders.filter((border) => border.y1 === 90 && border.y2 === 90);
expect(middleHorizontals).toHaveLength(2); // 첫 열은 병합이라 빠진다
const rect = cellRect(origin, widths, heights, 0, 0, cells[0][0]);
expect([rect.left, rect.top, rect.right, rect.bottom]).toEqual([0, 100, 20, 80]);
});
it('클릭 지점의 칸을 찾고, 병합 자리는 앵커를 돌려준다', () => {
const cells = normalizeCells(
[
[{ text: '그룹', rowSpan: 2 }, { text: 'b' }, { text: 'c' }],
[null, { text: 'e' }, { text: 'f' }],
],
2,
3
);
expect(cellAt(origin, widths, heights, cells, { x: 30, y: 95 })).toEqual({ row: 0, column: 1 });
// 아래쪽 병합 자리를 찍어도 앵커(0,0)가 나온다
expect(cellAt(origin, widths, heights, cells, { x: 10, y: 85 })).toEqual({ row: 0, column: 0 });
expect(cellAt(origin, widths, heights, cells, { x: 200, y: 95 })).toBeNull();
});
it('빈 칸을 채워도 병합은 풀리지 않는다', () => {
const normalized = normalizeCells([[{ text: '머리', colSpan: 3 }]], 2, 3);
expect(normalized[0][1]).toBeNull();
expect(normalized[0][2]).toBeNull();
expect(normalized[1][0]).toEqual({ text: '' });
});
});
describe('표 객체', () => {
const makeTable = () =>
new TableEntity('layer-1', new Point(0, 100), [...widths], [...heights], [
[{ text: '머리', colSpan: 3 }, null, null],
[{ text: 'a' }, { text: 'b', key: 'cut_soil' }, { text: 'c' }],
]);
it('경계상자는 열 폭·행 높이의 합이다', () => {
const box = makeTable().getBoundingBox();
expect([box.xmin, box.ymin, box.xmax, box.ymax]).toEqual([0, 80, 60, 100]);
});
it('JSON 왕복에서 병합·칸 키가 살아남는다', async () => {
const json = await makeTable().toJson();
expect(json).toBeTruthy();
if (!json) return;
const restored = await TableEntity.fromJson(json as never);
expect(restored.getCell(0, 0)?.colSpan).toBe(3);
expect(restored.getCell(0, 1)).toBeNull();
expect(restored.getCell(1, 1)?.key).toBe('cut_soil');
const box = restored.getBoundingBox();
expect([box.xmin, box.ymin, box.xmax, box.ymax]).toEqual([0, 80, 60, 100]);
});
it('행·열을 넣고 지울 수 있다', () => {
const table = makeTable();
table.insertRow(2);
expect(table.getRowHeights()).toHaveLength(3);
table.insertColumn(0);
expect(table.getColumnWidths()).toHaveLength(4);
table.deleteColumn(0);
table.deleteRow(2);
expect(table.getColumnWidths()).toEqual(widths);
expect(table.getRowHeights()).toEqual(heights);
});
it('열 폭 그립으로 폭만 바뀌고 전체 폭이 따라 늘어난다', () => {
const table = makeTable();
table.setColumnWidth(0, 40);
const box = table.getBoundingBox();
expect(box.xmax - box.xmin).toBe(80);
});
});
+6
View File
@@ -0,0 +1,6 @@
"""pytest 공통 설정 — 프로젝트 루트를 import 경로에 올린다."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""신규 프로젝트에서 구조물이 안 보이는 원인 진단 — 읽기 전용.
실행: ./venv/Scripts/python.exe tmp/tests/diag_structures.py [프로젝트UUID]
인자를 생략하면 storage 아래에서 **가장 최근에 만들어진** 프로젝트를 고른다.
자동설계 체인이 남겨야 할 것을 한 줄씩 대조한다:
1. 배관 정본(edits/pipe_points.json) — 관·세월교·BOX 지정
2. 종단 정본 stations[].structure — 구조물 측점 표식
3. 횡단 정본 cross_*.json 의 structure — 측점 파일
4. B06 읽기 경로(load_culvert_sets) — 실제로 세트가 붙는가
5. 초기값 스냅샷 — 체인 직후 상태가 떠졌는가
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
def newest_project() -> Path | None:
candidates = [p for p in (ROOT / "storage").glob("*/*/*") if (p / "project_manifest.json").is_file()]
return max(candidates, key=lambda p: p.stat().st_mtime) if candidates else None
def read_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def main() -> int:
if len(sys.argv) > 1:
matches = [p for p in (ROOT / "storage").glob(f"*/*/{sys.argv[1]}")]
project = matches[0] if matches else None
else:
project = newest_project()
if project is None or not project.is_dir():
print("프로젝트를 찾지 못했습니다.")
return 1
print(f"프로젝트: {project}")
# 1) 배관 정본
pipes_path = project / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json"
pipes = read_json(pipes_path)
points = (pipes or {}).get("points") or []
print(f"1) pipe_points.json 존재={pipes_path.is_file()} 관={len(points)}")
for point in points:
print(
f" - {point.get('chainage_m')}m facility={point.get('facility') or 'pipe'}"
f" options={list((point.get('options') or {}).keys())}"
)
if pipes:
print(f" route_signature={str(pipes.get('route_signature'))[:16]}")
# 2) 종단 정본 구조물 표식
long_path = project / "B06_Section" / "longitudinal" / "longitudinal.json"
longitudinal = read_json(long_path) or {}
stations = longitudinal.get("stations") or []
marked = [s for s in stations if s.get("structure")]
print(f"2) longitudinal.json 측점={len(stations)} 구조물 표식={len(marked)}")
for station in marked:
print(f" - {station.get('chainage_m')}m {station.get('structure')} kind={station.get('kind')}")
# 3) 횡단 정본 파일
cross_dir = project / "B06_Section" / "cross_sections"
files = sorted(cross_dir.glob("cross_*.json")) if cross_dir.is_dir() else []
with_structure = [(p.name, read_json(p).get("structure")) for p in files if (read_json(p) or {}).get("structure")]
print(f"3) cross_*.json 파일={len(files)}개 구조물 표식={len(with_structure)}{with_structure}")
# 4) B06 읽기 경로 — 실제로 세트가 붙는가
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
sets = load_culvert_sets(project)
print(f"4) load_culvert_sets 세트={len(sets)}")
for chainage, spec in sorted(sets.items()):
print(f" - {chainage}m kind={spec.get('kind')}")
# 측점과 붙을 수 있는가 (허용오차 0.02m)
chainages = [float(s.get("chainage_m", -1)) for s in stations]
for chainage in sorted(sets):
hit = any(abs(chainage - c) <= 0.02 for c in chainages)
if not hit:
print(f"{chainage}m 에 붙을 측점이 없다 — 횡단도에 구조물이 안 뜬다")
# 5) 초기값 스냅샷
snapshot = project / "initial_snapshot"
snap_pipes = read_json(snapshot / "B04_PreProcess__drainage__edits" / "pipe_points.json")
print(
f"5) initial_snapshot 존재={snapshot.is_dir()}"
f" 스냅샷 관={len((snap_pipes or {}).get('points') or [])}"
)
lock = project / "initial_design.lock"
print(f" initial_design.lock(체인 진행 중 표시)={lock.exists()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,111 @@
/* B05 잘린 자리 윤곽(`cut-merged`) 단위검증 헬퍼 — `_Corridor_Cut.ts`를 그 자리에서
* 트랜스파일해 Node로 돌린다(타입 import뿐이라 의존 없음). 2026-09-02 */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Corridor_Cut.ts"),
"utf8",
);
const js = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const moduleBox = { exports: {} };
new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require);
const { maskFillByPlanCurves } = moduleBox.exports;
/** 직선 노선 성토 리본 — x = 종방향(1m/행), y = 횡방향(1m/열), z = 0. */
function ribbonOf(rows, cols) {
const positions = new Float64Array(rows * cols * 3);
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
const i = (row * cols + col) * 3;
positions[i] = row;
positions[i + 1] = col;
positions[i + 2] = 0;
}
}
return {
kind: "fill",
side: "left",
colCount: cols,
chainages: Array.from({ length: rows }, (_v, row) => row),
positions,
groundZ: new Float64Array(rows * cols),
};
}
/** 절취 영역 — 직사각형 x∈[x0,x1], y∈[y0,y1] (닫힘점 포함). */
function rectCurve(x0, x1, y0, y1) {
return {
setChainageM: (x0 + x1) / 2,
source: "slope-projected",
role: "inlet",
side: "left",
planZ: 10,
loops: [
[
[x0, y0, 10],
[x1, y0, 10],
[x1, y1, 10],
[x0, y1, 10],
[x0, y0, 10],
],
],
};
}
function insideLoop(loop, x, y) {
let hit = false;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
const [xi, yi] = loop[i];
const [xj, yj] = loop[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
}
return hit;
}
function run(rows, cols, curve) {
const ribbon = ribbonOf(rows, cols);
const { ribbons, curves } = maskFillByPlanCurves([ribbon], [curve]);
const cut = ribbons[0];
const merged = curves.filter((c) => c.source === "cut-merged");
const loops = merged.flatMap((c) => c.loops);
let masked = 0;
let covered = 0;
const uncovered = [];
if (cut.cellMask) {
for (let row = 0; row < rows - 1; row += 1) {
for (let col = 0; col < cols - 1; col += 1) {
if (!cut.cellMask[row * (cols - 1) + col]) continue;
masked += 1;
const cx = row + 0.5;
const cy = col + 0.5;
if (loops.some((loop) => insideLoop(loop, cx, cy))) covered += 1;
else uncovered.push([row, col]);
}
}
}
return {
masked,
covered,
uncovered,
loops: loops.map((loop) => ({
n: loop.length,
closed:
loop.length > 1 &&
loop[0][0] === loop[loop.length - 1][0] &&
loop[0][1] === loop[loop.length - 1][1],
points: loop.map(([x, y]) => [x, y]),
})),
};
}
const out = {
// 직사각형 — 시작 모서리(첫 행 첫 열)가 살아야 4모서리 + 닫힘점 = 5.
rect: run(12, 6, rectCurve(1.9, 7.1, -0.5, 3.4)),
// 한 셀짜리 — 가장 작은 고리도 4모서리를 다 지킨다.
single: run(6, 4, rectCurve(1.9, 3.1, 0.9, 2.1)),
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,85 @@
/* B05 패치 스커트 단위검증 헬퍼 — TS를 그 자리에서 트랜스파일해 Node로 돌린다.
* (프론트에 JS 테스트 러너가 없어 pytest가 이 스크립트를 호출한다 — 2026-08-27) */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Corridor_Skirt.ts"),
"utf8",
);
const js = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const moduleBox = { exports: {} };
new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require);
const { buildPatchSkirts } = moduleBox.exports;
/** 직선 노선 패치 리본 하나 — 바깥 열 z를 행마다 지정해 만든다. */
function ribbonOf(outerZ, options = {}) {
const cols = 3;
const rows = outerZ.length;
const positions = new Float64Array(rows * cols * 3);
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
const i = (row * cols + col) * 3;
positions[i] = row; // x = 종방향 1m 간격
positions[i + 1] = col; // y = 횡방향(마지막 열이 바깥)
positions[i + 2] = col === cols - 1 ? outerZ[row] : 100;
}
}
return {
kind: "fill",
side: "left",
colCount: cols,
chainages: outerZ.map((_z, row) => row),
positions,
patch: true,
...options,
};
}
const results = {};
// ① 뜬 모서리 — 바깥 끝이 지반(90)보다 0.5m 위: 마디마다 판 2장씩.
{
const ribbon = ribbonOf([90.5, 90.5, 90.5]);
const { walls, stats } = buildPatchSkirts([ribbon], () => 90);
results.floating = {
walls: walls.length,
triangles: stats.triangles,
hatched: walls.every((w) => w.uvs && w.uvs.length === (w.positions.length / 3) * 2),
marked: walls.every((w) => w.patchSkirt === true),
};
}
// ② 잠긴 모서리 — 바깥 끝이 지반 아래: 판 없음(지형이 덮는다).
{
const ribbon = ribbonOf([89.5, 89.5, 89.5]);
const { walls, stats } = buildPatchSkirts([ribbon], () => 90);
results.submerged = { walls: walls.length, skipped: stats.skippedSubmerged };
}
// ③ 구조물에서 끝나는 줄 — 소유 측점(가운데 행) 낙차 2m > 허용 0.5m: 리본 전체 제외.
{
const ribbon = ribbonOf([92, 92, 92]);
const { walls, stats } = buildPatchSkirts([ribbon], () => 90);
results.toeGate = { walls: walls.length, reaches: stats.detail[0].reachesGround };
}
// ④ 낙차 상한 — 게이트는 통과(가운데 0.2m)하되 한 마디가 3m 넘게 뜨면 그 마디만 건너뜀.
{
const ribbon = ribbonOf([90.2, 94, 90.2, 90.2]);
const { walls, stats } = buildPatchSkirts([ribbon], () => 90);
results.tall = { walls: walls.length, skippedTall: stats.skippedTall, tris: stats.triangles };
}
// ⑤ 비패치·절토 리본은 무시.
{
const plain = ribbonOf([90.5, 90.5], { patch: undefined });
const cut = ribbonOf([90.5, 90.5], { kind: "cut" });
const { walls } = buildPatchSkirts([plain, cut], () => 90);
results.ignored = { walls: walls.length };
}
process.stdout.write(JSON.stringify(results));
@@ -0,0 +1,114 @@
"""B06 성토사면 경사길이 교차검증 헬퍼 (2026-09-03).
화면(`B06_Section_UI_Cross_Fit.fillSlopeLengths`)이 낸 값을 **API 원본으로 다시 계산해**
대조한다. 프론트 구현과 독립적으로 설계선·지반선만 보고 재는 것이 목적이라 pytest 가
아니라 실행 스크립트다.
1) 공용 브라우저에서 `/api/projects/{pid}/sections/{route}/detail` 응답을 파일로 저장
2) ./venv/Scripts/python.exe tmp/tests/helper_b06_fill_slope_length.py <그 파일> [측점...]
「≥」는 사면이 설계선 끝(계산 반폭)까지 원지반을 못 만난 측점 — 거기까지의 하한값이다.
허용 오차 — 화면은 교차 판정에 2cm 허용오차(`MEET_TOLERANCE_M`)를 쓰고 여기서는 부호
전환만 보므로, 사면이 지반과 나란히 붙는 자리에서 최대 0.15m 차이가 난다(2026-09-03 실측).
"""
import json
import math
import pathlib
import sys
def interp(points):
"""(offset, elevation) 목록의 선형보간 함수와 정의역 양 끝을 준다."""
pts = sorted(points, key=lambda t: t[0])
def at(x):
if x <= pts[0][0]:
return pts[0][1]
if x >= pts[-1][0]:
return pts[-1][1]
lo, hi = 0, len(pts) - 1
while hi - lo > 1:
mid = (lo + hi) // 2
if pts[mid][0] <= x:
lo = mid
else:
hi = mid
(x0, z0), (x1, z1) = pts[lo], pts[hi]
return z0 if x1 == x0 else z0 + (z1 - z0) * (x - x0) / (x1 - x0)
return at, pts[0][0], pts[-1][0]
def slope_starts(design):
"""좌·우 사면 시작 오프셋 — 노견 끝(그 측에 측구가 있으면 측구 바깥)."""
edges = design["road_edges"]
left = max(edges["left"]["offset_m"], edges["right"]["offset_m"])
right = min(edges["left"]["offset_m"], edges["right"]["offset_m"])
spec = design.get("ditch") or {"type": "none"}
width = 0.0
if design.get("ditch_enabled") is not False and spec.get("type") != "none":
width = spec["top_width_m"] if spec["type"] == "standard" else spec["width_m"]
return {
"left": left + (width if design.get("ditch_side") == "left" else 0.0),
"right": right - (width if design.get("ditch_side") == "right" else 0.0),
}
def fill_slope_lengths(section, step=0.005):
"""성토측 사면 경사길이 — {side: (길이 m, 미교차 여부)}."""
design = section["design"]
ground_at, _, _ = interp(
[
(p["offset_m"], p["elevation_m"])
for p in section["samples"]
if p.get("valid") is not False and p.get("elevation_m") is not None
]
)
design_at, line_min, line_max = interp(
[(p["offset_m"], p["elevation_m"]) for p in design["design_line"]]
)
starts = slope_starts(design)
slant = math.hypot(1, 1 / design["fill_slope_ratio"])
result = {}
for side in ("left", "right"):
if design["section_mode"] in ("both_cut", f"{side}_cut"):
continue
outward = 1 if side == "left" else -1
start = starts[side]
limit = line_max if side == "left" else line_min
previous = design_at(start) - ground_at(start)
offset, meet = start, None
while (limit - offset) * outward > 0:
offset = start + outward * min(abs(offset - start) + step, abs(limit - start))
diff = design_at(offset) - ground_at(offset)
if previous != 0 and (diff > 0) != (previous > 0):
# 부호가 뒤집힌 두 걸음 사이를 선형보간한 자리가 사면 끝이다.
meet = offset - outward * step + outward * step * (previous / (previous - diff))
break
previous = diff
end = limit if meet is None else meet
result[side] = (abs(end - start) * slant, meet is None)
return result
def main(argv):
detail = json.loads(pathlib.Path(argv[1]).read_text(encoding="utf-8"))
want = {round(float(value), 1) for value in argv[2:]}
for section in detail["cross_sections"]:
if not section.get("design"):
continue
chainage = round(section["chainage_m"], 1)
if want and chainage not in want:
continue
lengths = fill_slope_lengths(section)
if not lengths:
continue
text = " ".join(
f"{side}={'' if open_ else ''}{length:.2f}m" for side, (length, open_) in lengths.items()
)
print(f"ch={chainage:8.1f} {section['design']['section_mode']:10s} {text}")
if __name__ == "__main__":
main(sys.argv)
+52
View File
@@ -0,0 +1,52 @@
"""소단 + 다중 무릎에서 **제자리 무릎이 되풀이되어 안 끝나는지** 재현한다.
의심 — 암 경계선의 기울기가 **암 경사와 토사 경사 사이**에 있으면, 경계에 정확히 닿은 자리에서
① 토사로 바꾸면 다음 걸음이 경계 아래로 내려가 「다시 암」 ② 암으로 바꾸면 다음 걸음이 경계
위로 올라가 「다시 토사」 가 되어 **같은 자리에서 앞뒤로 뒤집힌다**. `share`(보간 비율)가 0 이라
거리가 한 걸음도 안 나가므로 `while dist < limit` 이 끝나지 않는다.
"""
import signal
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from common_util.common_util_cross_berm import BermSpec, cut_profile_points # noqa: E402
# 경계선 기울기를 **두 설계 경사 사이**에 둔다 — 암 1:0.4 는 수평 1m 에 2.5m 오르고,
# 토사 1:1.0 은 1.0m 오른다. 그 사이(1.5m/1m)면 「토사로 바꾸면 경계 아래, 암으로 바꾸면
# 경계 위」가 되어 같은 자리에서 뒤집힌다.
BOUNDARY_RISE = 1.5
def boundary(dist: float) -> float:
"""시작점에서 정확히 만나고, 그 뒤로 두 경사 사이 기울기로 오르는 경계선."""
return 100.0 + dist * BOUNDARY_RISE
def run(multi_knee: bool, seconds: int = 8):
def on_alarm(signum, frame): # noqa: ARG001
raise TimeoutError("끝나지 않음")
try:
points = cut_profile_points(
0.0, 100.0, 0.4, 1.0, boundary, BermSpec(0.5, 3.0, 0.0), 50.0, multi_knee
)
return f"끝남 · 꼭짓점 {len(points)}"
except RecursionError as exc:
return f"RecursionError {exc}"
if __name__ == "__main__":
import threading
for multi in (False, True):
result = {"v": None}
thread = threading.Thread(target=lambda: result.__setitem__("v", run(multi)), daemon=True)
thread.start()
thread.join(8)
state = result["v"] if not thread.is_alive() else "⚠ 8초 안에 안 끝남 (무한 반복)"
print(f"multi_knee={multi}: {state}")
@@ -0,0 +1,62 @@
"""저장된 프로젝트마다 관 지점 읽기가 **어느 가지**로 가는지 잰다 (계획서 0-7).
투영 이월 가지를 지워도 되는지 판단하는 근거다 — 실제 자료에서 그 가지가 안 돌면
「지문 일치」·「허용오차 안」 둘 중 하나로 끝난다는 뜻이다. 값은 안 고친다(읽기만).
"""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from common_util.common_util_drainage_pipes import ( # noqa: E402
ROUTE_MATCH_TOLERANCE_M,
max_projection_shift,
parse_pipe_points,
)
from common_util.common_util_route_geometry import RouteVertex # noqa: E402
from config.config_system import STORAGE_BASE_DIR # noqa: E402
ROOT = Path(STORAGE_BASE_DIR)
def route_vertices(project_dir: Path):
"""B05 가 읽는 노선 정점 — `route_main.geojson`."""
for name in ("route_main.geojson", "planned_route.geojson"):
for path in project_dir.rglob(name):
data = json.loads(path.read_text(encoding="utf-8"))
# 파일 꼴이 둘이다 — Feature 하나짜리와 FeatureCollection.
geoms = [data.get("geometry") or {}]
geoms += [(f.get("geometry") or {}) for f in (data.get("features") or [])]
for geom in geoms:
if geom.get("type") == "LineString" and geom.get("coordinates"):
# 투영은 x·y 만 본다 — z·누가거리는 0 으로 채운다(읽기 전용 계산).
return (
[
RouteVertex(x=float(pair[0]), y=float(pair[1]), z=0.0, chainage_m=0.0)
for pair in geom["coordinates"]
],
path.name,
)
return None, None
for pipe_file in sorted(ROOT.rglob("pipe_points.json")):
if "initial_snapshot" in str(pipe_file):
continue
project_dir = pipe_file.parents[3]
document = json.loads(pipe_file.read_text(encoding="utf-8"))
points = parse_pipe_points(document.get("points"))
stored_sig = str(document.get("route_signature") or "")
vertices, src = route_vertices(project_dir)
shift = max_projection_shift(points, vertices) if vertices else None
if shift is None:
verdict = "노선 파일 없음 — 판정 불가"
elif shift <= ROUTE_MATCH_TOLERANCE_M:
verdict = f"허용오차 안 (같은 노선) — {shift:.4f}m ≤ {ROUTE_MATCH_TOLERANCE_M}m"
else:
verdict = f"⚠ 투영 이월 가지 — {shift:.4f}m > {ROUTE_MATCH_TOLERANCE_M}m"
print(f"{project_dir.name[:8]}{len(points):3d}건 · 지문 {stored_sig[:18]:18s} · {src} · {verdict}")
+71
View File
@@ -0,0 +1,71 @@
"""사면이 안 닫히는 측점 — **얼마나 더 넓히면 닫히나**를 잰다(읽기 전용).
왜 — 계산 반폭(±20m) 안에서 사면이 원지반을 못 만나면 절·성토 면적이 그 자리에서 잘린다.
「경고로 대체」가 2026-09-03 사용자 확정이지만, 데스크탑 창이 **수량이 조용히 적게 나온다**는
점을 짚었다. 반폭을 넓힐지 정하려면 **얼마나 넓혀야 닫히는지**를 알아야 한다.
재는 법 — 저장된 지반 샘플의 **바깥 5m 평균 기울기**로 지형이 계속 이어진다고 보고,
설계 사면선과 만나는 거리를 푼다. 지형이 그대로 이어진다는 가정이라 **하한 추정**이다.
"""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from config.config_system import STORAGE_BASE_DIR # noqa: E402
ROOT = Path(STORAGE_BASE_DIR)
def outer_trend(samples, side):
"""바깥 5m 구간의 지반 기울기(수직/수평) — 양수면 바깥으로 갈수록 높아진다."""
pts = sorted(
((float(s["offset_m"]), float(s["elevation_m"])) for s in samples if s.get("valid") is not False),
key=lambda p: p[0],
)
if side == "left":
pts = [p for p in pts if p[0] <= pts[0][0] + 5.0]
pts = pts[::-1]
else:
pts = [p for p in pts if p[0] >= pts[-1][0] - 5.0]
if len(pts) < 2:
return None, None
run = abs(pts[-1][0] - pts[0][0])
if run <= 0:
return None, None
return (pts[-1][1] - pts[0][1]) / run, pts[-1]
def main(project: str, route_hint: str = "") -> None:
base = ROOT / "1" / "3" / project / "B06_Section" / "cross_sections"
files = sorted(base.glob("cross_*.json"))
print(f"측점 파일 {len(files)}")
rows = []
for path in files:
doc = json.loads(path.read_text(encoding="utf-8"))
samples = doc.get("samples") or []
if len(samples) < 4:
continue
for side in ("left", "right"):
slope, edge = outer_trend(samples, side)
if slope is None or edge is None:
continue
# 절토 사면은 1:0.4(암)~1:1.0(토사) — 바깥으로 갈수록 오르는 기울기 1/n.
# 지형이 사면보다 가파르면 영원히 안 만난다.
for ratio, label in ((0.4, "암 1:0.4"), (1.0, "토사 1:1.0")):
design_rise = 1.0 / ratio
if slope >= design_rise:
rows.append((path.stem, side, label, None))
break
never = [r for r in rows if r[3] is None]
print(f"지형이 설계 사면보다 가팔라 **영원히 못 만나는** 측점·측 조합: {len(never)}")
for row in never[:12]:
print(" ", row[0], row[1], row[2])
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "5601e828-feea-487a-9b25-415e5199f2f5")
+99
View File
@@ -0,0 +1,99 @@
"""미교차 측점이 **얼마나 더 넓히면 닫히는지** — 저장분만으로 잰다(읽기 전용).
재는 법 — 저장된 `design_line` 의 바깥 끝(사면이 잘린 자리)에서 시작해, 지반이 **바깥 5m 평균
기울기**로 이어진다고 보고 설계 사면선과 만나는 거리를 푼다. 지형이 그대로 이어진다는 가정이라
**하한 추정**이며, 지형이 설계 사면보다 가파르면 「아무리 넓혀도 안 닫힘」으로 잡힌다.
"""
import asyncio
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import config.config_db as db # noqa: E402
from config.config_system import STORAGE_BASE_DIR # noqa: E402
ROUTE_ID = 169
PROJECT = "5601e828-feea-487a-9b25-415e5199f2f5"
SAMPLES = Path(STORAGE_BASE_DIR) / "1" / "3" / PROJECT / "B06_Section" / "cross_sections"
def ground_of(chainage: float):
path = SAMPLES / f"cross_{int(round(chainage)):05d}m.json"
if not path.exists():
return None
doc = json.loads(path.read_text(encoding="utf-8"))
pts = [
(float(s["offset_m"]), float(s["elevation_m"]))
for s in doc.get("samples") or []
if s.get("valid") is not False and s.get("elevation_m") is not None
]
return sorted(pts) or None
def need_width(design_line, ground, ratio_fill, ratio_cut):
"""양 끝에서 필요한 추가 폭(m). 못 닫히면 None."""
out = {}
for side, pick in (("left", min), ("right", max)):
edge = pick(design_line, key=lambda p: p["offset_m"])
tip_x, tip_z = float(edge["offset_m"]), float(edge["elevation_m"])
near = [p for p in ground if abs(p[0] - tip_x) <= 5.0]
if len(near) < 2:
continue
run = near[-1][0] - near[0][0]
if abs(run) < 1e-6:
continue
terrain = (near[-1][1] - near[0][1]) / run # 바깥으로 갈수록 (+)면 오름
gap = tip_z - (near[-1][1] if side == "right" else near[0][1])
# 절토(사면이 올라감)면 1/cut, 성토(내려감)면 -1/fill. 바깥 방향 부호를 맞춘다.
rising = gap < 0 # 설계선이 지반보다 낮다 = 절토측
design = (1.0 / ratio_cut) if rising else (-1.0 / ratio_fill)
if side == "left":
design, terrain = -design, -terrain
closing = design - terrain
if abs(closing) < 1e-9 or (gap < 0) != (closing > 0):
out[side] = None # 벌어지기만 함 — 아무리 넓혀도 안 닫힘
continue
out[side] = abs(gap / closing)
return out
async def main() -> None:
await db.init_db_pool()
pool = db.get_db_pool()
rows = []
async with pool.acquire() as conn, conn.cursor() as cur:
await cur.execute("SELECT chainage_m, data FROM cross_sections WHERE route_id=%s", (ROUTE_ID,))
for chainage, data in await cur.fetchall():
doc = (json.loads(data) if isinstance(data, str) else data) or {}
design = doc.get("design") or {}
if not design.get("slope_unclosed"):
continue
ground = ground_of(float(chainage))
line = design.get("design_line") or []
if not ground or len(line) < 2:
continue
need = need_width(
line, ground, float(design.get("fill_slope_ratio") or 1.2),
float(design.get("cut_slope_ratio") or 1.0),
)
rows.append((float(chainage), need))
await db.close_db_pool()
never = [(c, s) for c, n in rows for s, v in n.items() if v is None]
finite = [(c, s, v) for c, n in rows for s, v in n.items() if v is not None]
print(f"미교차 측점 {len(rows)}")
print(f"· 아무리 넓혀도 안 닫히는 측·조합: {len(never)}")
if finite:
finite.sort(key=lambda r: -r[2])
print(f"· 넓히면 닫히는 조합: {len(finite)}건 — 필요한 추가 폭 최대 {finite[0][2]:.1f}m, "
f"중앙값 {sorted(v for _, _, v in finite)[len(finite)//2]:.1f}m")
for row in finite[:6]:
print(f" {row[0]:8.1f}m {row[1]:5s} +{row[2]:.1f}m")
asyncio.run(main())
+27
View File
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""코리도 사전 생성 실경로 점검 — 실제 DB·실제 프로젝트로 한 번 돌려 본다(수동 실행)."""
import asyncio
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from config.config_db import close_db_pool, init_db_pool # noqa: E402
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor # noqa: E402
PROJECT_ID = "5cff3920-a181-4a3d-bec0-e0ac4082b75d"
ROUTE_ID = 139
PROJECT_ROOT = ROOT / "storage" / "1" / "3" / PROJECT_ID
async def main() -> None:
await init_db_pool()
try:
ok = await prebuild_corridor(PROJECT_ID, ROUTE_ID, PROJECT_ROOT)
print("사전 생성:", ok)
finally:
await close_db_pool()
asyncio.run(main())
+44
View File
@@ -0,0 +1,44 @@
"""이관한 표가 예전 선·문자 그림과 같은 자리를 그리는지 확인 (수동 실행)."""
import sys
sys.path.insert(0, ".")
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
_cross_table_entities,
extract_quantity_table,
QUANTITY_VALUE_KEYS,
cross_table_height,
cross_table_width,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _info_box_entities
qt = {key: float(index + 1) for index, key in enumerate(QUANTITY_VALUE_KEYS)}
entities = _cross_table_entities("draw1", qt, 0.0, "No.10", center_x=0.0)
assert len(entities) == 1 and entities[0]["type"] == "Table", entities
shape = entities[0]["shapeData"]
widths = shape["columnWidths"]
heights = shape["rowHeights"]
print("cross columns:", len(widths), "rows:", len(heights))
print("width:", round(sum(widths), 6), "expected:", cross_table_width())
print("height:", round(sum(heights), 6), "expected:", cross_table_height())
print("origin:", shape["origin"])
titles = [c for row in shape["cells"] for c in row if c and c.get("colSpan", 1) == len(widths)]
print("title cell:", titles[0]["text"], "align", titles[0]["align"])
keyed = {c["key"]: c["text"] for row in shape["cells"] for c in row if c and c.get("key")}
print("keyed cells:", len(keyed), "of", len(QUANTITY_VALUE_KEYS))
missing = [k for k in QUANTITY_VALUE_KEYS if k not in keyed and k not in ("cut", "fill")]
print("missing keys:", missing)
back = extract_quantity_table("draw1", {"entities": entities})
same = all(
back.get(k) == qt[k] for k in QUANTITY_VALUE_KEYS if k not in ("cut", "fill")
)
print("round-trip values equal:", same)
box = _info_box_entities("draw1", 1, {"chainage_m": 100.0, "area_m2": 12345.0,
"relief_m": 12.0, "flow_length_m": 34.0}, "#fff",
(0.0, 0.0), 20.0)
assert len(box) == 1 and box[0]["type"] == "Table"
bs = box[0]["shapeData"]
print("basin columns:", bs["columnWidths"], "rows:", len(bs["rowHeights"]))
print("basin cells:", [[c["text"] if c else None for c in row] for row in bs["cells"]])
+28
View File
@@ -0,0 +1,28 @@
"""서버가 뜨는지 — `main` 을 들여와 라우터 모듈이 전부 import 되는지 본다.
왜 (2026-09-06) — 재수출이 사라진 이름을 다른 라우터가 옛 자리에서 들여오고 있어
**서버가 아예 안 뜨는** 상태로 커밋됐다(`B06_Section_Router_Confirm` →
`B06_Section_Router._compute_default_designs`). 시험 390 건이 다 통과했는데도 못 잡았다 —
그 경로를 아무도 import 하지 않았기 때문이다. 여기서 한 번에 잡는다.
`main` 을 들여오는 것만으로 27개 라우터가 전부 import 되므로, 이름이 어긋나면 즉시 터진다.
"""
import importlib
def test_main_을_들여올_수_있다():
"""import 만 한다 — 서버를 띄우지도, DB 에 붙지도 않는다."""
module = importlib.import_module("main")
assert getattr(module, "app", None) is not None
def test_라우터가_전부_붙어_있다():
module = importlib.import_module("main")
paths = {route.path for route in module.app.routes}
# 각 화면에서 하나씩 — 라우터 한 벌이 통째로 빠지면 걸린다.
for path in (
"/api/projects/{project_id}/routes/{route_id}/corridor",
"/api/projects/{project_id}/sections/{route_id}/detail",
):
assert path in paths, f"라우터 경로가 없습니다: {path}"
@@ -0,0 +1,120 @@
"""직행 업로드(`POST /api/projects/{id}/files`)가 청크 경로와 같은 보호막을 갖는지.
왜 있나(2026-09-08) — 창 넷이 DB·저장소를 함께 쓰는데 이 갈래만
① 분석 중 차단(`is_analysis_running`) ② 같은 이름 옛 행 내리기(`supersede_previous_input_files`)
가 빠져 있었다. 빠지면 분석 2개가 같은 산출물 경로에서 부딪히고, 같은 파일이 두 줄로
활성으로 남아 어느 것으로 도는지 순서에 달린다.
파일 내용(소스 문자열)에 기대지 않는다 — **함수 객체를 실제로 호출**해 확인한다.
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from typing import Any
import pytest
import B03_FileInput.B03_FileInput_Router as router_module
class _Cursor:
async def __aenter__(self) -> "_Cursor":
return self
async def __aexit__(self, *_: Any) -> None:
return None
async def execute(self, *_: Any, **__: Any) -> None:
return None
async def fetchone(self) -> None:
return None
class _Connection:
def cursor(self, *_: Any, **__: Any) -> _Cursor:
return _Cursor()
class _Acquire:
async def __aenter__(self) -> _Connection:
return _Connection()
async def __aexit__(self, *_: Any) -> None:
return None
class _Pool:
def acquire(self) -> _Acquire:
return _Acquire()
class _Upload(SimpleNamespace):
"""UploadFile 흉내 — 라우터가 마지막에 `close()` 를 부른다."""
async def close(self) -> None:
return None
def _upload(filename: str) -> _Upload:
return _Upload(filename=filename, size=10)
_FULL_SET = ["a.las", "b.tif", "b.tfw", "b.prj", "route.csv"]
def test_분석_중이면_직행_업로드를_409로_막는다(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(router_module, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(
router_module, "get_project_storage_relative_path", _async_return("1/3/proj")
)
monkeypatch.setattr(router_module, "resolve_stored_project_path", lambda _p: ".")
monkeypatch.setattr(router_module, "is_analysis_running", _async_return(True))
response = asyncio.run(
router_module.upload_project_files(
project_id=_PROJECT_ID,
files=[_upload(name) for name in _FULL_SET],
las_free=False,
session={"role": "USER"},
)
)
assert response.status_code == 409
def test_직행_업로드가_같은_이름_옛_행을_내린다() -> None:
"""`supersede_previous_input_files` 가 이 모듈에 실제로 이어져 있는지."""
from B03_FileInput.B03_FileInput_Repository import (
supersede_previous_input_files as repository_function,
)
assert router_module.supersede_previous_input_files is repository_function
def test_세_갈래가_같은_보호막을_쓴다() -> None:
"""청크·임시배치·직행이 같은 두 함수를 쓰는지 — 한 갈래만 빠지는 사고를 막는다."""
import B03_FileInput.B03_FileInput_Router_Chunks as chunks
import B03_FileInput.B03_FileInput_Router_Temp as temp
for module in (router_module, chunks, temp):
assert hasattr(module, "is_analysis_running"), module.__name__
assert hasattr(module, "supersede_previous_input_files"), module.__name__
# 확인 단계도 세 갈래가 다 들고 있어야 한다 — 하나만 빠지면 그 갈래가 500 으로 죽는다
# (2026-09-08 실제로 청크 갈래에서 `NameError` 로 겪었다).
for module in (router_module, chunks, temp):
assert hasattr(module, "OutputsWouldBeDiscarded"), module.__name__
assert hasattr(module, "_confirm_replace_response"), module.__name__
from uuid import UUID # noqa: E402 — 아래 상수에서만 쓴다
_PROJECT_ID = UUID("fa76c162-71c7-46e5-a95d-fb3930665a45")
def _async_return(value: Any):
async def _inner(*_: Any, **__: Any) -> Any:
return value
return _inner
+21
View File
@@ -0,0 +1,21 @@
"""B03 카드 미리보기 — 노선 좌표열 솎기(2026-09-04)."""
from B03_FileInput.B03_FileInput_Engine_Shapefile import _thin_preview_path
def test_thin_keeps_endpoints_and_cap() -> None:
part = [(float(i), float(i * 2)) for i in range(5000)]
preview = _thin_preview_path([part], limit=200)
assert len(preview) == 1
points = preview[0]
# 상한(+ 끝점 보정 1)을 넘지 않는다.
assert len(points) <= 201
assert points[0] == [0.0, 0.0]
assert points[-1] == [4999.0, 9998.0]
def test_thin_handles_multipart_and_empty() -> None:
assert _thin_preview_path([]) == []
assert _thin_preview_path([[]]) == []
preview = _thin_preview_path([[(0.0, 0.0), (1.0, 1.0)], [(5.0, 5.0), (6.0, 6.0)]], limit=200)
assert preview == [[[0.0, 0.0], [1.0, 1.0]], [[5.0, 5.0], [6.0, 6.0]]]
@@ -0,0 +1,73 @@
"""새 자료로 갈아 끼울 때 「지우고 진행할까?」를 먼저 묻는지.
왜 있나(2026-09-08) — 업로드 하나가 그 프로젝트의 설계 산출물·초기값 스냅숏을
**되돌릴 수 없게** 지운다. 창 넷이 한 프로젝트를 볼 수 있어 말없이 지우면 남의 작업이
사라진다. 사람이 올리는 갈래만 묻고, 자동 절차는 안 막는다.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from B03_FileInput.B03_FileInput_Router_Helpers import (
OutputsWouldBeDiscarded,
_confirm_replace_response,
describe_existing_outputs,
)
def test_산출물이_없으면_묻지_않는다(tmp_path: Path) -> None:
(tmp_path / "B03_FileInput" / "input").mkdir(parents=True)
(tmp_path / "B03_FileInput" / "input" / "a.las").write_text("x", encoding="utf-8")
assert describe_existing_outputs(tmp_path) == []
def test_설계_산출물과_초기값을_사람_말로_알린다(tmp_path: Path) -> None:
(tmp_path / "B05_Profile" / "route").mkdir(parents=True)
(tmp_path / "B05_Profile" / "route" / "route.json").write_text("{}", encoding="utf-8")
(tmp_path / "B06_Section").mkdir()
(tmp_path / "B06_Section" / "cross.json").write_text("{}", encoding="utf-8")
(tmp_path / "initial_snapshot").mkdir()
targets = describe_existing_outputs(tmp_path)
assert "노선·종단 설계" in targets
assert "횡단 설계" in targets
assert any("초기값" in item for item in targets)
# B03 입력은 「지워지는 것」이 아니다 — 새 자료가 얹히는 자리다.
assert all("입력" not in item for item in targets)
def test_빈_폴더만_있으면_지울_것이_없다(tmp_path: Path) -> None:
(tmp_path / "B05_Profile" / "route").mkdir(parents=True)
(tmp_path / "B08_Quantity").mkdir()
assert describe_existing_outputs(tmp_path) == []
def test_확인_응답은_409_이고_무엇이_지워지는지_담는다() -> None:
response = _confirm_replace_response(OutputsWouldBeDiscarded(["횡단 설계", "수량 산출"]))
body = response.body.decode("utf-8")
assert response.status_code == 409
assert "replace_outputs" in body
assert "\\ub418\\ub3cc\\ub9b4" in body or "되돌릴" in body # 문구에 「되돌릴 수 없」
assert "\\ud6a1\\ub2e8" in body or "횡단" in body
def test_자동_절차는_기본으로_안_묻는다() -> None:
"""`confirm_replace` 기본이 True 여야 체인·스크립트가 물음에 안 걸린다."""
import inspect
from B03_FileInput.B03_FileInput_Router_Helpers import _complete_file_input_if_ready
default = inspect.signature(_complete_file_input_if_ready).parameters["confirm_replace"].default
assert default is True
@pytest.mark.parametrize("stage", ["B04_PreProcess", "B07_DesignDetail", "B09_Estimation"])
def test_모든_산출물_단계를_본다(tmp_path: Path, stage: str) -> None:
(tmp_path / stage).mkdir(parents=True)
(tmp_path / stage / "out.json").write_text("{}", encoding="utf-8")
assert describe_existing_outputs(tmp_path), stage
@@ -0,0 +1,180 @@
"""실데이터 등가성 — 래스터화가 기존 griddata 경로와 같은 표고를 내는지.
PLAN 2026-08-17 완료 조건 검증.
- `meshfree`: Delaunay를 그대로 쓰므로 표고 완전 일치
- `tin`: 저장 faces와 재Delaunay는 다른 삼각망이라 차이가 남는다. 그 차이가
**보간 버그가 아니라 삼각망 선택 차이**임을 대조로 분리한다.
**주의: griddata가 호출당 3분 걸려 이 파일 전체 실행에 약 8분이 든다.**
실제 프로젝트 산출물이 없으면 skip.
"""
import time
from pathlib import Path
import numpy as np
import pytest
from scipy.interpolate import griddata
from scipy.spatial import Delaunay
from B04_PreProcess.B04_PreProcess_Engine_Contour import (
_grid_axes,
extract_contours,
rasterize_triangle_mesh,
)
MODELS = Path(
"C:/Program_coding/임도설계 및 견적자동화 프로그램 개발/storage/1/3/"
"f9543035-4a91-4df0-b467-cdc515723507/B04_PreProcess/models"
)
def _load(name):
path = MODELS / name
if not path.exists():
pytest.skip(f"실데이터 없음: {path}")
return np.load(path)
def _axes_for(xy):
return _grid_axes(
float(np.min(xy[:, 0])),
float(np.max(xy[:, 0])),
float(np.min(xy[:, 1])),
float(np.max(xy[:, 1])),
1.0,
)
def _compare(old, new):
return {
"only_old": int((np.isfinite(old) & ~np.isfinite(new)).sum()),
"only_new": int((np.isfinite(new) & ~np.isfinite(old)).sum()),
"both": np.isfinite(old) & np.isfinite(new),
}
def test_meshfree_matches_griddata_exactly():
"""meshfree — 같은 Delaunay를 쓰므로 표고가 완전히 일치해야 한다."""
points = _load("meshfree_csf.npz")["points"]
x_coords, y_coords = _axes_for(points)
xx, yy = np.meshgrid(x_coords, y_coords)
triangulation = Delaunay(np.asarray(points[:, :2], dtype=np.float64))
new = rasterize_triangle_mesh(points, triangulation.simplices, x_coords, y_coords)
old = griddata(points[:, :2], points[:, 2], (xx, yy), method="linear")
result = _compare(old, new)
assert result["both"].sum() > 10_000, "비교 대상 셀이 너무 적다"
assert result["only_old"] == 0, "기존이 채우던 셀이 비었다"
assert result["only_new"] == 0, "없던 셀이 생겼다"
assert np.abs(old[result["both"]] - new[result["both"]]).max() < 1e-9
def test_rasterize_matches_griddata_on_same_triangulation():
"""래스터화 정확성 — 같은 재Delaunay를 넣으면 griddata와 완전 일치.
이 대조가 통과하면 tin에서 남는 차이는 전부 삼각망 선택 차이다.
"""
vertices = _load("tin_csf.npz")["vertices"]
x_coords, y_coords = _axes_for(vertices)
xx, yy = np.meshgrid(x_coords, y_coords)
triangulation = Delaunay(np.asarray(vertices[:, :2], dtype=np.float64))
control = rasterize_triangle_mesh(vertices, triangulation.simplices, x_coords, y_coords)
old = griddata(vertices[:, :2], vertices[:, 2], (xx, yy), method="linear")
result = _compare(old, control)
assert result["only_old"] == 0 and result["only_new"] == 0
assert np.abs(old[result["both"]] - control[result["both"]]).max() < 1e-9
def test_tin_difference_comes_only_from_triangulation():
"""tin 저장 faces — griddata 대비 차이가 재Delaunay 대비 차이와 같아야 한다.
두 대조가 일치하면 차이의 원인은 삼각망 하나뿐이다. 저장 faces는 긴 변 제거·
외곽 클리핑이 반영된 실제 TIN이므로(`ModelBuild.py:38-51`) 재Delaunay보다 덮는
면적이 작고, 그 몫이 `only_old`로 나온다.
"""
data = _load("tin_csf.npz")
vertices, faces = data["vertices"], data["faces"]
x_coords, y_coords = _axes_for(vertices)
xx, yy = np.meshgrid(x_coords, y_coords)
stored = rasterize_triangle_mesh(vertices, faces, x_coords, y_coords)
triangulation = Delaunay(np.asarray(vertices[:, :2], dtype=np.float64))
control = rasterize_triangle_mesh(vertices, triangulation.simplices, x_coords, y_coords)
old = griddata(vertices[:, :2], vertices[:, 2], (xx, yy), method="linear")
vs_griddata = _compare(old, stored)
vs_control = _compare(control, stored)
assert vs_griddata["only_old"] == vs_control["only_old"]
assert vs_griddata["only_new"] == vs_control["only_new"] == 0
diff = np.abs(old[vs_griddata["both"]] - stored[vs_griddata["both"]])
changed = diff > 1e-6
print(
f"\n[tin] 공통셀={int(vs_griddata['both'].sum()):,} "
f"저장 faces가 안 덮는 셀={vs_griddata['only_old']:,}\n"
f" 값 다른 셀={int(changed.sum()):,} ({changed.mean():.2%}) "
f"최대={diff.max():.4f}m 차이셀 평균={diff[changed].mean():.4f}m"
)
# 대각선 선택이 다른 사각형에서만 어긋나므로 국소 지형 기복을 넘지 않아야 한다.
assert diff.max() < 1.0, "표고차 1m 초과 — 삼각망 차이로 설명 불가"
@pytest.mark.parametrize(
"npz_name,representation,cached_name,exact",
[
("tin_csf.npz", "triangular_mesh", "contour_csf_tin_1.0m.json", False),
("meshfree_csf.npz", "meshfree_surfels", "contour_csf_meshfree_1.0m.json", True),
("dtm_csf.npz", "regular_grid", "contour_csf_dtm_1.0m.json", True),
],
)
def test_contour_output_matches_cached(npz_name, representation, cached_name, exact):
"""최종 산출물 대조 — 캐시된 등고선 JSON과 레벨·정점 수를 맞춰 본다."""
import json
npz_path = MODELS / npz_name
cached_path = MODELS / cached_name
if not npz_path.exists() or not cached_path.exists():
pytest.skip("실데이터 없음")
started = time.perf_counter()
lines = extract_contours(npz_path, representation, 1.0)
elapsed = time.perf_counter() - started
with open(cached_path, encoding="utf-8") as handle:
cached = json.load(handle)["contours"]
new_levels = {round(float(item["level"]), 3) for item in lines}
old_levels = {round(float(item["level"]), 3) for item in cached}
new_points = sum(len(item["coordinates"]) for item in lines)
old_points = sum(len(item["coordinates"]) for item in cached)
print(
f"\n[{npz_name}] {elapsed:.2f}초 세그먼트 {len(lines):,}/{len(cached):,} "
f"정점 {new_points:,}/{old_points:,}"
)
assert new_levels == old_levels, "등고선 레벨 집합이 바뀌었다"
if exact:
assert len(lines) == len(cached)
assert new_points == old_points
else:
# tin은 삼각망 차이만큼 경계 세그먼트가 미세하게 달라진다.
assert abs(new_points - old_points) / old_points < 0.01
def test_contour_extraction_is_fast():
"""가속이 목적이므로 속도를 회귀로 못박는다 (변경 전 tin 119초·meshfree 302초)."""
for npz_name, representation in (
("tin_csf.npz", "triangular_mesh"),
("meshfree_csf.npz", "meshfree_surfels"),
):
path = MODELS / npz_name
if not path.exists():
pytest.skip("실데이터 없음")
started = time.perf_counter()
extract_contours(path, representation, 1.0)
elapsed = time.perf_counter() - started
print(f"\n[속도] {npz_name} {elapsed:.2f}")
assert elapsed < 30.0, f"{npz_name} {elapsed:.1f}초 — 가속 실패"
+195
View File
@@ -0,0 +1,195 @@
"""B04 삼각망 래스터화 — 위치탐색 없이 격자 표고를 만드는 함수의 정확도 검증.
기존 경로는 scipy `griddata`가 질의점마다 삼각형 98만개 망을 탐색해 등고선 1회에
2~5분이 걸렸다(PLAN 2026-08-17). 삼각형을 격자에 직접 래스터화하면 탐색이 사라진다.
정밀도 유지가 조건이므로 무게중심 선형보간의 해석해 일치를 먼저 못박는다.
"""
import numpy as np
import pytest
from B04_PreProcess.B04_PreProcess_Engine_Contour import rasterize_triangle_mesh
def _axes(x_min, x_max, y_min, y_max, step=1.0):
cols = int(round((x_max - x_min) / step)) + 1
rows = int(round((y_max - y_min) / step)) + 1
return (
np.linspace(x_min, x_max, cols, dtype=np.float64),
np.linspace(y_min, y_max, rows, dtype=np.float64),
)
def _plane(x, y):
"""검증용 기준 평면 — 선형보간이 정확하면 오차 0이어야 한다."""
return 2.0 * x + 3.0 * y + 5.0
def test_single_triangle_matches_plane_solution():
"""평면 위 삼각형 하나 — 내부 격자점이 해석해와 일치."""
verts = np.array(
[
[0.0, 0.0, _plane(0.0, 0.0)],
[10.0, 0.0, _plane(10.0, 0.0)],
[0.0, 10.0, _plane(0.0, 10.0)],
]
)
tris = np.array([[0, 1, 2]])
x_coords, y_coords = _axes(0.0, 10.0, 0.0, 10.0)
z_grid = rasterize_triangle_mesh(verts, tris, x_coords, y_coords)
xx, yy = np.meshgrid(x_coords, y_coords)
filled = np.isfinite(z_grid)
assert filled.any()
assert np.allclose(z_grid[filled], _plane(xx[filled], yy[filled]), atol=1e-9)
def test_cells_outside_mesh_stay_nan():
"""삼각망 밖은 NaN — footprint 마스크가 기존과 같이 동작해야 한다."""
verts = np.array([[0.0, 0.0, 1.0], [2.0, 0.0, 1.0], [0.0, 2.0, 1.0]])
tris = np.array([[0, 1, 2]])
x_coords, y_coords = _axes(0.0, 10.0, 0.0, 10.0)
z_grid = rasterize_triangle_mesh(verts, tris, x_coords, y_coords)
# 빗변 바깥쪽 (9, 9)는 삼각형 밖
assert np.isnan(z_grid[9, 9])
# 꼭짓점 (0, 0)은 삼각형 안
assert z_grid[0, 0] == pytest.approx(1.0)
def test_two_triangles_fill_full_square():
"""사각형을 이루는 두 삼각형 — 격자 전체가 빈칸 없이 채워진다."""
corners = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)]
verts = np.array([[x, y, _plane(x, y)] for x, y in corners])
tris = np.array([[0, 1, 2], [0, 2, 3]])
x_coords, y_coords = _axes(0.0, 4.0, 0.0, 4.0)
z_grid = rasterize_triangle_mesh(verts, tris, x_coords, y_coords)
assert np.isfinite(z_grid).all()
xx, yy = np.meshgrid(x_coords, y_coords)
assert np.allclose(z_grid, _plane(xx, yy), atol=1e-9)
def test_shared_edge_is_consistent():
"""두 삼각형이 공유하는 변 위의 격자점은 어느 쪽으로 계산해도 같은 값."""
verts = np.array(
[[0.0, 0.0, 0.0], [4.0, 0.0, 8.0], [4.0, 4.0, 20.0], [0.0, 4.0, 12.0]]
) # z = 2x + 3y
tris = np.array([[0, 1, 2], [0, 2, 3]])
x_coords, y_coords = _axes(0.0, 4.0, 0.0, 4.0)
z_grid = rasterize_triangle_mesh(verts, tris, x_coords, y_coords)
xx, yy = np.meshgrid(x_coords, y_coords)
assert np.allclose(z_grid, 2.0 * xx + 3.0 * yy, atol=1e-9)
def test_degenerate_triangle_is_skipped():
"""면적 0 삼각형이 섞여 있어도 죽지 않고 나머지를 채운다."""
verts = np.array(
[
[0.0, 0.0, 1.0],
[4.0, 0.0, 1.0],
[0.0, 4.0, 1.0],
[1.0, 1.0, 99.0],
[2.0, 2.0, 99.0],
[3.0, 3.0, 99.0],
]
)
tris = np.array([[0, 1, 2], [3, 4, 5]]) # 두 번째는 일직선
x_coords, y_coords = _axes(0.0, 4.0, 0.0, 4.0)
z_grid = rasterize_triangle_mesh(verts, tris, x_coords, y_coords)
filled = np.isfinite(z_grid)
assert filled.any()
assert np.allclose(z_grid[filled], 1.0)
def test_triangle_smaller_than_cell_still_lands():
"""격자 간격보다 작은 삼각형도 자기가 덮는 격자점을 채운다 (실데이터는 m²당 13.7면)."""
verts = np.array([[1.6, 1.6, 7.0], [2.4, 1.6, 7.0], [2.0, 2.4, 7.0]])
tris = np.array([[0, 1, 2]])
x_coords, y_coords = _axes(0.0, 4.0, 0.0, 4.0)
z_grid = rasterize_triangle_mesh(verts, tris, x_coords, y_coords)
assert z_grid[2, 2] == pytest.approx(7.0)
assert np.isnan(z_grid[0, 0])
def test_float32_utm_axis_does_not_drop_cells():
"""float32 축(UTM 좌표) — 등간격 환산으로 인덱스를 내면 셀을 놓친다.
`_grid_axes`가 float32를 쓰는데 18만대 좌표에서 해상도가 0.015625m라 간격이
0.984~1.0으로 흔들린다. 등간격 가정 시 최대 0.81셀 어긋나 실측 8,819셀이
비었다(2026-08-17). 축을 직접 탐색해야 한다.
"""
x_coords = np.linspace(183433.6, 183805.8, 374, dtype=np.float32)
y_coords = np.linspace(489168.8, 489570.2, 403, dtype=np.float32)
# 격자 전체를 덮는 큰 삼각형 2개 → 모든 셀이 채워져야 한다.
x0, x1 = float(x_coords[0]) - 5.0, float(x_coords[-1]) + 5.0
y0, y1 = float(y_coords[0]) - 5.0, float(y_coords[-1]) + 5.0
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
verts = np.array([[x, y, _plane(x, y)] for x, y in corners])
tris = np.array([[0, 1, 2], [0, 2, 3]])
z_grid = rasterize_triangle_mesh(verts, tris, x_coords, y_coords)
assert np.isfinite(z_grid).all(), f"빈 셀 {int((~np.isfinite(z_grid)).sum()):,}"
xx, yy = np.meshgrid(np.asarray(x_coords, np.float64), np.asarray(y_coords, np.float64))
assert np.abs(z_grid - _plane(xx, yy)).max() < 1e-6
def test_small_triangles_on_float32_axis_cover_every_cell():
"""실데이터처럼 격자보다 작은 삼각형이 촘촘할 때도 누락이 없어야 한다."""
x_coords = np.linspace(183433.6, 183463.6, 31, dtype=np.float32)
y_coords = np.linspace(489168.8, 489198.8, 31, dtype=np.float32)
# 0.5m 간격 정점망을 사각형→삼각형 2개로 쪼개 촘촘한 TIN을 흉내낸다.
gx = np.arange(float(x_coords[0]) - 1.0, float(x_coords[-1]) + 1.5, 0.5)
gy = np.arange(float(y_coords[0]) - 1.0, float(y_coords[-1]) + 1.5, 0.5)
mx, my = np.meshgrid(gx, gy)
verts = np.column_stack([mx.ravel(), my.ravel(), _plane(mx.ravel(), my.ravel())])
n_col = len(gx)
tris = []
for r in range(len(gy) - 1):
for c in range(n_col - 1):
p00, p10 = r * n_col + c, r * n_col + c + 1
p01, p11 = (r + 1) * n_col + c, (r + 1) * n_col + c + 1
tris.append([p00, p10, p11])
tris.append([p00, p11, p01])
z_grid = rasterize_triangle_mesh(verts, np.array(tris), x_coords, y_coords)
assert np.isfinite(z_grid).all(), f"빈 셀 {int((~np.isfinite(z_grid)).sum()):,}"
xx, yy = np.meshgrid(np.asarray(x_coords, np.float64), np.asarray(y_coords, np.float64))
assert np.abs(z_grid - _plane(xx, yy)).max() < 1e-6
def test_descending_axis_is_supported():
"""내림차순 축(예: 북쪽이 위인 y축)도 같은 결과를 낸다."""
corners = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)]
verts = np.array([[x, y, _plane(x, y)] for x, y in corners])
tris = np.array([[0, 1, 2], [0, 2, 3]])
x_coords = np.linspace(0.0, 4.0, 5)
y_desc = np.linspace(4.0, 0.0, 5)
z_grid = rasterize_triangle_mesh(verts, tris, x_coords, y_desc)
xx, yy = np.meshgrid(x_coords, y_desc)
assert np.isfinite(z_grid).all()
assert np.allclose(z_grid, _plane(xx, yy), atol=1e-9)
def test_empty_mesh_returns_all_nan():
x_coords, y_coords = _axes(0.0, 3.0, 0.0, 3.0)
z_grid = rasterize_triangle_mesh(
np.zeros((0, 3)), np.zeros((0, 3), dtype=np.int64), x_coords, y_coords
)
assert z_grid.shape == (len(y_coords), len(x_coords))
assert np.isnan(z_grid).all()
+121
View File
@@ -0,0 +1,121 @@
"""B04 지면 필터 회귀 테스트 — 고정 상수 때문에 지면점이 사라지던 결함 방지.
원 결함(2026-09-01): CSF 하강 예산이 0.3185m x 150회 = 47.8m로 고정돼 기복이
그보다 큰 산악지에서 천이 지면에 닿지 못했고(용화.las 지면점 0.06%),
grid_min_z의 3x3 minimum_filter가 급경사에서 기준면을 경사만큼 파고들었다.
"""
import numpy as np
import pytest
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
filter_classification,
has_classified_ground,
)
from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf
from B04_PreProcess.B04_PreProcess_Engine_Filter_Grid import filter_grid_min_z
from B04_PreProcess.B04_PreProcess_Engine_Ground import resolve_auto_source_filters
# CSF 고정 하강 예산 (옛 코드): 9.8 * 0.05 * 0.65 * 150
LEGACY_CSF_DROP_BUDGET_M = 47.775
def _sloped_ground(relief_m: float, slope: float = 0.3, spacing_m: float = 0.5) -> np.ndarray:
"""지정한 경사로 relief_m 만큼 오르내리는 지면 점군을 만든다.
경사와 기복을 따로 잡는다 — 결함은 경사가 아니라 **절대 기복**이 CSF 하강
예산을 넘을 때 터졌다. 점 간격은 격자·천 셀보다 촘촘하게 둔다(실제 LAS와 동일).
셀 안 표고 편차가 임계값을 넘으면 필터와 무관하게 점이 떨어지기 때문이다.
"""
axis = np.arange(0.0, relief_m / slope, spacing_m, dtype=np.float64)
x, y = np.meshgrid(axis, axis)
z = 100.0 + slope * x
return np.column_stack([x.ravel(), y.ravel(), z.ravel()])
def _structured(points: np.ndarray, classification: np.ndarray | None = None) -> dict:
data = {
"xyz": points,
"bounds": np.array(
[
[points[:, 0].min(), points[:, 0].max()],
[points[:, 1].min(), points[:, 1].max()],
[points[:, 2].min(), points[:, 2].max()],
]
),
}
if classification is not None:
data["classification"] = classification
return data
@pytest.mark.parametrize("relief_m", [20.0, 120.0])
def test_csf_recovers_ground_regardless_of_relief(relief_m: float) -> None:
"""기복이 옛 고정 예산(47.8m)을 넘어도 지면이 살아남는다."""
ground = _sloped_ground(relief_m)
mask = filter_csf(_structured(ground))
assert mask.mean() > 0.5, f"기복 {relief_m}m 에서 지면 회수율 {mask.mean():.1%}"
# 옛 결함은 낮은 쪽 띠만 남기고 높은 쪽을 통째로 버렸다.
high_side = ground[:, 2] > ground[:, 2].min() + LEGACY_CSF_DROP_BUDGET_M
if high_side.any():
assert mask[high_side].mean() > 0.5, "옛 하강 예산 위쪽 지면이 잡히지 않는다"
def test_csf_rejects_canopy_points() -> None:
"""6단계 수목 필터가 실제로 동작한다 (피연산자 역전 시 항상 참이 되던 자리)."""
ground = _sloped_ground(60.0, slope=0.3)
canopy = ground.copy()
canopy[:, 2] += 12.0
points = np.vstack([ground, canopy])
mask = filter_csf(_structured(points))
is_canopy = np.zeros(len(points), dtype=bool)
is_canopy[len(ground) :] = True
assert not mask[is_canopy].any(), "수목 반사점이 지면으로 분류됐다"
assert mask[~is_canopy].mean() > 0.5
def test_grid_min_z_keeps_ground_on_steep_slope() -> None:
"""급경사에서 3x3 최소필터가 기준면을 파고들던 결함 방지."""
# 경사 100%. 셀 1m 안 표고 편차는 1.0m로 임계값(1.5m) 이내지만, 옛 코드는
# 3x3 최소필터가 2m 밖 셀 값을 끌어와 기준면을 2.0m 낮췄다 -> 임계값 초과.
ground = _sloped_ground(120.0, slope=1.0)
mask = filter_grid_min_z(_structured(ground), cell_size=1.0, height_threshold=1.5)
assert mask.mean() > 0.9, f"급경사 지면 회수율 {mask.mean():.1%}"
def test_grid_min_z_rejects_points_above_threshold() -> None:
ground = _sloped_ground(10.0)
above = ground.copy()
above[:, 2] += 5.0 # 임계값 1.5m 초과
points = np.vstack([ground, above])
mask = filter_grid_min_z(_structured(points))
assert not mask[len(ground) :].any()
def test_classification_filter_reads_class_two() -> None:
points = _sloped_ground(10.0)
classification = np.ones(len(points), dtype=np.uint8)
classification[::4] = 2
mask = filter_classification(_structured(points, classification))
assert mask.sum() == len(points[::4])
def test_auto_filters_prefer_classification_but_always_build_csf() -> None:
"""자동 전처리 목록 — 첫 항목이 기본 확정값. csf는 분류가 있어도 늘 함께 만든다."""
points = _sloped_ground(10.0)
unclassified = np.zeros(len(points), dtype=np.uint8)
classified = np.ones(len(points), dtype=np.uint8)
classified[::4] = 2
# 분류가 있으면 classification 이 기본이고, csf 는 검증용으로 같이 만든다.
assert resolve_auto_source_filters(_structured(points, classified)) == [
"classification",
"csf",
]
# 분류가 없으면 csf 하나뿐 — 빈 마스크로 모델을 만들지 않는다.
assert resolve_auto_source_filters(_structured(points, unclassified)) == ["csf"]
assert not has_classified_ground(_structured(points, unclassified))
# classification 이 없는 구조화 데이터(옛 캐시)에서도 죽지 않는다.
assert resolve_auto_source_filters(_structured(points)) == ["csf"]
@@ -0,0 +1,200 @@
"""계곡 통과 시설 확장 — PipePoint의 시설종류·구간·옵션 (PLAN 2026-08-17 컨테이너 병합 1단계).
배관 정본 `pipe_points.json`은 유역 계산의 입력이라 저장소를 옮기지 않고 제자리 확장한다.
구 파일(`{chainage_m, source}`뿐)은 배관·폭 미지정으로 읽혀야 한다 (하위 호환).
"""
import pytest
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
PIPE_SOURCE_STREAM,
PIPE_SOURCE_USER,
PipePoint,
carry_facility_attributes,
parse_pipe_points,
)
# ── 하위 호환 — 구 파일 형식 ────────────────────────────────────────────────
def test_legacy_dict_defaults_to_pipe_with_no_span():
points = parse_pipe_points([{"chainage_m": 120.0, "source": "stream"}])
assert len(points) == 1
point = points[0]
assert point.facility == PIPE_FACILITY_PIPE
assert point.start_m is None and point.end_m is None
assert point.source == PIPE_SOURCE_STREAM
def test_legacy_bare_number_still_parses():
points = parse_pipe_points([80.5])
assert points[0].chainage_m == 80.5
assert points[0].facility == PIPE_FACILITY_PIPE
def test_legacy_dict_roundtrip_stays_compact():
"""구 형식 저장분은 확장 필드 없이 그대로 다시 저장된다 (파일 불변)."""
point = parse_pipe_points([{"chainage_m": 60.0, "source": "user"}])[0]
assert point.as_dict() == {"chainage_m": 60.0, "source": PIPE_SOURCE_USER}
# ── 시설 종류 ───────────────────────────────────────────────────────────────
def test_facility_roundtrip():
for facility in (
PIPE_FACILITY_PIPE,
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_FORD_BRIDGE,
):
data = {"chainage_m": 100.0, "facility": facility}
parsed = parse_pipe_points([data])[0]
assert parsed.facility == facility
assert parse_pipe_points([parsed.as_dict()])[0].facility == facility
def test_unknown_facility_falls_back_to_pipe():
points = parse_pipe_points([{"chainage_m": 100.0, "facility": "bridge"}])
assert points[0].facility == PIPE_FACILITY_PIPE
# ── 구간 (기준점 + 시작·종료) ──────────────────────────────────────────────
def test_span_roundtrip():
data = {"chainage_m": 100.0, "facility": "box_culvert", "start_m": 92.0, "end_m": 111.0}
point = parse_pipe_points([data])[0]
assert (point.start_m, point.end_m) == (92.0, 111.0)
again = parse_pipe_points([point.as_dict()])[0]
assert (again.start_m, again.end_m) == (92.0, 111.0)
def test_span_is_normalized_to_contain_anchor():
"""시작·종료가 뒤집혔거나 기준점을 안 품으면 정규화한다 (파서는 관대하게)."""
flipped = parse_pipe_points([{"chainage_m": 100.0, "start_m": 110.0, "end_m": 90.0}])[0]
assert (flipped.start_m, flipped.end_m) == (90.0, 110.0)
outside = parse_pipe_points([{"chainage_m": 100.0, "start_m": 104.0, "end_m": 112.0}])[0]
assert outside.start_m == 100.0 and outside.end_m == 112.0
def test_half_span_is_completed_with_anchor():
"""한쪽만 오면 기준점으로 나머지를 채운다."""
point = parse_pipe_points([{"chainage_m": 100.0, "end_m": 115.0}])[0]
assert (point.start_m, point.end_m) == (100.0, 115.0)
def test_non_numeric_span_is_dropped():
point = parse_pipe_points([{"chainage_m": 100.0, "start_m": "", "end_m": None}])[0]
assert point.start_m is None and point.end_m is None
# ── 시설 옵션 (세월교 관 종류/크기/수량 등) ────────────────────────────────
def test_options_roundtrip():
data = {
"chainage_m": 100.0,
"facility": "ford_bridge",
"options": {"pipe_kind": "흄관", "pipe_diameter_mm": 800, "pipe_count": 3},
}
point = parse_pipe_points([data])[0]
assert point.options == {"pipe_kind": "흄관", "pipe_diameter_mm": 800, "pipe_count": 3}
again = parse_pipe_points([point.as_dict()])[0]
assert again.options == point.options
def test_pipe_accessory_options_roundtrip():
"""배관 부속 키(2026-08-17 재편) — 유형·집수정·기슭막이·돌붙임이 왕복 보존되는지."""
data = {
"chainage_m": 240.0,
"facility": "pipe",
"start_m": 236.0,
"end_m": 246.0,
"options": {
"flow_type": "계곡부형",
"pipe_diameter_mm": 1000,
"catch_basin": "없음",
"revetment": "있음",
"revet_length_m": 10.0,
"revet_front_m": 4.0,
"stone_pitching": "있음",
},
}
point = parse_pipe_points([data])[0]
again = parse_pipe_points([point.as_dict()])[0]
assert again.options == data["options"]
assert (again.start_m, again.end_m) == (236.0, 246.0)
def test_non_dict_options_are_dropped():
point = parse_pipe_points([{"chainage_m": 100.0, "options": "D800x3"}])[0]
assert point.options is None
def test_empty_options_are_omitted_from_dict():
point = PipePoint(chainage_m=50.0, options={})
assert "options" not in point.as_dict()
# ── 시설 속성 승계 — 세부유역 계산 왕복에서 종류가 사라지면 안 된다 ────────
def test_carry_attributes_restores_facility_after_rebuild():
"""계산기는 chainage만 다뤄 재구성 목록이 전부 기본 배관이 된다 — 되붙여야 한다."""
requested = parse_pipe_points(
[
{
"chainage_m": 100.0,
"facility": "ford_bridge",
"start_m": 95.0,
"end_m": 108.0,
"options": {"pipe_count": 2},
},
{"chainage_m": 200.0},
]
)
rebuilt = [PipePoint(chainage_m=100.0, source="user"), PipePoint(chainage_m=200.0)]
carried = carry_facility_attributes(rebuilt, requested)
assert carried[0].facility == PIPE_FACILITY_FORD_BRIDGE
assert (carried[0].start_m, carried[0].end_m) == (95.0, 108.0)
assert carried[0].options == {"pipe_count": 2}
assert carried[1].facility == PIPE_FACILITY_PIPE
def test_carry_attributes_uses_nearest_when_chainage_shifted():
"""계산기가 좌표를 미세 조정해도(스냅) 가장 가까운 원본에서 승계한다 — _retag과 동일 기준."""
requested = parse_pipe_points([{"chainage_m": 150.0, "facility": "box_culvert"}])
rebuilt = [PipePoint(chainage_m=150.37)]
carried = carry_facility_attributes(rebuilt, requested)
assert carried[0].facility == PIPE_FACILITY_BOX
def test_carry_attributes_without_reference_is_noop():
rebuilt = [PipePoint(chainage_m=100.0)]
assert carry_facility_attributes(rebuilt, []) is rebuilt
assert rebuilt[0].facility == PIPE_FACILITY_PIPE
# ── 정렬 유지 ───────────────────────────────────────────────────────────────
def test_points_stay_sorted_by_chainage():
points = parse_pipe_points(
[
{"chainage_m": 300.0, "facility": "ford_pavement"},
{"chainage_m": 100.0},
{"chainage_m": 200.0, "facility": "box_culvert"},
]
)
assert [point.chainage_m for point in points] == [100.0, 200.0, 300.0]
@@ -0,0 +1,113 @@
"""코리도 저장본이 리본 표식을 잃지 않는지 (2026-09-02).
`patch`·`patchClip` 은 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도** 본다
(`B05_Profile_UI_Viewer.ts` 의 지형 스냅 제외 가드). 그런데 직렬화 대상에서 빠져 있어
저장본으로 다시 연 코리도는 패치인 줄 몰랐고, 바깥 끝이 원지반까지 끌려 내려갔다.
같은 종류의 실수를 다시 잡으려고 **대칭**을 잠근다 — `serialize()` 가 리본에 쓰는
선택 필드는 전부 `deserialize()` 가 되읽어야 한다. 필드를 새로 담으면서 되읽기를
빠뜨리면 여기서 걸린다.
"""
import re
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# 저장 형식(serialize/deserialize)은 브라우저·서버 공용 모듈로 옮겼다(2026-09-04 분리).
CORRIDOR = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Envelope.ts"
VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Viewer.ts"
CARVE_WING = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve_Wing.ts"
CARVE = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve.ts"
# 700줄 제한으로 함수가 다른 파일로 갈릴 수 있다 — 후보를 모두 이어 붙여 훑는다.
# ⚠ 못 찾을 때 최상위에서 터지면 **이 파일 하나 때문에 회귀 전체가 멈춘다**(2026-09-08).
_CANDIDATES = (CORRIDOR, PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor.ts")
SOURCE = "\n".join(path.read_text(encoding="utf-8") for path in _CANDIDATES if path.is_file())
def _block(name: str) -> str:
"""`function <name>(` 부터 다음 최상위 `function ` 앞까지."""
start = SOURCE.find(f"function {name}(")
if start < 0:
pytest.skip(
f"`function {name}(` 를 못 찾음 — 다른 파일로 갈라져 나갔는지 확인할 것",
allow_module_level=True,
)
rest = SOURCE[start + 1 :]
# 공용 모듈에서는 최상위 함수가 `export function` 으로도 나온다(2026-09-04 분리).
ends = [pos for pos in (rest.find("\nfunction "), rest.find("\nexport function ")) if pos >= 0]
return rest if not ends else rest[: min(ends)]
SERIALIZE = _block("serialize")
DESERIALIZE = _block("deserialize")
# ── 표식이 실제로 오간다 ─────────────────────────────────────────────────────
def test_serialize_carries_patch_flags():
"""저장 쪽이 두 표식을 담는다."""
assert "patch: true" in SERIALIZE
assert "patchClip: true" in SERIALIZE
def test_deserialize_restores_patch_flags():
"""복원 쪽이 두 표식을 되살린다 — 이게 빠져서 결함이 났다."""
assert "patch: true" in DESERIALIZE
assert "patchClip: true" in DESERIALIZE
def test_envelope_version_bumped():
"""담는 필드가 바뀌었으므로 형식 버전이 올라가야 옛 저장본이 만료된다."""
version = int(re.search(r"const ENVELOPE_VERSION = (\d+);", SOURCE).group(1))
assert version >= 6
def test_viewer_still_guards_on_patch():
"""뷰어가 표식을 보는 것이 이 수정의 이유다 — 가드가 사라지면 전제가 무너진다."""
assert "if (ribbon.patch) return;" in VIEWER.read_text(encoding="utf-8")
# ── 대칭 — 쓴 것은 되읽는다 ──────────────────────────────────────────────────
def _optional_ribbon_fields(block: str) -> set[str]:
"""`...(조건 ? { 키: … } : {})` 로 붙는 선택 필드 이름."""
return set(re.findall(r"\?\s*\{\s*(\w+):", block)) | set(
re.findall(r"\?\s*\{\s*(\w+)\s*:", block)
)
def test_every_saved_optional_field_is_restored():
"""저장이 담는 선택 필드는 전부 복원이 되읽어야 한다."""
saved = _optional_ribbon_fields(SERIALIZE)
restored = _optional_ribbon_fields(DESERIALIZE)
# 이름이 다른 짝(직렬화 base64 ↔ 복원 원본형)은 접미사를 떼고 맞춘다.
normalize = lambda names: {n.removesuffix("Base64") for n in names} # noqa: E731
missing = normalize(saved) - normalize(restored)
assert not missing, f"복원이 되읽지 않는 필드: {sorted(missing)}"
# ── 죽은 코드 제거 ───────────────────────────────────────────────────────────
def test_reverted_wing_trim_tools_are_gone():
"""버전 76~78 트림 도구는 원복 뒤 참조가 0이었다 — 되살아나면 여기서 걸린다."""
source = CARVE_WING.read_text(encoding="utf-8")
for symbol in ("wingOuterLines", "wingTrimAt", "clipRunToRange", "WingTrimLine"):
assert f"export function {symbol}" not in source
assert f"export interface {symbol}" not in source
def test_carve_still_imports_live_wing_tools():
"""살아 있는 도구까지 지우지 않았는지 — 호출부 import 가 그대로여야 한다."""
source = CARVE.read_text(encoding="utf-8")
for symbol in ("wingSpanOf", "wingSpanOnRoute", "wingRangeAt", "clipRunBelow"):
assert symbol in source
@@ -0,0 +1,44 @@
"""코리도 저장본의 열쇠(해시)만 머리에서 떼어 보는 길 — 18.6MB 를 안 받고 판정한다.
왜 — B05 진입 때 저장본을 통째로 받은 뒤 해시가 다르면 버렸다(2026-09-06 실측).
이제 `?hash=` 를 주면 서버가 머리 4KB 만 읽어 맞춰 보고, 어긋나면 파일 대신 `stale` 만 낸다.
"""
import asyncio
import json
from B05_Profile.B05_Profile_Router_Corridor import _stored_hash
def _write_envelope(path, hash_value: str, ribbon_count: int = 200) -> None:
"""실제 봉투와 같은 칸 차례(version → hash → ribbons)로 쓴다."""
payload = {
"version": 3,
"hash": hash_value,
# 머리 4KB 뒤로 밀리는 덩치를 흉내 낸다 — 해시가 앞에 있어야 찾을 수 있다.
"ribbons": [{"name": f"ribbon-{i}", "data": "x" * 64} for i in range(ribbon_count)],
}
path.write_text(json.dumps(payload), encoding="utf-8")
def test_머리에서_해시를_찾는다(tmp_path):
path = tmp_path / "corridor.json"
_write_envelope(path, "1a2b3c4d")
assert asyncio.run(_stored_hash(path)) == "1a2b3c4d"
# 파일 전체를 읽지 않아도 되는지 — 머리 4KB 보다 훨씬 큰 파일이어야 의미가 있다.
assert path.stat().st_size > 4096
def test_해시가_없으면_None(tmp_path):
path = tmp_path / "corridor.json"
path.write_text(json.dumps({"version": 3, "ribbons": []}), encoding="utf-8")
assert asyncio.run(_stored_hash(path)) is None
def test_머리_밖으로_밀리면_못_찾는다(tmp_path):
"""봉투 차례가 바뀌어 해시가 4KB 뒤로 가면 None — 그때는 종전대로 파일을 내려보낸다."""
path = tmp_path / "corridor.json"
path.write_text(
json.dumps({"pad": "y" * 8000, "version": 3, "hash": "deadbeef"}), encoding="utf-8"
)
assert asyncio.run(_stored_hash(path)) is None
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""코리도 초기값 — 스냅샷 복원이 **새 route id 이름**으로 되돌리는지(2026-09-04).
[초기화]는 routes 행을 새로 넣어 route id가 바뀐다. 코리도 저장본 파일명에는 번호가
박혀 있어, 스냅샷을 그대로 복사하면 브라우저가 못 찾아 매번 다시 만든다.
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Router_Corridor import corridor_path # noqa: E402
from common_util.common_util_initial_snapshot import ( # noqa: E402
_CORRIDOR_NAME,
restore_snapshot_files,
snapshot_dir,
)
def _seed_snapshot(root: Path, payload: dict) -> None:
target = snapshot_dir(root)
target.mkdir(parents=True, exist_ok=True)
(target / _CORRIDOR_NAME).write_text(json.dumps(payload), encoding="utf-8")
def test_restore_renames_corridor_to_new_route_id(tmp_path: Path) -> None:
_seed_snapshot(tmp_path, {"version": 6, "hash": "abcd1234", "ribbons": []})
# 옛 번호로 남아 있던 작업본 — 복원 뒤에도 새 번호 파일이 서야 한다.
old = corridor_path(tmp_path, 139)
old.parent.mkdir(parents=True, exist_ok=True)
old.write_text("{}", encoding="utf-8")
restore_snapshot_files(tmp_path, 141)
fresh = corridor_path(tmp_path, 141)
assert fresh.is_file(), "새 route id 이름으로 코리도가 복원되어야 한다"
assert json.loads(fresh.read_text(encoding="utf-8"))["hash"] == "abcd1234"
def test_restore_without_route_id_leaves_corridor_alone(tmp_path: Path) -> None:
"""재계산 경로(스냅샷 없음)에서는 route id가 없다 — 코리도는 건드리지 않는다."""
_seed_snapshot(tmp_path, {"version": 6, "hash": "abcd1234", "ribbons": []})
restore_snapshot_files(tmp_path, None)
assert not (tmp_path / "B05_Profile" / "corridor").exists()
def test_restore_without_snapshot_corridor_is_noop(tmp_path: Path) -> None:
snapshot_dir(tmp_path).mkdir(parents=True, exist_ok=True)
restore_snapshot_files(tmp_path, 141)
assert not corridor_path(tmp_path, 141).exists()
@@ -0,0 +1,37 @@
"""초기화가 주인 없는 코리도 파일을 지우는지 — 2026-08-28 백로그.
route 행이 지워지면 corridor_00NN.json 은 주인을 잃는다(실측: 한 프로젝트에 19개·약 85MB).
"""
from B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files
def _make(root, route_ids):
directory = root / "B05_Profile" / "corridor"
directory.mkdir(parents=True, exist_ok=True)
for route_id in route_ids:
(directory / f"corridor_{route_id:04d}.json").write_text("{}", encoding="utf-8")
return directory
def test_keeps_only_live_routes(tmp_path):
directory = _make(tmp_path, [75, 91, 92, 94])
assert prune_corridor_files(tmp_path, {94}) == 3
assert sorted(path.name for path in directory.glob("*.json")) == ["corridor_0094.json"]
def test_keeps_all_requested_ids(tmp_path):
directory = _make(tmp_path, [10, 11])
assert prune_corridor_files(tmp_path, {10, 11}) == 0
assert len(list(directory.glob("*.json"))) == 2
def test_missing_directory_is_not_an_error(tmp_path):
assert prune_corridor_files(tmp_path, {1}) == 0
def test_other_files_are_left_alone(tmp_path):
directory = _make(tmp_path, [5])
(directory / "메모.txt").write_text("보존", encoding="utf-8")
assert prune_corridor_files(tmp_path, set()) == 1
assert [path.name for path in directory.iterdir()] == ["메모.txt"]
@@ -0,0 +1,82 @@
"""B05 코리도(예상형상) 저장 라우터 검증.
파생 데이터 보관 계약 — 경로 규칙, PUT 검증(형식·크기), 파일 왕복.
DB 의존(_resolve_project_root)은 스텁으로 대체해 파일 로직만 검증한다.
"""
import asyncio
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from uuid import uuid4 # noqa: E402
import B05_Profile.B05_Profile_Router_Corridor as corridor # noqa: E402
def test_corridor_path_layout(tmp_path):
"""저장 경로 = {project_root}/B05_Profile/corridor/corridor_{route:04d}.json."""
path = corridor.corridor_path(tmp_path, 7)
assert path == tmp_path / "B05_Profile" / "corridor" / "corridor_0007.json"
def test_put_rejects_wrong_shape(tmp_path, monkeypatch):
"""ribbons 없는 본문은 422 — 파생 데이터라도 최소 형식은 지킨다."""
async def fake_root(_project_id):
return tmp_path
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
response = asyncio.run(corridor.put_corridor(uuid4(), 1, {"nope": True}))
assert response.status_code == 422
def test_put_then_get_roundtrip(tmp_path, monkeypatch):
"""PUT 저장본을 GET이 그대로 돌려준다(원자 기록·경로 일치)."""
async def fake_root(_project_id):
return tmp_path
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
payload = {
"version": 1,
"hash": "deadbeef",
"ribbons": [{"kind": "cut", "side": "left", "colCount": 2, "chainages": [0, 2]}],
"outline": {"chainages": [0, 2], "left": [[0, 1]], "right": [[0, -1]]},
}
put_response = asyncio.run(corridor.put_corridor(uuid4(), 3, payload))
assert put_response.status_code == 200
stored = tmp_path / "B05_Profile" / "corridor" / "corridor_0003.json"
assert stored.is_file()
get_response = asyncio.run(corridor.get_corridor(uuid4(), 3))
assert get_response.status_code == 200
assert json.loads(get_response.body) == payload
def test_get_missing_returns_404(tmp_path, monkeypatch):
"""저장본 없음 = 404 — 프론트가 빌드로 폴백하는 계약."""
async def fake_root(_project_id):
return tmp_path
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
response = asyncio.run(corridor.get_corridor(uuid4(), 99))
assert response.status_code == 404
def test_put_rejects_oversize(tmp_path, monkeypatch):
"""크기 상한 초과는 413 — 비정상 요청 차단."""
async def fake_root(_project_id):
return tmp_path
monkeypatch.setattr(corridor, "MAX_CORRIDOR_BYTES", 64)
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
payload = {"version": 1, "hash": "x", "ribbons": [{"blob": "y" * 200}], "outline": {}}
response = asyncio.run(corridor.put_corridor(uuid4(), 1, payload))
assert response.status_code == 413
@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""절성토 완전 분리(2026-08-23 사용자 지시) 검증.
전환 구간을 상대 측점의 축퇴점으로 모핑하던 방식을 버리고, 행마다 두 측점
지반선을 보간한 **행 지반선**과 비탈선을 직접 교차시켜 종결한다.
TS `trimSlopeToGround()`와 같은 식을 파이썬으로 재현해 규칙을 잠근다:
- 절토는 지반 아래에서 시작해 **위로** 만나는 곳까지, 성토는 위에서 **아래로**.
- 시작부터 지반이 반대편이면 그 행에 그 종류는 없다(None → 조각 드롭 → 분리).
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
BUILD = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Build.ts").read_text(
encoding="utf-8"
)
STATION = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Station.ts").read_text(
encoding="utf-8"
)
CATCH_EPS = 0.005
MIN_WIDTH = 0.02
EXTEND_MAX = 30.0
EXTEND_STEP = 0.25
def _trim(inner2outer, kind, ground_at):
"""TS trimSlopeToGround와 같은 규칙."""
if len(inner2outer) < 2:
return None
sign = -1 if kind == "cut" else 1
def e_of(p):
return (p[1] - ground_at(p[0])) * sign
first = inner2outer[0]
if e_of(first) < -CATCH_EPS:
return None
kept = [first]
prev, e_prev = first, e_of(first)
closed = False
for point in inner2outer[1:]:
e_now = e_of(point)
if e_now > CATCH_EPS:
kept.append(point)
prev, e_prev = point, e_now
continue
ratio = 0 if e_prev - e_now <= 1e-12 else e_prev / (e_prev - e_now)
offset = prev[0] + (point[0] - prev[0]) * ratio
kept.append((offset, ground_at(offset)))
closed = True
break
if not closed:
tail, before = inner2outer[-1], inner2outer[-2]
run_off = tail[0] - before[0]
run_len = ((run_off) ** 2 + (tail[1] - before[1]) ** 2) ** 0.5
if abs(run_off) > 1e-9 and run_len > 1e-9:
so = run_off / run_len * EXTEND_STEP
sz = (tail[1] - before[1]) / run_len * EXTEND_STEP
cursor, e_cur = tail, e_of(tail)
step = 0
while step * EXTEND_STEP < EXTEND_MAX:
nxt = (cursor[0] + so, cursor[1] + sz)
e_nxt = e_of(nxt)
if e_nxt <= CATCH_EPS:
ratio = 0 if e_cur - e_nxt <= 1e-12 else e_cur / (e_cur - e_nxt)
offset = cursor[0] + so * ratio
kept.append((offset, ground_at(offset)))
closed = True
break
cursor, e_cur = nxt, e_nxt
kept.append(cursor)
step += 1
if len(kept) < 2 or abs(kept[-1][0] - kept[0][0]) < MIN_WIDTH:
return None
return kept
def test_cut_trims_where_slope_meets_ground_above():
"""절토: 노면(지반 아래)에서 1:1로 올라가 지반과 만나는 지점에서 잘린다."""
slope = [(0.0, 100.0), (2.0, 102.0), (4.0, 104.0)] # 1:1 상승
trimmed = _trim(slope, "cut", lambda o: 101.0) # 평평한 지반 101
assert trimmed is not None
assert abs(trimmed[-1][0] - 1.0) < 1e-9 # 100+o = 101 → o=1
assert abs(trimmed[-1][1] - 101.0) < 1e-9 # 끝점은 지반 위
def test_cut_dropped_when_ground_is_below_road():
"""절토인데 그 행의 지반이 노면 아래(성토 상황)면 조각이 없다 — 완전 분리 핵심.
(기존 모핑은 이런 행에도 절토 서피스를 만들어 지반으로 내려갔다.)"""
slope = [(0.0, 100.0), (2.0, 102.0)]
assert _trim(slope, "cut", lambda o: 99.0) is None
def test_fill_trims_where_slope_meets_ground_below():
"""성토: 노면(지반 위)에서 내려가 지반과 만나는 지점에서 잘린다."""
slope = [(0.0, 100.0), (2.4, 98.0), (4.8, 96.0)] # 1:1.2 하강
trimmed = _trim(slope, "fill", lambda o: 97.0)
assert trimmed is not None
assert abs(trimmed[-1][1] - 97.0) < 1e-9
def test_fill_dropped_when_ground_is_above_road():
"""성토인데 지반이 노면 위(절토 상황)면 조각이 없다."""
slope = [(0.0, 100.0), (2.4, 98.0)]
assert _trim(slope, "fill", lambda o: 101.0) is None
def test_extends_last_grade_when_section_too_short():
"""단면 반폭 안에서 지반을 못 만나면 마지막 구배로 연장해 접점을 찾는다."""
slope = [(0.0, 100.0), (1.0, 101.0)] # 지반 103은 폴리라인 밖
trimmed = _trim(slope, "cut", lambda o: 103.0)
assert trimmed is not None
assert abs(trimmed[-1][0] - 3.0) < EXTEND_STEP + 1e-9 # 100+o=103 → o=3 부근
assert abs(trimmed[-1][1] - 103.0) < 1e-9
def test_pinched_slope_is_dropped():
"""전환점 부근처럼 접점이 코앞이면(폭 < 하한) 조각을 만들지 않는다."""
slope = [(0.0, 100.0), (2.0, 102.0)]
assert _trim(slope, "cut", lambda o: 100.005) is None
def test_source_no_longer_morphs_to_degenerate_point():
"""빌더에서 모핑 장치(degeneratePiece·taper)가 사라지고 트림이 들어갔는지."""
assert "degeneratePiece" not in BUILD
assert "taper" not in BUILD
assert "trimSlopeToGround" in BUILD and "mixedGround" in BUILD
assert "slopeRelative" in STATION # 비탈도 측구처럼 도로부 앵커 상대 좌표.
def test_outline_follows_row_extent():
"""클리핑 외곽이 측점 보간이 아니라 행 실점유(비탈→측구→노견)를 따른다."""
assert "outerOf" in BUILD
assert "lerpOuter" not in BUILD
@@ -0,0 +1,85 @@
"""코리도 평면 궤적 스플라인 성질 검증 (TS 구현과 같은 식을 파이썬으로 재현).
노선 정점을 직선으로 이으면 정점 간격(실측 평균 2.6m)만큼 각진다. Centripetal
Catmull-Rom으로 바꾼 뒤 지켜야 할 두 가지를 확인한다.
1) 제어점(노선 정점)을 그대로 지난다 — 노선을 벗어나면 안 된다.
2) 급커브(실측 최대 42°)에서도 오버슈트가 없다 — 제어 다각형 밖으로 튀지 않는다.
"""
import math
ALPHA = 0.5
def _catmull_rom(p0, p1, p2, p3, t):
"""TS `catmullRom()`과 같은 식(Barry-Goldman 재귀, centripetal)."""
def knot(previous, a, b):
return previous + max(1e-6, math.dist(a, b) ** ALPHA)
t0 = 0.0
t1 = knot(t0, p0, p1)
t2 = knot(t1, p1, p2)
t3 = knot(t2, p2, p3)
time = t1 + (t2 - t1) * t
def mix(a, b, ta, tb):
span = (tb - ta) or 1e-9
w = (tb - time) / span
return (a[0] * w + b[0] * (1 - w), a[1] * w + b[1] * (1 - w))
a1 = mix(p0, p1, t0, t1)
a2 = mix(p1, p2, t1, t2)
a3 = mix(p2, p3, t2, t3)
b1 = mix(a1, a2, t0, t2)
b2 = mix(a2, a3, t1, t3)
return mix(b1, b2, t1, t2)
def test_passes_through_control_points():
"""t=0은 p1, t=1은 p2 — 스플라인이 노선 정점을 그대로 지난다."""
p0, p1, p2, p3 = (0.0, 0.0), (2.0, 0.0), (4.0, 1.0), (6.0, 1.0)
start = _catmull_rom(p0, p1, p2, p3, 0.0)
end = _catmull_rom(p0, p1, p2, p3, 1.0)
assert math.dist(start, p1) < 1e-9
assert math.dist(end, p2) < 1e-9
def test_no_overshoot_on_sharp_turn():
"""42° 꺾임에서도 구간이 제어 다각형 근방을 벗어나지 않는다(centripetal 성질)."""
angle = math.radians(42)
p0 = (-2.6, 0.0)
p1 = (0.0, 0.0)
p2 = (2.6 * math.cos(angle), 2.6 * math.sin(angle))
p3 = (p2[0] + 2.6 * math.cos(angle), p2[1] + 2.6 * math.sin(angle))
chord = math.dist(p1, p2)
for step in range(21):
point = _catmull_rom(p0, p1, p2, p3, step / 20)
# 구간 양 끝에서의 거리 합이 현의 길이를 크게 넘지 않으면 부풀지 않은 것이다.
detour = math.dist(point, p1) + math.dist(point, p2)
assert detour <= chord * 1.10, f"t={step / 20}: 우회 {detour:.3f} > 현 {chord:.3f}"
def test_max_turn_angle_drops():
"""스플라인은 큰 꺾임 하나를 작은 꺾임 여럿으로 나눈다 — **최대** 꺾임각이 준다.
(합이 아니라 최대각이 지표다: 점이 많아지면 합은 당연히 커진다.)"""
pts = [(0.0, 0.0), (2.6, 0.0), (4.5, 1.8), (7.1, 1.8), (9.0, 3.6)]
def max_turn(path):
worst = 0.0
for i in range(1, len(path) - 1):
a = (path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1])
b = (path[i + 1][0] - path[i][0], path[i + 1][1] - path[i][1])
na, nb = math.hypot(*a), math.hypot(*b)
if na < 1e-9 or nb < 1e-9:
continue
cos = max(-1.0, min(1.0, (a[0] * b[0] + a[1] * b[1]) / (na * nb)))
worst = max(worst, math.degrees(math.acos(cos)))
return worst
dense = []
for i in range(1, len(pts) - 2):
for step in range(10):
dense.append(_catmull_rom(pts[i - 1], pts[i], pts[i + 1], pts[i + 2], step / 10))
assert max_turn(dense) < max_turn(pts) / 2
@@ -0,0 +1,88 @@
"""코리도 절토↔성토 전환점과 측구 타입(일반/L형) 반영 검증.
전환점(2026-08-23 사용자 확정): 두 측점 사이에서 계획선이 지면선을 통과하는 자리가
측구·절토의 종점이고, 거기서부터 성토가 시작된다. TS `buildCrossingFinder()`와 같은
식을 파이썬으로 재현해 경계 처리를 확인하고, 측구 두 형식이 엔진에서 서로 다른
단면으로 나오는지도 대조한다(3D는 그 단면을 잘라 쓰므로 형식이 그대로 반영된다).
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# 2026-08-23 700줄 분리로 측점 분류부가 Station 파일로 이사했다.
BUILD_SOURCE = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Station.ts").read_text(
encoding="utf-8"
)
def _crossing(samples, lo, hi):
"""TS buildCrossingFinder와 같은 규칙 — 구간 안쪽 교차만 돌려준다."""
for i in range(1, len(samples)):
a_ch, a_diff = samples[i - 1]
b_ch, b_diff = samples[i]
if b_ch <= lo or a_ch >= hi:
continue
if a_diff == 0:
if lo < a_ch < hi:
return a_ch
continue
if a_diff * b_diff >= 0:
continue
span = b_ch - a_ch
ratio = 0 if span <= 1e-12 else a_diff / (a_diff - b_diff)
crossing = a_ch + span * ratio
if lo < crossing < hi:
return crossing
return None
def test_finds_cut_to_fill_crossing():
"""절토(-)에서 성토(+)로 넘어가는 자리를 선형보간으로 집는다."""
samples = [(0.0, -1.0), (10.0, -0.5), (20.0, 0.5)]
assert _crossing(samples, 0.0, 20.0) == 15.0
def test_start_point_touching_ground_is_not_a_crossing():
"""BP·EP는 정의상 지반과 만난다 — 구간 경계의 0을 전환점으로 삼으면 안 된다.
(실제로 이걸 안 걸러 측구·절토가 0.4m 만에 끝났다 — 2026-08-23 실측)"""
samples = [(0.0, 0.0), (10.0, -0.4), (20.0, 0.7)]
crossing = _crossing(samples, 0.0, 20.0)
assert crossing is not None and crossing > 0.0
assert abs(crossing - 10.0 - 10.0 * (0.4 / 1.1)) < 1e-9
def test_no_crossing_when_all_fill():
"""전 구간 성토면 전환점이 없다 — 호출부가 중간(0.5)으로 폴백한다."""
assert _crossing([(0.0, 0.3), (10.0, 0.8), (20.0, 1.4)], 0.0, 20.0) is None
def test_ditch_width_follows_type():
"""3D 빌더가 측구 형식별 폭 필드를 구분해 쓰는지 — 일반은 상단폭, L형은 폭."""
assert 'ditch.type === "standard"' in BUILD_SOURCE
assert "ditch.top_width_m" in BUILD_SOURCE
assert "ditch.width_m" in BUILD_SOURCE
def test_ditch_shapes_differ_between_types():
"""엔진이 두 형식을 서로 다른 단면으로 낸다(3D는 이 단면을 잘라 쓴다)."""
from B06_Section.B06_Section_Engine_Design import compute_cross_design
samples = [{"offset_m": o / 2, "elevation_m": 100.0 + o * 0.02, "valid": True} for o in range(-40, 41)]
common = dict(
samples=samples,
design_elevation_m=100.0,
ground_type="ripping_rock",
section_mode="left_cut",
ditch_side="left",
)
standard = compute_cross_design(**common, ditch_type="standard")
l_type = compute_cross_design(**common, ditch_type="l_type")
assert standard["ditch"]["type"] == "standard"
assert l_type["ditch"]["type"] == "l_type"
# 일반은 사다리꼴(상단폭/저폭/깊이), L형은 폭·깊이만 — 형상이 다르다.
assert "top_width_m" in standard["ditch"] and "bottom_width_m" in standard["ditch"]
assert "width_m" in l_type["ditch"] and "bottom_width_m" not in l_type["ditch"]
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""B05 잘린 자리 윤곽(`cut-merged`) 회귀검증 — 2026-09-02.
`dropCollinear`가 추적 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 시작 모서리를
지우던 결함(실측 63셀 구멍)의 재발 방지. Node 헬퍼(helper_b05_cut_merged_loop.cjs)가
⚠ 도우미 확장자가 **`.cjs`** 인 이유(2026-09-07) — 루트 `package.json` 에
`"type": "module"` 이 있어 `.js` 는 Node 판·환경에 따라 ESM 으로 읽힌다. 그러면 도우미의
`require` 가 「require is not defined in ES module scope」로 죽는다(보조 창 폴더에서 실제로
9건이 그렇게 실패했고, 같은 파일이 이 폴더에서는 통과해 **환경 차이**임이 드러났다).
`.cjs` 는 `type` 과 무관하게 언제나 CommonJS 라 어느 폴더·어느 Node 에서도 같게 돈다.
`_Corridor_Cut.ts`를 트랜스파일해 합성 리본에 돌리고, pytest가 결과를 판정한다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b05_cut_merged_loop.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_rect_every_masked_cell_inside_loop(results):
"""도려낸 셀은 전부 병합 고리 안 — 시작 모서리 쐐기가 빠지면 여기서 걸린다."""
r = results["rect"]
assert r["masked"] > 0
assert r["uncovered"] == [], r["uncovered"]
assert r["covered"] == r["masked"]
def test_rect_loop_keeps_four_corners_and_closure(results):
"""직사각형 마스크 → 모서리 4 + 닫힘점 1. 종전에는 머리 모서리가 빠져 3+1이었다."""
loops = results["rect"]["loops"]
assert len(loops) == 1
assert loops[0]["closed"] is True
assert loops[0]["n"] == 5, loops[0]["points"]
def test_single_cell_loop_intact(results):
"""한 셀짜리 고리도 4모서리 유지."""
r = results["single"]
assert r["masked"] >= 1
assert r["uncovered"] == []
assert all(loop["n"] == 5 and loop["closed"] for loop in r["loops"]), r["loops"]
+62
View File
@@ -0,0 +1,62 @@
"""설계속도 축 검증 — 임도 종류 3종 · 기본 20km/h · 법정 상한 매핑.
2026-08-19 사용자 확정: 임도는 속도를 낼 수 없는 노선이라 설계속도 기본은 20이고,
간선·산불진화만 30·40을 고를 수 있다. 작업임도는 20 고정(별표2 Ⅰ.3).
종단기울기 상한 표(지식DB 설계제원_총괄 §6)는 설계속도 × 지형 구분으로 정해진다.
"""
import pytest
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct, resolve_design_speed
from config.config_system import FOREST_ROAD_PROFILE_CRITERIA, ROUTE_GRADE_CLASSES
def test_road_types_are_current_three_plus_legacy():
"""현행 3종(간선·산불진화·작업) + 폐지된 지선(기존 저장분 호환)."""
assert set(ROUTE_GRADE_CLASSES) == {"trunk", "fire", "work", "branch"}
@pytest.mark.parametrize("grade_class", ["trunk", "fire", "work", "branch"])
def test_default_design_speed_is_20(grade_class):
"""설계속도를 고르지 않으면 어느 종류든 20km/h가 기본이다."""
assert resolve_design_speed(grade_class) == 20
def test_selectable_speeds_by_road_type():
"""간선·산불진화는 20~40, 작업임도는 20만 고를 수 있다."""
selectable = FOREST_ROAD_PROFILE_CRITERIA["selectable_design_speeds"]
assert selectable["trunk"] == (20, 30, 40)
assert selectable["fire"] == (20, 30, 40)
assert selectable["work"] == (20,)
def test_selected_speed_wins_when_allowed():
"""허용된 속도를 고르면 그 값이 쓰이고, 허용 밖이면 기본으로 돌아간다."""
assert resolve_design_speed("trunk", 40) == 40
assert resolve_design_speed("fire", 30) == 30
# 작업임도는 20만 허용 — 40을 보내도 20으로 되돌린다.
assert resolve_design_speed("work", 40) == 20
# 표에 없는 값도 기본으로.
assert resolve_design_speed("trunk", 25) == 20
@pytest.mark.parametrize(
("speed", "normal", "special"),
[(40, 7.0, 10.0), (30, 8.0, 12.0), (20, 9.0, 14.0)],
)
def test_grade_limit_follows_design_speed(speed, normal, special):
"""종단기울기 상한 = 설계속도 × 지형(지식DB 설계제원_총괄 §6 표와 동일)."""
assert legal_grade_limit_pct("trunk", "normal", False, speed) == normal
assert legal_grade_limit_pct("trunk", "special", False, speed) == special
def test_work_road_ignores_high_speed_and_uses_20_limits():
"""작업임도는 40을 보내도 20km/h 기준(9%/14%)을 쓴다."""
assert legal_grade_limit_pct("work", "normal", False, 40) == 9.0
assert legal_grade_limit_pct("work", "special", False, 40) == 14.0
def test_paved_special_terrain_exception_still_applies():
"""특수지형 + 노면포장은 18% 예외 상한(별표2) — 설계속도와 무관하게 유지."""
assert legal_grade_limit_pct("trunk", "special", True, 40) == 18.0
assert legal_grade_limit_pct("work", "special", True, 20) == 18.0
@@ -0,0 +1,135 @@
"""배수 추천 구조물·관경과 자동 배치 주입 (PLAN 2026-08-17 「4」).
추천 근거는 **유량(유효직경)뿐**이다 — 계곡 횡단경사·하천 차수는 지형 계산이 필요해
이번 범위에서 뺐고 화면이 "현장 확인"으로 안내한다(사용자 확정).
D ≤ 1,500㎜ 배관 (레지스트리 선택지로 스냅, 하한 800㎜)
1,500 < D ≤ 2,000 BOX암거 후보
D > 2,000㎜ 세월교·물넘이 검토
"""
import pytest
from B04_PreProcess.B04_PreProcess_Router_Basins import _apply_recommendations
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
from common_util.common_util_drainage_detail import (
DrainageDetail,
WatershedBasin,
recommend_structure,
)
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_PIPE,
PIPE_SOURCE_STREAM,
PIPE_SOURCE_USER,
PipePoint,
)
from config.config_system import DRAINAGE_RECOMMEND_DIAMETERS_MM
# ── 추천 판정 ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"diameter_mm, expected",
[
(None, (PIPE_FACILITY_PIPE, None)), # 강우량표 없음 — 관경 미정
(300.0, (PIPE_FACILITY_PIPE, 800)), # 하한 800㎜ 아래로는 안 내린다
(800.0, (PIPE_FACILITY_PIPE, 800)),
(800.1, (PIPE_FACILITY_PIPE, 1000)), # 규격을 넘으면 바로 위 규격
(1000.0, (PIPE_FACILITY_PIPE, 1000)),
(1100.0, (PIPE_FACILITY_PIPE, 1200)),
(1500.0, (PIPE_FACILITY_PIPE, 1500)), # 경계값은 아직 배관
(1500.1, (PIPE_FACILITY_BOX, None)), # 초과 → BOX암거 후보
(2000.0, (PIPE_FACILITY_BOX, None)), # 경계값은 아직 BOX암거
(2000.1, (PIPE_FACILITY_FORD_BRIDGE, None)), # 관 최대 규격 초과
],
)
def test_recommend_structure_thresholds(diameter_mm, expected):
assert recommend_structure(diameter_mm) == expected
def test_recommended_sizes_match_registry_choices():
"""추천 관경은 폼에서 고를 수 있어야 한다 — 레지스트리 선택지와 같은 목록."""
by_id = {item.type_id: item for item in load_structure_types()}
options = {option.key: option for option in by_id["pipe"].options}
choices = [int(value) for value in options["pipe_diameter_mm"].choices]
assert list(DRAINAGE_RECOMMEND_DIAMETERS_MM) == choices
# ── 자동 배치 주입 ─────────────────────────────────────────────────────────
def _detail(*basins: WatershedBasin) -> DrainageDetail:
detail = DrainageDetail.__new__(DrainageDetail)
detail.basins = list(basins)
return detail
def _basin(chainage: float, facility: str, diameter: int | None) -> WatershedBasin:
return WatershedBasin(
index=1,
chainage_m=chainage,
outlet_x=0.0,
outlet_y=0.0,
recommended_facility=facility,
recommended_diameter_mm=diameter,
)
def test_auto_pipe_gets_recommended_diameter():
"""자동 배치 관은 폼을 열지 않아도 유역 유량에 맞는 관경을 갖는다."""
points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_STREAM)]
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1200)), points)
assert points[0].options == {"pipe_diameter_mm": 1200}
assert points[0].facility == PIPE_FACILITY_PIPE
def test_auto_point_switches_facility_to_box():
"""추천이 BOX암거면 시설 종류까지 바꾼다(관경은 붙이지 않는다)."""
points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_STREAM)]
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points)
assert points[0].facility == PIPE_FACILITY_BOX
assert points[0].options is None
def test_user_placed_point_is_never_overwritten():
"""사용자가 직접 놓거나 옮긴 관은 재계산이 건드리지 않는다."""
points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_USER)]
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points)
assert points[0].facility == PIPE_FACILITY_PIPE
assert points[0].options is None
def test_existing_option_is_kept():
"""설계자가 고른 관경을 추천이 되돌리면 안 된다 — 빈칸만 채운다."""
points = [
PipePoint(
chainage_m=100.0,
source=PIPE_SOURCE_STREAM,
options={"pipe_diameter_mm": 800},
)
]
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1500)), points)
assert points[0].options == {"pipe_diameter_mm": 800}
def test_already_chosen_facility_is_kept():
"""이미 다른 시설로 바꿔 둔 지점은 추천이 되돌리지 않는다."""
points = [
PipePoint(
chainage_m=100.0,
source=PIPE_SOURCE_STREAM,
facility=PIPE_FACILITY_FORD_BRIDGE,
)
]
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points)
assert points[0].facility == PIPE_FACILITY_FORD_BRIDGE
def test_point_without_basin_is_untouched():
"""담당 유역이 없는 관(유역 미산출)은 그대로 둔다."""
points = [PipePoint(chainage_m=500.0, source=PIPE_SOURCE_STREAM)]
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1200)), points)
assert points[0].options is None
@@ -0,0 +1,301 @@
"""구조물(비정규) 측점의 단일 공급자 — 2026-08-28 [초기화] 후 측점 실종 건.
배경: 측점 정본은 파일(longitudinal.json stations + cross_*.json)인데, 그 파일을 쓰는
`run_section_generation`에는 공급자가 없었다. 유일한 공급자가 프론트 [임시저장]이라
초기화·반폭 재생성이 파일을 다시 쓸 때마다 구조물 측점이 통째로 사라졌다(실측: 초기화
직후 cross 23 → 19, irregular 4 → 0).
"""
import json
import numpy as np
import pytest
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
cross_filename,
resolve_extra_stations,
run_section_generation,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature
from common_util.common_util_route_geometry import RouteVertex
# 실측 정본(프로젝트 c1bb453f) 그대로 — 라벨 문자열이 종단 정본과 글자까지 같아야 한다.
LIVE_POINTS = [
{
"chainage_m": 84.3,
"source": "spacing",
"options": {"pipe_kind": "파형강관", "pipe_diameter_mm": 1000},
},
{"chainage_m": 149.73, "source": "stream", "facility": "ford_bridge"},
{"chainage_m": 200.92, "source": "spacing", "facility": "box_culvert"},
{"chainage_m": 275.71, "source": "spacing", "options": {"pipe_diameter_mm": 800}},
]
LIVE_LABELS = (
(84.3, "파형강관 D1000"),
(149.73, "세월교"),
(200.92, "BOX암거"),
(275.71, "파형강관 D800"),
)
STRAIGHT_LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]]
class _FlatSampler:
"""표고 고정 스텁 — 측점 구성만 보는 테스트라 지형은 상수로 둔다."""
def sample_xy(self, xy):
count = len(np.asarray(xy, dtype=np.float64))
return np.full(count, 100.0), np.ones(count, dtype=bool)
def _write_pipe_points(root, points, polyline=STRAIGHT_LINE, signature=None):
edits = root / "B04_PreProcess" / "drainage" / "edits"
edits.mkdir(parents=True, exist_ok=True)
vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in polyline]
(edits / "pipe_points.json").write_text(
json.dumps(
{"route_signature": signature or route_signature(vertices), "points": points},
ensure_ascii=False,
),
encoding="utf-8",
)
def _write_structures(root, structures):
route_dir = root / "B05_Profile" / "route"
route_dir.mkdir(parents=True, exist_ok=True)
(route_dir / "structures.json").write_text(
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
encoding="utf-8",
)
# ── 라벨 파생 ──────────────────────────────────────────────────────────────
def test_pipe_points_become_labelled_stations(tmp_path):
assert resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) == LIVE_LABELS
def test_pipe_without_options_falls_back_to_config_defaults(tmp_path):
extras = resolve_extra_stations(tmp_path, parse_pipe_points([{"chainage_m": 30.0}]))
assert extras == ((30.0, "파형강관 D800"),)
def test_ford_pavement_is_not_an_irregular_station(tmp_path):
"""물넘이포장은 비정규 측점이 아니다 — 종단 그래프 구조물 표시 몫(2026-08-28 사용자 확정)."""
points = [{"chainage_m": 40.0, "facility": "ford_pavement"}, {"chainage_m": 60.0}]
extras = resolve_extra_stations(tmp_path, parse_pipe_points(points))
assert [chainage for chainage, _label in extras] == [60.0]
# ── 구조물 정본 A군 합류 ───────────────────────────────────────────────────
def test_group_a_point_and_revetment_facility_join(tmp_path):
"""A군 점형은 기준 한 곳, 기슭막이(관 시설)는 시작·기준·종료 세 곳(2026-08-28).
기슭막이는 구조물 정본에서 관 지점 정본으로 옮겨갔다(`managed_by: pipe_points`) —
구간형 구조물이 아니라 관 시설 목록에서 온다.
"""
_write_structures(
tmp_path,
[{"type_id": "cross_drain_exposed", "chainage_m": 60.0, "placement": "point"}],
)
points = [
*LIVE_POINTS,
{
"chainage_m": 100.0,
"source": "user",
"facility": "revetment",
"start_m": 95.0,
"end_m": 115.0,
},
]
extras = resolve_extra_stations(tmp_path, parse_pipe_points(points))
assert (60.0, "노출형 횡단수로") in extras
assert [chainage for chainage, label in extras if label == "기슭막이"] == [95.0, 100.0, 115.0]
assert [chainage for chainage, _label in extras] == [
60.0,
84.3,
95.0,
100.0,
115.0,
149.73,
200.92,
275.71,
]
def test_other_groups_do_not_plant_stations(tmp_path):
"""C·E·G군은 측점을 만들지 않는다 — 지금 켠 것은 A군과 D군 구간형뿐이다."""
_write_structures(
tmp_path,
[
{
"type_id": "retaining_wall",
"chainage_m": 40.0,
"start_m": 40.0,
"end_m": 60.0,
"placement": "interval",
},
{
"type_id": "pavement_concrete",
"chainage_m": 300.0,
"start_m": 295.0,
"end_m": 305.0,
"placement": "interval",
},
],
)
extras = resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS))
assert [chainage for chainage, _label in extras] == [84.3, 149.73, 200.92, 275.71]
def test_broken_structures_file_keeps_pipe_derived_stations(tmp_path):
route_dir = tmp_path / "B05_Profile" / "route"
route_dir.mkdir(parents=True, exist_ok=True)
(route_dir / "structures.json").write_text("{not json", encoding="utf-8")
assert resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) == LIVE_LABELS
# ── 관 정본 로드·지문 게이트 ───────────────────────────────────────────────
def test_missing_pipe_points_file_is_empty_not_error(tmp_path):
assert _load_pipe_points(tmp_path, STRAIGHT_LINE) == []
def test_route_signature_mismatch_drops_points_with_warning(tmp_path, caplog):
"""지문이 다르고 좌표도 없는 구 저장분만 버린다 — 침묵하지 않고 경고를 남긴다.
좌표가 있으면 그 선에 투영해 이월한다(2026-08-30). LIVE_POINTS에는 좌표가 없다.
"""
_write_pipe_points(tmp_path, LIVE_POINTS, signature="옛노선-0000")
with caplog.at_level("WARNING"):
assert _load_pipe_points(tmp_path, STRAIGHT_LINE) == []
assert any("좌표 없는 구 저장분" in record.getMessage() for record in caplog.records)
def test_matching_signature_loads_points(tmp_path):
_write_pipe_points(tmp_path, LIVE_POINTS)
assert [pipe.chainage_m for pipe in _load_pipe_points(tmp_path, STRAIGHT_LINE)] == [
84.3,
149.73,
200.92,
275.71,
]
# ── 횡단 파일명 충돌 스냅(격자 우선 불변식) ────────────────────────────────
def _stations(extra_stations):
result = generate_sections(
STRAIGHT_LINE,
_FlatSampler(),
SectionGenerationOptions(station_interval_m=20.0, extra_stations=extra_stations),
)
return result["longitudinal"]["stations"]
def test_extra_station_within_one_meter_snaps_to_grid():
stations = _stations(((100.4, "X"),))
assert 100.4 not in [station["chainage_m"] for station in stations]
labelled = [station for station in stations if station.get("structure") == "X"]
assert [station["chainage_m"] for station in labelled] == [100.0]
assert labelled[0]["kind"] == "regular" # 격자 측점 우선 — 기존 불변식
def test_two_extras_in_same_meter_bucket_keep_one():
stations = _stations(((80.2, "A"), (80.4, "B")))
assert [station["chainage_m"] for station in stations if station.get("structure")] == [80.0]
def test_live_extras_stay_irregular_and_filenames_stay_unique():
stations = _stations(LIVE_LABELS)
irregular = [station for station in stations if station["kind"] == "irregular"]
assert [station["chainage_m"] for station in irregular] == [84.3, 149.73, 200.92, 275.71]
assert [station["structure"] for station in irregular] == [
label for _chainage, label in LIVE_LABELS
]
assert len({cross_filename(station["chainage_m"]) for station in stations}) == len(stations)
# ── 단일 공급자 계약 (run_section_generation) ──────────────────────────────
@pytest.fixture
def project(tmp_path, monkeypatch):
route_dir = tmp_path / "B05_Profile" / "route"
route_dir.mkdir(parents=True, exist_ok=True)
(route_dir / "route_main.geojson").write_text(
json.dumps({"geometry": {"type": "LineString", "coordinates": STRAIGHT_LINE}}),
encoding="utf-8",
)
monkeypatch.setattr(
"B05_Profile.B05_Profile_Engine_Sections.build_surface_sampler",
lambda *args, **kwargs: _FlatSampler(),
)
return tmp_path
def _generate(project_root, options=None):
return run_section_generation(
project_root,
"B05_Profile/route/route_main.geojson",
"csf",
"dtm",
True,
options=options or SectionGenerationOptions(station_interval_m=20.0),
)
def _saved_stations(project_root):
path = project_root / "B06_Section" / "longitudinal" / "longitudinal.json"
return json.loads(path.read_text(encoding="utf-8"))["stations"]
def test_run_section_generation_derives_stations_from_truth_files(project):
_write_pipe_points(project, LIVE_POINTS)
result = _generate(project)
stations = _saved_stations(project)
irregular = [station for station in stations if station["kind"] == "irregular"]
assert [station["chainage_m"] for station in irregular] == [84.3, 149.73, 200.92, 275.71]
assert [station["structure"] for station in irregular] == [
label for _chainage, label in LIVE_LABELS
]
cross_dir = project / "B06_Section" / "cross_sections"
for name in ("cross_00084m", "cross_00150m", "cross_00201m", "cross_00276m"):
assert (cross_dir / f"{name}.json").is_file()
# 파일 = DB 행 — 종전에는 파일 23 vs DB 19로 갈렸다.
assert len(result["cross_sections"]) == len(stations)
def test_caller_supplied_extra_stations_are_ignored(project):
"""호출자 값을 되먹이면 B04에서 지운 관이 스냅샷을 타고 부활한다 — 정본 파일만 쓴다."""
_write_pipe_points(project, LIVE_POINTS)
_generate(
project,
SectionGenerationOptions(station_interval_m=20.0, extra_stations=((10.0, "가짜"),)),
)
stations = _saved_stations(project)
assert all(station.get("structure") != "가짜" for station in stations)
assert [station["chainage_m"] for station in stations if station["kind"] == "irregular"] == [
84.3,
149.73,
200.92,
275.71,
]
def test_without_pipe_points_only_grid_stations(project):
_generate(project)
assert all(station["kind"] != "irregular" for station in _saved_stations(project))
@@ -0,0 +1,68 @@
"""평면(지도)에 **구간형 구조물 띠**가 서는지 — 계획서 3-6 마지막 항목.
왜 (2026-09-07 사용자 지시) — 산마루측구·도수로·옹벽처럼 **구간**으로 놓이는 시설이 종단에는
띠로 보이는데 **평면에는 아무 표시가 없었다**. 계획서에는 「노선 위에 임의 구간을 얹는 부품이
없어 새로 짜야 함」으로 남아 있었다.
새로 짠 것은 작다 — 계획선 표본(`strengthSamples`)이 **1m 간격이라 배열 인덱스가 곧
누가거리**여서, 구간 → 화면 선은 그 토막을 잘라 굵게 긋기만 하면 된다.
이 시험이 지키는 것 — ① 띠를 그리는 부품이 있고 ② 배수유역도가 그것을 계획선 **위**,
강도 색칠 **아래**에 그리며 ③ 띠 자료는 **종단과 같은 자료**(구조물 정본 + 타입 레지스트리)에서
나오고 ④ 구간이 없는 점형 시설은 빠지는 것.
"""
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
_B05 = PROJECT_ROOT / "B05_Profile"
_SPANS = _B05 / "B05_Profile_UI_Drainage_Spans.ts"
_RENDER = _B05 / "B05_Profile_UI_Drainage_Render.ts"
_PANEL = _B05 / "B05_Profile_UI_Drainage_Panel.ts"
_PROFILE = _B05 / "B05_Profile_UI_Profile_Panel.ts"
def test_띠를_그리는_부품이_있다():
source = _SPANS.read_text(encoding="utf-8")
assert "export function drawRouteSpans" in source
# 인덱스 = 누가거리(m) 라는 전제가 코드에 남아 있어야 한다 — 표본 간격이 바뀌면 깨진다.
assert "Math.floor(Math.min(span.startM, span.endM))" in source
def test_범위_밖_구간도_안전하게_잘린다():
"""저장분에 노선보다 긴 구간이 남아 있어도 그리다 죽지 않아야 한다."""
source = _SPANS.read_text(encoding="utf-8")
assert "Math.max(0, Math.min(last," in source
assert "Math.max(from + 1," in source, "시작=끝인 구간이 사라지지 않게 한 칸을 준다"
def test_계획선_위_강도색칠_아래에_그린다():
"""쌓는 순서가 뒤집히면 띠가 계획선을 덮거나 마커에 가린다."""
render = _RENDER.read_text(encoding="utf-8")
assert "drawRouteSpans(context, scene.strengthSamples" in render
# 호출 자리끼리 견준다 — 파일 머리의 import 는 순서 판정에 쓰면 안 된다.
order_route = render.index("drawPreparedLayer(context, scene.routeLayer")
order_span = render.index("drawRouteSpans(context")
order_strength = render.index("drawStrengthLine(context")
assert order_route < order_span < order_strength
def test_종단과_같은_자료에서_뽑는다():
"""색은 레지스트리 표시색, 구간은 구조물 정본 — 새 정본을 만들지 않는다."""
spans = _SPANS.read_text(encoding="utf-8")
assert "export function routeSpansFromStructures" in spans
assert 'item.placement !== "interval"' in spans, "구간형만 띠로 그린다"
assert "type.style?.color" in spans, "색을 새로 정하지 않고 레지스트리 값을 쓴다"
profile = _PROFILE.read_text(encoding="utf-8")
assert "drainagePanel.setIntervalSpans(routeSpansFromStructures(" in profile
def test_목록이_바뀌면_다시_그린다():
"""구조물을 넣거나 빼면 띠도 따라와야 한다 — 안 그러면 옛 띠가 남는다."""
profile = _PROFILE.read_text(encoding="utf-8")
body = profile[profile.index("setStructures(next: StructureInstance[])") :][:400]
assert "syncDrainageSpans()" in body
types_body = profile[profile.index("setStructureTypes(types: StructureType[])") :][:300]
assert "syncDrainageSpans()" in types_body
panel = _PANEL.read_text(encoding="utf-8")
assert "setIntervalSpans(spans) {" in panel and "scheduleDraw();" in panel
@@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""종단 편집 최소고 가드 개편(2026-08-23) 검증.
기존 가드는 측점 ▼에만 걸리고 관경 고정 산식(지반+관경+토피)이라 ① 구간 쉬프트가
횡단배수 앵커를 최소고 아래로 끌어내렸고 ② BOX암거·세월교를 과소, 물넘이포장을
과잉 차단했다. 개편: 편집 후보로 정렬을 계산해 시설별 최소고(minCoverPoints 원천)
위반이 **새로 생기거나 커지면** 차단 — 측점·구간·곡선 경유 하강을 한 가드로 잡는다.
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
RENDER = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Render.ts").read_text(
encoding="utf-8"
)
PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text(
encoding="utf-8"
)
GUARD = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text(
encoding="utf-8"
)
def _violations(targets, ground_at, plan_at, tolerance=0.001):
"""TS findMinCoverViolations와 같은 규칙."""
result = []
for chainage, clearance in targets:
required = ground_at(chainage) + clearance
planned = plan_at(chainage)
if planned < required - tolerance:
result.append((chainage, required - planned))
return result
def _blocks(targets, ground_at, plan_now, plan_next):
"""TS blocksMinCover와 같은 규칙 — 위반이 새로 생기거나 커지면 True."""
planned = _violations(targets, ground_at, plan_next)
if not planned:
return False
current = dict(_violations(targets, ground_at, plan_now))
return any(short > current.get(chainage, 0) + 1e-6 for chainage, short in planned)
TARGETS = [(40.0, 2.5)] # BOX암거 H2.0 → 지반 +2.5
GROUND = lambda c: 100.0 # noqa: E731
def test_blocks_new_violation():
"""최소고 위(102.6)에서 아래(102.4)로 내리는 편집은 차단된다."""
assert _blocks(TARGETS, GROUND, lambda c: 102.6, lambda c: 102.4)
def test_allows_edit_down_to_exact_minimum():
"""정확히 최소고(102.5)까지는 허용 — 한계에 앉히는 편집을 막지 않는다."""
assert not _blocks(TARGETS, GROUND, lambda c: 102.6, lambda c: 102.5)
def test_allows_recovery_when_already_violating():
"""이미 위반(102.0)이면 악화가 아닌 한 허용 — 복구(올림) 편집을 막으면 안 된다."""
assert not _blocks(TARGETS, GROUND, lambda c: 102.0, lambda c: 102.3)
assert _blocks(TARGETS, GROUND, lambda c: 102.0, lambda c: 101.9) # 악화는 차단.
def test_unlisted_station_is_free():
"""대상 목록에 없는 자리(물넘이포장 등)는 어떤 편집도 막지 않는다."""
assert not _blocks([], GROUND, lambda c: 102.6, lambda c: 90.0)
def test_source_guard_sits_on_the_single_apply_path():
"""가드가 **모든 편집이 지나는 한 곳**(`applyEdits`)에 걸려 있는지 소스 검사.
2026-09-02: 측점 끌기(`_Profile_Render`)에만 걸려 있어 [직선화]·[쉬프트]·틸팅·
방향키가 그냥 지나갔다. 판정 로직을 `_Profile_MinCover.blocksMinCover` 로 옮기고
`_Profile_Panel.applyEdits` 한 곳에서만 부른다 — 화면 쪽에는 사본을 두지 않는다.
"""
assert (
"if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;"
in PANEL
)
assert PANEL.count("blocksMinCover(") == 1 # 부르는 자리는 한 곳뿐.
assert "export function blocksMinCover(" in GUARD
assert "blocksMinCover" not in RENDER # 화면 모듈에 사본이 남지 않았다.
# 판정점 = 제어점 z(라운드 중심) — 이웃 틸팅이 잠기지 않는다(2026-08-23).
assert GUARD.count("controlElevationAt(") >= 2
assert "planElevationAt(candidate" not in GUARD
def test_source_old_diameter_formula_removed():
"""관경 고정 산식·배관 판정이 사라지고 시설별 산식(minCoverPoints 원천)을 쓴다."""
assert "blocksPipeLowering" not in RENDER
assert "MIN_PIPE_COVER_M" not in RENDER
assert "diameterMm ?? 1000" not in RENDER
assert "findMinCoverViolations" in GUARD
assert "minCoverTargets: () => minCoverTargets" in PANEL
ALIGNMENT = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts").read_text(
encoding="utf-8"
)
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)
@@ -0,0 +1,74 @@
"""B05 횡단배수 최소 계획고 규칙 — TS 산식이 지식DB·엔진 상수와 맞는지 대조.
TS 구현(`B05_Profile_UI_Profile_MinCover.ts`)의 산식은
배수관: 지반고 + 관경(m) + 토피 0.5
BOX암거: 지반고 + 구체 높이(m) + 토피 0.5
이다(2026-08-23 사용자 확정). 여기서는 그 상수·기본값이 코드에 그대로 있는지와
B06 배수관 엔진의 토피 상수와 같은 값인지 확인한다 — 두 화면이 다른 토피를 쓰면
종단에서 통과한 계획선이 횡단에서 성립하지 않는다.
"""
import re
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Culvert import MIN_PIPE_COVER_M # noqa: E402
from common_util.common_util_drainage_pipes import facility_clearance_m # noqa: E402
SOURCE = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text(
encoding="utf-8"
)
def _const(name: str) -> float:
match = re.search(rf"{name}\s*=\s*([\d.]+)", SOURCE)
assert match, f"{name} 상수를 찾지 못했습니다"
return float(match.group(1))
def test_cover_matches_culvert_engine():
"""토피 0.5m는 B06 배수관 엔진과 같은 값이어야 한다(두 화면 일관성)."""
assert _const("MIN_COVER_M") == MIN_PIPE_COVER_M == 0.5
def test_default_sizes_match_user_examples():
"""사용자 확정 예시 기본값 — 배수관 Ø1000, BOX 2.0m."""
assert _const("DEFAULT_PIPE_DIAMETER_MM") == 1000
assert _const("DEFAULT_BOX_HEIGHT_M") == 2
def test_clearance_examples():
"""예시 그대로: Ø1000 → +1.5m, BOX 2.0×2.0 → +2.5m."""
cover = _const("MIN_COVER_M")
assert _const("DEFAULT_PIPE_DIAMETER_MM") / 1000 + cover == 1.5
assert _const("DEFAULT_BOX_HEIGHT_M") + cover == 2.5
def test_ford_bridge_is_pipe_plus_extra():
"""세월교 = 배관 다발 — 배수관 산식 + 물넘이 몫 0.5m (2026-08-23 사용자 확정)."""
assert facility_clearance_m("ford_bridge", {}) == 1.0 + MIN_PIPE_COVER_M + 0.5 == 2.0
assert facility_clearance_m("ford_bridge", {"pipe_diameter_mm": 800}) == 0.8 + 0.5 + 0.5
def test_ford_pavement_needs_no_clearance():
"""물넘이포장은 도로에 그대로 만든다 — 요구 여유 0."""
assert facility_clearance_m("ford_pavement", {}) == 0.0
assert facility_clearance_m("ford_pavement", {"ford_height_m": 0.4}) == 0.0
def test_backend_is_the_source_of_truth():
"""화면 사본과 백엔드 정본 산식이 같은 값을 내야 한다(두 화면 일관성)."""
assert facility_clearance_m("pipe", {}) == _const("DEFAULT_PIPE_DIAMETER_MM") / 1000 + _const(
"MIN_COVER_M"
)
assert facility_clearance_m("box_culvert", {}) == _const("DEFAULT_BOX_HEIGHT_M") + _const(
"MIN_COVER_M"
)
assert facility_clearance_m("ford_bridge", {}) == _const(
"DEFAULT_PIPE_DIAMETER_MM"
) / 1000 + _const("MIN_COVER_M") + _const("FORD_BRIDGE_EXTRA_M")
assert facility_clearance_m("pipe", {"pipe_diameter_mm": 600}) == 0.6 + MIN_PIPE_COVER_M
+68
View File
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""B05 패치 스커트(`B05_Profile_UI_Corridor_Skirt.ts`) 단위검증 — 2026-08-27.
프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_b05_patch_skirt.cjs)가 TS를
⚠ 도우미 확장자가 **`.cjs`** 인 이유(2026-09-07) — 루트 `package.json` 에
`"type": "module"` 이 있어 `.js` 는 Node 판·환경에 따라 ESM 으로 읽힌다. 그러면 도우미의
`require` 가 「require is not defined in ES module scope」로 죽는다(보조 창 폴더에서 실제로
9건이 그렇게 실패했고, 같은 파일이 이 폴더에서는 통과해 **환경 차이**임이 드러났다).
`.cjs` 는 `type` 과 무관하게 언제나 CommonJS 라 어느 폴더·어느 Node 에서도 같게 돈다.
트랜스파일해 실행하고, pytest는 그 결과(JSON)를 검증한다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def skirt_results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b05_patch_skirt.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_floating_edge_builds_hatched_skirt(skirt_results):
"""뜬 모서리(지반 위 0.5m)는 마디마다 판 2장 + 빗금 UV + patchSkirt 표식."""
r = skirt_results["floating"]
assert r["walls"] == 1
assert r["triangles"] == 4 # 마디 2개 × 2장
assert r["hatched"] is True
assert r["marked"] is True
def test_submerged_edge_builds_nothing(skirt_results):
"""잠긴 모서리(지반 아래)는 판 없음 — 지형이 덮는다."""
r = skirt_results["submerged"]
assert r["walls"] == 0
assert r["skipped"] == 2
def test_structure_ending_run_excluded(skirt_results):
"""소유 측점 행 낙차 2m > 허용 0.5m — 벽 뒷면·구체 상단에서 끝나는 줄은 리본째 제외."""
r = skirt_results["toeGate"]
assert r["walls"] == 0
assert r["reaches"] is False
def test_tall_segment_skipped(skirt_results):
"""게이트 통과 후에도 낙차 3m 초과 마디는 건너뛴다(지느러미 방지)."""
r = skirt_results["tall"]
assert r["skippedTall"] == 2 # 94를 낀 두 마디
assert r["tris"] == 2 # 남은 정상 마디 1개 × 2장
assert r["walls"] == 1
def test_non_patch_and_cut_ignored(skirt_results):
"""patch 표식 없는 리본·절토 리본은 스커트 대상이 아니다."""
assert skirt_results["ignored"]["walls"] == 0
@@ -0,0 +1,168 @@
"""B05 횡단배수 최소고 강제 스위치(2026-09-01 사용자 지시).
배수 자리가 종단 계획고를 잡아당겨 제어가 어렵다는 지적에 따라, 최소고 적용을
`enforce_pipe_clearance` 하나로 여닫게 했다. **기본은 해제**다. 여기서는
① 기본이 해제인지
② 요청·저장값이 스위치를 켜는지
③ 켜고 끔에 따라 계획선 표고가 실제로 달라지는지 (산식은 그대로)
④ 화면(TS) 편집 가드도 같은 스위치를 보는지
를 확인한다. 산식 자체(관경+토피 등)는 `test_b05_min_cover_rules.py`가 잠근다.
"""
import sys
from pathlib import Path
import numpy as np
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_pipe_anchored_profile,
)
from B05_Profile.B05_Profile_Schema import RouteSolveRequest # noqa: E402
def _options(**kwargs):
return resolve_grade_options("trunk", **kwargs)
# ── ① 기본 해제 ──────────────────────────────────────────────────────────────
def test_default_is_released():
"""기본은 해제 — 자동 전처리가 배수 자리마다 계획고를 들어 올리지 않는다."""
assert _options().enforce_pipe_clearance is False
def test_as_dict_carries_the_switch():
"""저장·복원 경로(criteria/grade_options)에 스위치가 실린다."""
assert _options().as_dict()["enforce_pipe_clearance"] is False
assert (
_options(requested={"enforce_pipe_clearance": True}).as_dict()["enforce_pipe_clearance"]
is True
)
# ── ② 요청 → 저장 → 기본 순서 ────────────────────────────────────────────────
def test_requested_turns_it_on():
assert _options(requested={"enforce_pipe_clearance": True}).enforce_pipe_clearance is True
def test_stored_is_used_when_request_is_silent():
"""재생성처럼 요청이 비어 오는 경로는 확정 당시 저장값을 따른다."""
assert _options(stored={"enforce_pipe_clearance": True}).enforce_pipe_clearance is True
def test_request_wins_over_stored():
"""사용자가 방금 끈 것이 옛 저장값을 이긴다."""
resolved = _options(
requested={"enforce_pipe_clearance": False},
stored={"enforce_pipe_clearance": True},
)
assert resolved.enforce_pipe_clearance is False
def test_schema_defaults_to_none_and_ships_the_flag():
"""스키마 기본은 None(미지정) — 저장값을 덮어쓰지 않는다."""
request = RouteSolveRequest(
filter_key="f",
bp={"x": 0.0, "y": 0.0},
ep={"x": 100.0, "y": 0.0},
)
assert request.enforce_pipe_clearance is None
assert "enforce_pipe_clearance" in request.grade_options()
# ── ③ 계획선 표고가 실제로 달라진다 ──────────────────────────────────────────
def _flat_longitudinal(length_m: float = 400.0, step: float = 20.0) -> dict:
"""지반고가 일정한 종단 — 최소고 적용 여부만 표고 차이로 드러난다."""
chainages = np.arange(0.0, length_m + step, step)
return {
"length_m": float(length_m),
"samples": [
{"chainage_m": float(c), "elevation_m": 100.0, "valid": True} for c in chainages
],
"stations": [
{"chainage_m": float(c), "kind": "regular", "elevation_m": 100.0} for c in chainages
],
}
def _elevation_at(profile: dict, chainage_m: float) -> float:
samples = profile["samples"]
xs = [float(s["chainage_m"]) for s in samples]
ys = [float(s["elevation_m"]) for s in samples]
return float(np.interp(chainage_m, xs, ys))
@pytest.mark.parametrize("anchor_m", [200.0])
def test_released_profile_stays_on_the_ground(anchor_m):
"""해제(기본): 배관 자리 계획고가 지반고를 그대로 통과한다."""
_, entry = design_pipe_anchored_profile(
_flat_longitudinal(),
_options(),
[anchor_m],
station_interval_m=20.0,
pipe_clearances=None,
)
assert _elevation_at(entry, anchor_m) == pytest.approx(100.0, abs=0.05)
@pytest.mark.parametrize("anchor_m,clearance_m", [(200.0, 1.5)])
def test_enforced_profile_is_lifted_by_the_clearance(anchor_m, clearance_m):
"""강제: 배관 자리 계획고가 시설 여유만큼 들린다(Ø1000 → +1.5m)."""
_, entry = design_pipe_anchored_profile(
_flat_longitudinal(),
_options(requested={"enforce_pipe_clearance": True}),
[anchor_m],
station_interval_m=20.0,
pipe_clearances={anchor_m: clearance_m},
)
assert _elevation_at(entry, anchor_m) == pytest.approx(100.0 + clearance_m, abs=0.05)
def test_criteria_round_trip_keeps_the_switch():
"""계획선에 기록된 criteria로 스위치가 되살아난다(재편집 경로)."""
_, entry = design_pipe_anchored_profile(
_flat_longitudinal(),
_options(requested={"enforce_pipe_clearance": True}),
[200.0],
station_interval_m=20.0,
pipe_clearances={200.0: 1.5},
)
assert entry["criteria"]["enforce_pipe_clearance"] is True
# ── ④ 화면 가드도 같은 스위치를 본다 ─────────────────────────────────────────
RENDER_SOURCE = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Render.ts").read_text(
encoding="utf-8"
)
SECTIONS_SOURCE = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_Engine_Sections.py").read_text(
encoding="utf-8"
)
def test_edit_guard_is_conditional():
"""편집 차단 가드가 스위치를 먼저 본다 — 해제면 막지 않는다.
가드는 2026-09-02 에 `_Profile_MinCover.blocksMinCover` 로 옮겼다(모든 편집이
지나는 `applyEdits` 한 곳에서 부른다). 스위치는 그 인자 `enforced` 로 들어온다.
"""
guard = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text(
encoding="utf-8"
)
assert "if (!base || !alignment || !enforced || !targets.length) return false;" in guard
def test_section_generation_passes_clearances_only_when_enabled():
"""자동설계는 켜졌을 때만 여유값을 넘긴다."""
assert "grade_options.enforce_pipe_clearance" in SECTIONS_SOURCE
@@ -0,0 +1,110 @@
"""계획선 출처(`basis`) 구분과 B04 3D 화면맞춤 범위 (2026-09-02 결함 2건).
① 1차(배관 정착)와 2차(직선 분할)가 같은 `_profile_entry()` 를 쓰다 보니 저장본
`basis` 가 둘 다 `station_alignment` 로 나갔다. 사고 조사 때 "폴백으로 떨어졌다"
저장본에서 확인하지 못한 원인이다. 값을 갈랐는지 잠근다.
② B04 지표면 3D 뷰어가 `referenceBounds`(지표면 범위)만으로 카메라 거리를 정해,
라이다가 노선의 일부만 덮으면 나머지가 화면 밖으로 잘렸다(용화 실측 —
노선 2,136m 중 라이다 1,400m). 노선까지 담는 대칭 확장이 들어갔는지 잠근다.
"""
import sys
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
ALIGNMENT_BASIS,
PIPE_ANCHORED_BASIS,
design_alignment_profile,
design_pipe_anchored_profile,
rebuild_alignment_profile,
)
def _longitudinal(length_m: float = 400.0, step: float = 20.0) -> dict:
"""지반고가 완만하게 내려가는 종단 — 두 진입점 모두 계획선을 만들 수 있다."""
chainages = np.arange(0.0, length_m + step, step)
return {
"length_m": float(length_m),
"samples": [
{"chainage_m": float(c), "elevation_m": 100.0 - 0.02 * float(c), "valid": True}
for c in chainages
],
"stations": [
{"chainage_m": float(c), "kind": "regular", "elevation_m": 100.0 - 0.02 * float(c)}
for c in chainages
],
}
# ── ① 계획선 출처가 저장본에서 갈린다 ────────────────────────────────────────
def test_two_bases_are_distinct_values():
"""두 값이 같으면 저장본으로 진입점을 못 가른다."""
assert PIPE_ANCHORED_BASIS != ALIGNMENT_BASIS
def test_pipe_anchored_profile_reports_its_own_basis():
"""1차(배관 정착)는 `pipe_anchored` 로 남는다."""
_, entry = design_pipe_anchored_profile(
_longitudinal(),
resolve_grade_options("trunk"),
[200.0],
station_interval_m=20.0,
)
assert entry["basis"] == PIPE_ANCHORED_BASIS
def test_fallback_profile_keeps_the_station_basis():
"""2차(직선 분할 폴백)는 종전 값 그대로 — 옛 저장본과 뜻이 어긋나지 않는다."""
_, entry = design_alignment_profile(
_longitudinal(),
resolve_grade_options("trunk"),
station_interval_m=20.0,
)
assert entry["basis"] == ALIGNMENT_BASIS
def test_rebuild_inherits_the_stored_basis():
"""사용자 편집 재구성은 저장된 자동 선형을 그대로 쓰므로 출처도 물려받는다."""
longitudinal = _longitudinal()
alignment, entry = design_pipe_anchored_profile(
longitudinal,
resolve_grade_options("trunk"),
[200.0],
station_interval_m=20.0,
)
# 저장 경로(`B05_Profile_Engine_Sections.py:252`)와 같은 자리에 선형을 얹는다.
longitudinal["design_profiles"] = [entry]
longitudinal["profile_alignment"] = alignment
_, rebuilt = rebuild_alignment_profile(longitudinal, None)
assert rebuilt["basis"] == PIPE_ANCHORED_BASIS
# ── ② B04 3D 화면맞춤이 노선까지 담는다 ──────────────────────────────────────
VIEWER_SOURCE = (PROJECT_ROOT / "B04_PreProcess" / "B04_PreProcess_UI_TerrainViewer.ts").read_text(
encoding="utf-8"
)
def test_fit_camera_uses_route_expanded_bounds():
"""`fitCamera` 가 지표면 범위 대신 노선을 담은 범위를 쓴다."""
assert "const bounds = fitBounds();" in VIEWER_SOURCE
assert "getTopFitDistance(bounds, aspect)" in VIEWER_SOURCE
assert "getTopFitDistance(referenceBounds, aspect)" not in VIEWER_SOURCE
def test_fit_bounds_expands_symmetrically_around_the_reference_center():
"""카메라 타깃이 지표면 중심이라 한쪽만 넓히면 소용없다 — 반폭을 대칭으로 잡는다."""
assert "halfX = Math.max(halfX, Math.abs(point.x - cx))" in VIEWER_SOURCE
assert "halfY = Math.max(halfY, Math.abs(point.y - cy))" in VIEWER_SOURCE
assert "x_min: cx - halfX" in VIEWER_SOURCE
assert "x_max: cx + halfX" in VIEWER_SOURCE
@@ -0,0 +1,78 @@
"""보호공 개편(2026-08-17) 구 저장분 마이그레이션.
개편 전 유입·유출 바닥 보호는 `*_pitching`(있음/없음)과 `*_pitching_finish`(찰/메)
두 축이었다. 도수로·산비탈수로(B4)가 유출부에서 돌붙임 대신 쓰는 공종이라 세 값을
`*_protection` 한 축(돌붙임(찰)/돌붙임(메)/도수로)으로 합쳤고, "없음"은 삭제했다
(물이 흐르는 자리라 보호공은 필수 — 사용자 확정).
`parse_pipe_points`가 읽는 순간 새 키로 옮기므로 화면·수량 어느 쪽도 옛 키를 모른다.
"""
from common_util.common_util_drainage_pipes import parse_pipe_points
def _options(payload: dict) -> dict:
points = parse_pipe_points([{"chainage_m": 100.0, "options": payload}])
assert len(points) == 1
return points[0].options or {}
def test_legacy_pitching_with_finish_becomes_protection():
"""있음 + 찰/메 → 돌붙임(찰)/돌붙임(메). 면적은 새 키로 옮겨 탄다."""
options = _options(
{
"inlet_pitching": "있음",
"inlet_pitching_finish": "",
"inlet_pitching_area_m2": 9.0,
"outlet_pitching": "있음",
"outlet_pitching_finish": "",
"outlet_pitching_area_m2": 25.0,
}
)
assert options["inlet_protection"] == "돌붙임(찰)"
assert options["outlet_protection"] == "돌붙임(메)"
assert options["inlet_protection_area_m2"] == 9.0
assert options["outlet_protection_area_m2"] == 25.0
def test_legacy_keys_are_removed():
"""옛 키가 남으면 화면·수량이 두 벌을 보게 된다."""
options = _options(
{"inlet_pitching": "있음", "inlet_pitching_finish": "", "inlet_pitching_area_m2": 9.0}
)
for gone in ("inlet_pitching", "inlet_pitching_finish", "inlet_pitching_area_m2"):
assert gone not in options, gone
def test_legacy_none_is_promoted_to_part_default():
""""없음"은 선택지가 사라졌으므로 부위 기본값(유입 찰·유출 메)으로 올린다."""
options = _options({"inlet_pitching": "없음", "outlet_pitching": "없음"})
assert options["inlet_protection"] == "돌붙임(찰)"
assert options["outlet_protection"] == "돌붙임(메)"
def test_legacy_without_finish_falls_back_to_part_default():
"""표면처리가 빠진 구 저장분도 부위 기본값으로 채운다."""
options = _options({"outlet_pitching": "있음"})
assert options["outlet_protection"] == "돌붙임(메)"
def test_new_keys_win_over_legacy():
"""이미 새 키가 있으면 구 키가 덮어쓰지 않는다(재저장분 보호)."""
options = _options(
{
"inlet_protection": "도수로",
"inlet_protection_width_m": 1.0,
"inlet_pitching": "있음",
"inlet_pitching_finish": "",
}
)
assert options["inlet_protection"] == "도수로"
assert options["inlet_protection_width_m"] == 1.0
assert "inlet_pitching" not in options
def test_untouched_options_pass_through():
"""보호공과 무관한 옵션은 그대로 둔다."""
options = _options({"pipe_kind": "파형강관", "pipe_diameter_mm": 1000})
assert options == {"pipe_kind": "파형강관", "pipe_diameter_mm": 1000}
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""종단 계획고 변경이 횡단 설계에 반영되는지(2026-08-23 사용자 지시).
횡단 설계는 계산 당시 계획고(design.design_elevation_m)를 기준으로 설계선 좌표를
굳혀 둔다. 계획선이 뒤에 바뀌면 설계선은 옛 자리에 남는데 화면의 계획고 십자선·
3D 예상형상은 최신 계획선을 쓴다 — 두 기준이 어긋난다(실측: 한 노선 최대 2.21m).
어긋남 판정을 B05·B06 공용 규칙(hasStaleDesigns)으로 두고, B05 진입 때도
재계산이 돌게 한 것을 소스로 잠근다.
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
COMMON = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").read_text(
encoding="utf-8"
)
B05_PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text(
encoding="utf-8"
)
B06_PAGE = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page.ts").read_text(encoding="utf-8")
def _stale(profile_samples, sections, tol=1e-3):
"""TS hasStaleDesigns와 같은 규칙 — 계획선 보간값과 계산 기준의 차이."""
def plan_at(ch):
if not profile_samples:
return None
if ch <= profile_samples[0][0]:
return profile_samples[0][1]
if ch >= profile_samples[-1][0]:
return profile_samples[-1][1]
for i in range(1, len(profile_samples)):
c0, z0 = profile_samples[i - 1]
c1, z1 = profile_samples[i]
if ch <= c1:
span = c1 - c0
t = (ch - c0) / span if span > 0 else 0
return z0 + (z1 - z0) * t
return profile_samples[-1][1]
out = []
for ch, used in sections:
if used is None:
continue
planned = plan_at(ch)
if planned is not None and abs(planned - used) > tol:
out.append(ch)
return out
PROFILE = [(0.0, 100.0), (20.0, 102.0), (40.0, 104.0)]
def test_detects_design_computed_from_old_plan():
"""계획선이 바뀌어 계산 기준과 어긋난 측점을 잡는다."""
assert _stale(PROFILE, [(20.0, 101.0)]) == [20.0]
def test_clean_when_design_matches_plan():
"""계획선과 계산 기준이 같으면 재계산 대상이 아니다."""
assert _stale(PROFILE, [(0.0, 100.0), (20.0, 102.0), (40.0, 104.0)]) == []
def test_interpolates_between_profile_samples():
"""측점이 계획선 샘플 사이에 있으면 보간값과 비교한다(비정규 측점·배수관 자리)."""
assert _stale(PROFILE, [(10.0, 101.0)]) == []
assert _stale(PROFILE, [(10.0, 100.5)]) == [10.0]
def test_sections_without_design_are_skipped():
"""설계가 아직 없는 측점은 판정 대상이 아니다(최초 생성 전)."""
assert _stale(PROFILE, [(20.0, None)]) == []
def test_tolerance_ignores_rounding_noise():
"""저장 반올림 수준(1e-3 이하)은 어긋남으로 보지 않는다 — 무한 재계산 방지."""
assert _stale(PROFILE, [(20.0, 102.0005)]) == []
def test_source_shared_rule_and_b05_entry_recompute():
"""공용 판정 유틸을 B05 진입·B06 재계산이 함께 쓰는지 소스 검사."""
assert "export function hasStaleDesigns" in COMMON
# B05: 진입 렌더에서 어긋남이 있으면 프리뷰 1회로 맞춘다.
assert "hasStaleDesigns" in B05_PANEL
# 진입 즉시 한 번 맞춘다 — 편집 반영 계획선을 넣어 부르고, 어긋나면 예약한다.
assert "hasStaleDesigns({" in B05_PANEL
assert "scheduleCrossPreview();" in B05_PANEL
# B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다
# (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다).
assert "hasStaleDesigns(sectionDetail)" in B06_PAGE
assert "two_stage_slope === undefined" in COMMON
assert "two_stage_slope === undefined" not in B06_PAGE
@@ -0,0 +1,37 @@
/* 구조물 측점과 겹치는 규칙 측점 제거 — 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/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 — 구조물 측점과 겹친 규칙 측점만 제거");
@@ -0,0 +1,175 @@
"""마이그레이션 테스트 — 서버 저장 형식(구조물 문자열만)과 클라이언트 형식 모두 커버.
크로스체크 지적 1(2026-08-16): 확정 저장분은 `{chainage_m, structure}` 문자열뿐이다
(`B05_Profile_UI_Page.ts` confirmRoute 전송부). structureType이 없어도 문자열에서
종류를 알아내야 한다.
2026-09-01 사용자 확정: 정본이 갈린 타입(기슭막이 = `managed_by: pipe_points`)은
구조물 목록이 아니라 관 지점 정본으로 간다. 결과도 두 갈래(`MigrationPlan`)다.
"""
from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations
# ── 서버 저장 형식 (structure 문자열만) ──────────────────────────────────────
def test_server_format_gisungmagi_maps_to_revetment():
(item,) = migrate_irregular_stations(
[{"chainage_m": 120.0, "structure": "기성막이"}]
).pipe_facilities
assert item.facility == "revetment"
def test_server_format_escape_route_maps_to_refuge_with_width():
(item,) = migrate_irregular_stations(
[{"chainage_m": 300.0, "structure": "대피로 2.5m"}]
).structures
assert item.type_id == "refuge"
assert item.options["width_m"] == 2.5
def test_server_format_pipe_label_is_skipped():
"""배관 라벨(관종 D직경)은 관 정본 소관 — 옮기지 않는다."""
for label in ("파형강관 D800", "흄관 D1000", "배관"):
plan = migrate_irregular_stations([{"chainage_m": 50.0, "structure": label}])
assert plan.structures == [] and plan.pipe_facilities == []
def test_server_format_unknown_text_becomes_etc():
(item,) = migrate_irregular_stations(
[{"chainage_m": 15.0, "structure": "돌망태 보강"}]
).structures
assert item.type_id == "etc"
assert item.options["name"] == "돌망태 보강"
# ── 클라이언트 형식 (structureType 포함) ────────────────────────────────────
def test_client_format_pipe_entries_are_skipped():
plan = migrate_irregular_stations(
[
{"chainage_m": 50.0, "structure": "파형강관 D800", "structureType": "배관"},
{"chainage_m": 60.0, "structure": "배관", "origin": "pipe"},
]
)
assert plan.structures == [] and plan.pipe_facilities == []
def test_client_format_gisungmagi_maps_to_revetment():
"""기슭막이는 관 시설 — 구간·제원은 레지스트리 기본값을 승계한다(2026-09-01)."""
plan = migrate_irregular_stations(
[{"chainage_m": 120.0, "structure": "기성막이", "structureType": "기성막이"}]
)
assert plan.structures == []
(item,) = plan.pipe_facilities
assert item.facility == "revetment"
assert item.chainage_m == 120.0
# 기준측점 전/후 5m — 레지스트리 기본값.
assert (item.start_m, item.end_m) == (115.0, 125.0)
assert item.options["form"] == "돌쌓기(메)"
assert item.options["height_m"] == 2.5 and item.options["length_m"] == 10
def test_client_format_escape_route_width_field_wins():
(item,) = migrate_irregular_stations(
[
{
"chainage_m": 300.0,
"structure": "대피로 2.5m",
"structureType": "대피로",
"escapeWidthM": 3.0,
}
]
).structures
assert item.type_id == "refuge"
assert item.options["width_m"] == 3.0 # 명시 필드가 라벨 파싱보다 우선
def test_etc_keeps_custom_name():
(item,) = migrate_irregular_stations(
[
{
"chainage_m": 80.0,
"structure": "임시 표지",
"structureType": "기타",
"customName": "임시 표지",
}
]
).structures
assert item.type_id == "etc"
assert item.options["name"] == "임시 표지"
def test_duplicate_positions_are_deduplicated():
entries = [
{"chainage_m": 100.0, "structure": "기성막이"},
{"chainage_m": 100.0, "structure": "기성막이"},
]
assert len(migrate_irregular_stations(entries).pipe_facilities) == 1
def test_migration_is_idempotent():
entries = [
{"chainage_m": 10.0, "structure": "기성막이"},
{"chainage_m": 20.0, "structure": "대피로 2.0m"},
]
first = migrate_irregular_stations(entries)
second = migrate_irregular_stations(entries)
assert [(i.type_id, i.start_m) for i in first.structures] == [
(i.type_id, i.start_m) for i in second.structures
]
assert [(p.facility, p.start_m) for p in first.pipe_facilities] == [
(p.facility, p.start_m) for p in second.pipe_facilities
]
def test_empty_input_returns_empty():
plan = migrate_irregular_stations([])
assert plan.structures == [] and plan.pipe_facilities == []
# ── 다른 정본이 관리하는 A군 라벨 (2026-08-28) ──────────────────────────────
def test_group_a_labels_from_server_are_not_re_migrated():
"""서버가 종단 정본에 심는 A군 라벨은 구조물로 옮기지 않는다.
옮기면 정본이 이중화되어 "기타" 고스트가 쌓인다 — 실증: 초기화 전 프로젝트의
structures.json에 `etc@149.73`(세월교)·`etc@200.92`(BOX암거)가 남아 있었다.
"""
entries = [
{"chainage_m": 149.73, "structure": "세월교"},
{"chainage_m": 200.92, "structure": "BOX암거"},
{"chainage_m": 210.0, "structure": "물넘이포장"},
{"chainage_m": 30.0, "structure": "배수관"},
{"chainage_m": 60.0, "structure": "노출형 횡단수로"},
{"chainage_m": 70.0, "structure": "개거(겉도랑)"},
]
plan = migrate_irregular_stations(entries)
assert plan.structures == [] and plan.pipe_facilities == []
def test_pipe_owned_label_is_not_re_migrated():
"""관 시설에서 투영된 "기슭막이" 라벨은 되옮기지 않는다 — 되옮기면 "기타"로 굳는다.
구 이름("기성막이")만 이관 대상이다(2026-08-28 이관 뒤 라벨이 갈렸다).
"""
plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": "기슭막이"}])
assert plan.structures == [] and plan.pipe_facilities == []
def test_non_group_a_labels_still_migrate():
"""대조군 — 필터가 과하게 넓어지면 이 세 건이 함께 사라진다."""
(revetment,) = migrate_irregular_stations(
[{"chainage_m": 120.0, "structure": "기성막이"}]
).pipe_facilities
assert revetment.facility == "revetment"
(refuge,) = migrate_irregular_stations(
[{"chainage_m": 300.0, "structure": "대피로 2.0m"}]
).structures
assert refuge.type_id == "refuge"
(etc,) = migrate_irregular_stations(
[{"chainage_m": 15.0, "structure": "돌망태 보강"}]
).structures
assert etc.type_id == "etc"
@@ -0,0 +1,73 @@
"""구 비정규 측점 이관은 **한 번만** — 2026-08-24 사용자 확정.
원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, 옮긴 이력만 구조물 정본에
남긴다. 그래야 사용자가 그 구조물을 지운 뒤 B05에 다시 들어와도 되살아나지 않는다.
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Structures_Repository import ( # noqa: E402
load_migrated_legacy,
load_structures,
save_structures,
)
from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations # noqa: E402
def _key(item) -> str:
return f"{item.type_id}@{round(item.anchor_m(), 3)}"
def test_history_survives_plain_save(tmp_path):
"""일반 저장(구조물 편집)이 이력을 지우면 안 된다 — 지우면 삭제분이 되살아난다."""
root = str(tmp_path)
revision = save_structures(root, [], base_revision=0, migrated_legacy={"erosion_check@10.0"})
assert load_migrated_legacy(root) == {"erosion_check@10.0"}
# 이력 인자 없이 다시 저장해도 남아 있어야 한다.
save_structures(root, [], base_revision=revision)
assert load_migrated_legacy(root) == {"erosion_check@10.0"}
def test_history_accumulates(tmp_path):
"""여러 번 이관해도 이력은 합쳐진다."""
root = str(tmp_path)
revision = save_structures(root, [], base_revision=0, migrated_legacy={"a@1.0"})
save_structures(root, [], base_revision=revision, migrated_legacy={"b@2.0"})
assert load_migrated_legacy(root) == {"a@1.0", "b@2.0"}
def test_migration_candidate_keys_are_stable(tmp_path):
"""이관 후보의 표식이 저장 왕복에도 같아야 한다 — 달라지면 매번 다시 옮긴다."""
stations = [{"chainage_m": 10.0, "structure": "돌쌓기 기슭막이"}]
candidates = migrate_irregular_stations(stations).structures
if not candidates: # 매핑이 없는 문구면 이 검증은 대상 아님
return
root = str(tmp_path)
save_structures(root, candidates, base_revision=0)
_revision, stored = load_structures(root)
assert {_key(item) for item in stored} == {_key(item) for item in candidates}
def test_deleted_structure_is_not_recreated(tmp_path):
"""이관 → 삭제 → 재이관 시도: 이력에 있으므로 다시 만들지 않는다."""
stations = [{"chainage_m": 10.0, "structure": "돌쌓기 기슭막이"}]
candidates = migrate_irregular_stations(stations).structures
if not candidates:
return
root = str(tmp_path)
history = {_key(item) for item in candidates}
revision = save_structures(root, candidates, base_revision=0, migrated_legacy=history)
# 사용자가 전부 지운다(일반 저장 경로).
revision = save_structures(root, [], base_revision=revision)
assert load_structures(root)[1] == []
# 재진입 — 라우터와 같은 판정: 자리도 비었지만 이력에 있으니 후보에서 빠진다.
migrated_before = load_migrated_legacy(root)
fresh = [item for item in candidates if _key(item) not in migrated_before]
assert fresh == []
@@ -0,0 +1,563 @@
"""레지스트리 정책 검증 — phase 단계 분리·선택지 재편·기본값 원칙.
2026-08-17 선택지 재편: B05는 유무·종류·위치만 고르고 상세 치수는 B06/B07에서
받는다. 종단배수(B군)·생태/녹화(F군)·노면공(G군)은 B05 선택지에서 빠지고
`enabled: false`로 보존된다(B06 횡단도 옵션 재사용). 바닥막이·생태연못은 임도
미사용으로 완전 삭제. 배관·BOX암거는 부속 옵션 정의를 갖는다.
"""
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
# 기본값을 남겨 둘 수 있는 항목 — 법정 단일값·사용자 확정값·표시용 문자열뿐이다.
ALLOWED_DEFAULT_KEYS = {
# 돌 조달 「채집」 기본 — **사용자 확정 ②(2026-09-09) 「기본은 캔다, 구조물마다 바꿀 수
# 있게」**. 법도 그쪽을 권한다: 별표2 「석축 등에 필요한 야면석 등은 가급적 현장에서
# 채취·사용하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 구입 단가가 0건.
("masonry_wet", "stone_supply"),
("masonry_dry", "stone_supply"),
("boulder_masonry", "stone_supply"),
("erosion_check", "stone_supply"),
("bed_sill", "stone_supply"),
("revetment", "stone_supply"),
# 독립 기슭막이 설치 측(2026-08-28 사용자 확정: 자동 판정 없이 수동으로 받는다).
# "좌"는 셀렉트 첫 항목일 뿐 도메인 확정값이 아니다 — 측점마다 사용자가 고른다.
("revetment", "side"),
# C군 다섯(옹벽·돌쌓기 찰/메·흙막이·큰돌쌓기)의 설치 측 기본 "자동(성토 쪽)"
# (2026-09-07 사용자 지시로 폼 칸 신설). **도메인 확정값이 아니라 「지금까지의 동작」**
# 이다 — 칸이 생기기 전에도 기하는 성토 쪽에 세우고 있었고("좌"/"우" 를 고르면 그쪽),
# 그 행동을 그대로 이어받는 표식이다. 사용자가 측점마다 좌·우로 바꾼다.
("retaining_wall", "side"),
("masonry_wet", "side"),
("masonry_dry", "side"),
("soil_guard", "side"),
("boulder_masonry", "side"),
# 흙막이 — **기슭막이 칸을 한 벌로 옮긴 것**(2026-09-09 사용자 확정 4차 원문:
# 「흙막이는 횡단도에서 표현방식들과 옵션들은 동일하게 반영 · 형상은 동일 ·
# 데이터는 분리하여 계산」). 기본값의 까닭도 기슭막이 줄과 **같다** —
# 단 수 1 = 다단 없음 · 올림/이동 0 = 자동 자리 그대로 · 조달 채집 = 별표2 권고.
("soil_guard", "tiers"),
("soil_guard", "lift_m"),
("soil_guard", "shift_m"),
("soil_guard", "stone_supply"),
# 사토장 쌓는 쪽 기본 "자동(성토 쪽)" — C군 side 와 같은 뜻이다. 도메인 확정값이 아니라
# 「흙은 성토 쪽에 쌓는다」는 지금까지의 동작을 이어받는 표식이고, 측점마다 좌·우로 바꾼다.
("spoil_bank", "side"),
# 추가 운반거리 기본 0 = 「추가 없음」. 노선 안 사토장은 측점 사이 누가거리로 거리가
# 그대로 나오므로(2026-09-09 사용자 확정 ③) 이 칸은 **외부 사토장 대비 여벌**이다.
# 0 은 도메인 수치가 아니라 lift_m·shift_m 과 같은 「안 더함」 표식이다.
("spoil_bank", "extra_distance_m"),
# 단 수 기본 1 = 단일 벽(다단 아님). 도메인 수치가 아니라 "다단 없음" 표식이다.
("revetment", "tiers"),
# 자리 이동 기본 0 = 자동 자리(성토면 끝) 그대로. 이동량은 사용자 조작값이다.
("revetment", "lift_m"),
("revetment", "shift_m"),
# 포장 구간 길이 10m / 전·후 5m — 기슭막이와 같은 출발값(2026-08-28 사용자 확정).
# 지식DB에 포장 구간 길이 기준은 없다. 화면 출발값일 뿐 도메인 확정값이 아니다.
("pavement_concrete", "length_m"),
("pavement_concrete", "before_m"),
("pavement_concrete", "after_m"),
# ── 소단(berm·ditch_berm) 기본값 — 근거 확인 끝(2026-09-07, 보조 창 구현 + 원문 대조) ──
# 폭 0.5m · 간격(사면길이) 3m = **별표2 범위 안에서 가장 적게 파는 조합**이다.
# 지식DB `01_임도/02_상세설계/절토_비탈면.md` §2: 「사면길이 **2~3m마다** 폭 **50~100㎝**」.
# 범위의 양 끝(폭 최소·간격 최대)을 골랐다 — 기본값은 되돌리기 쉬운 쪽이어야 하기 때문이다
# (더 넣는 것은 폼에서 한 번이지만, 이미 판 것을 되돌리면 전 측점 재계산이다).
# 실효 경사 차이도 크다(설계 1:1): 0.5·3 → 1:1.24 / 1.0·3 → 1:1.47 / 1.0·2 → 1:1.71.
("berm", "width_m"),
("berm", "interval_m"),
("ditch_berm", "width_m"),
("ditch_berm", "interval_m"),
# 안쪽 기울기 기본 **0°** = 「기본으로 아무것도 안 넣음」. 기울이는 것은 실무이나
# **법령·교본 근거가 없어** 박지 않고 폼에서 받는다(2026-09-07 사용자 재확정 — 처음 2°
# 로 잡았다가 내렸다). 근거 없는 값을 안 넣으려는 값이라 이 원칙의 반대편이 아니다.
("berm", "slope_deg"),
("ditch_berm", "slope_deg"),
# 구간 길이 10m·전·후 5m — 소단만의 값이 아니라 **C군 구간형 폼 공통 출발값**이다
# (포장 구간·기슭막이와 같은 값). 도메인 확정값이 아니다.
("berm", "length_m"),
("berm", "before_m"),
("berm", "after_m"),
("ditch_berm", "length_m"),
("ditch_berm", "before_m"),
("ditch_berm", "after_m"),
("ditch_side", "depth_cm"),
("erosion_check", "length_m"),
("erosion_check", "height_m"),
("refuge", "length_m"),
("work_yard", "width_m"),
("work_yard", "length_m"),
("turnaround", "width_m"),
("etc", "name"),
# 별표2 관 지름 1,000㎜ 이상 원칙 + 2026-08-17 사용자 확정 기본값.
("pipe", "pipe_diameter_mm"),
# 보호공(돌붙임) 계열은 삭제 — 기슭막이는 사면 공정이라 바닥 돌붙임이 붙지
# 않는다(2026-08-19 사용자 지시 1·3). 바닥 보호는 바닥막이(bed_sill) 구조물 몫.
# 기슭막이 형태 기본 — 유입 돌쌓기(찰) / 유출 돌쌓기(메) (2026-08-17 사용자 확정).
("pipe", "inlet_revet_form"),
("pipe", "outlet_revet_form"),
# 유출구 구조는 현재 기슭막이 하나뿐 — 양식 일원화용 단일 선택지.
("pipe", "outlet_type"),
# 관종 기본 = 파형강관, 유입구 구조 기본 = 기슭막이 (2026-08-17 사용자 확정).
("pipe", "pipe_kind"),
("pipe", "inlet_type"),
# 집수정 종방향 기본 — 길이 2m·기준측점 전/후 각 1m (2026-08-24 사용자 확정).
("pipe", "inlet_basin_before_m"),
("pipe", "inlet_basin_after_m"),
# 기슭막이 기본 치수 — 길이 10m·높이 2.5m·보호공 면적 10㎡ (2026-08-17 사용자 확정).
("pipe", "inlet_revet_length_m"),
("pipe", "inlet_revet_height_m"),
("pipe", "outlet_revet_length_m"),
("pipe", "outlet_revet_height_m"),
# 독립 기슭막이(D4) — C군과 같은 범위 계산 방식, 기본 돌쌓기(메)·2.5·10·5·5
# (2026-08-19 사용자 지시 3).
("revetment", "form"),
("revetment", "height_m"),
("revetment", "length_m"),
("revetment", "before_m"),
("revetment", "after_m"),
# 바닥막이 부활 — 돌붙임(메)·면적 10㎡·높이 0.3m (2026-08-19 사용자 지시 2·5).
("bed_sill", "form"),
("bed_sill", "area_m2"),
("bed_sill", "height_m"),
# 세월교 구체 내 배관도 배수관과 같은 관종·관경을 쓴다 (2026-08-17 사용자 확정).
("ford_bridge", "pipe_kind"),
("ford_bridge", "pipe_diameter_mm"),
# 세월교 날개벽 — BOX암거와 같은 한 벌을 그대로 쓴다 (2026-08-25 사용자 확정).
("ford_bridge", "wing_in"),
("ford_bridge", "wing_in_height_m"),
("ford_bridge", "wing_in_length_m"),
("ford_bridge", "wing_in_angle_deg"),
("ford_bridge", "wing_out"),
("ford_bridge", "wing_out_height_m"),
("ford_bridge", "wing_out_length_m"),
("ford_bridge", "wing_out_angle_deg"),
# 기슭막이 종방향 전/후 각 5m·집수정 길이 2m — 확정값인데 목록에 빠져 있었다
# (2026-08-17·08-24 사용자 확정, 2026-08-25 보충).
("pipe", "inlet_revet_before_m"),
("pipe", "inlet_revet_after_m"),
("pipe", "outlet_revet_before_m"),
("pipe", "outlet_revet_after_m"),
("pipe", "inlet_basin_length_m"),
# C군 기본값 — 높이 2.5·길이 10·전길이 5 (2026-08-19 사용자 확정).
*(
(type_id, key)
for type_id in (
"retaining_wall",
"masonry_wet",
"masonry_dry",
"soil_guard",
"boulder_masonry",
)
for key in ("height_m", "length_m", "before_m", "after_m")
),
# BOX암거 본체 2.0×2.0(실무 관측 규격 프리셋의 첫 항목)과 날개벽 기본 제원
# — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45° (2026-08-17 사용자 확정).
("box_culvert", "body_width_m"),
("box_culvert", "body_height_m"),
("box_culvert", "wing_in"),
("box_culvert", "wing_in_height_m"),
("box_culvert", "wing_in_length_m"),
("box_culvert", "wing_in_angle_deg"),
("box_culvert", "wing_out"),
("box_culvert", "wing_out_height_m"),
("box_culvert", "wing_out_length_m"),
("box_culvert", "wing_out_angle_deg"),
}
# B06 이관 보류 — 정의 보존을 위해 삭제하지 않고 enabled=False로 숨긴다.
DISABLED_TYPE_IDS = {
"ditch_side",
"ditch_ridge",
"ditch_berm",
"chute",
"slope_drain",
"underdrain",
"wildlife_path",
"revegetation",
"gravel_surfacing",
}
# 임도 미사용 확정 — 레지스트리에서 완전 삭제. 바닥막이(bed_sill)는 2026-08-19
# 사용자 지시로 부활해 D군(계류 사방)에 되살렸다.
REMOVED_TYPE_IDS = {"eco_pond"}
def _quick_add_types():
"""우클릭 한 번으로 넣을 수 있는 타입 — 정본 관리·비활성 타입만 제외한다."""
return [item for item in load_structure_types() if not item.managed_by and item.enabled]
def test_quick_add_types_exclude_pipe_family():
"""계곡 통과 시설(배관·BOX암거·물넘이·세월교)은 관 지점 정본 소관."""
quick_ids = {item.type_id for item in _quick_add_types()}
for type_id in ("pipe", "box_culvert", "ford_pavement", "ford_bridge"):
assert type_id not in quick_ids
def test_all_enabled_non_managed_types_are_quick_addable():
"""잔존 B05 선택지 = A군 2(노출형·개거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1.
콘크리트 포장은 2026-08-28 사용자 지시로 되살렸다 — 포장 구간을 기준측점+길이+전/후로
받는 정본이 됐다(종단경사 자동 포장 적용을 대체).
D군이 4에서 3으로 줄었다 — 독립 기슭막이가 관 지점 시설로 옮겨가(2026-08-28,
`managed_by: pipe_points`) 우클릭 추가 대상에서 빠졌다.
C군이 5에서 6으로 늘었다 — **소단(`berm`)** 이 들어왔다(2026-09-07, 계획서 3-9).
소단 규격은 법령·교본마다 갈려 프로그램이 판정하지 않고 **사용자가 구간에 놓는다** —
그래서 C군 구간형 시설과 같은 자리에 선다. (측구쪽 `ditch_berm` 은 우클릭 추가 대상이
아니라 여기 수에 안 잡힌다.)
"""
assert len(_quick_add_types()) == 23
def test_removed_types_are_absent():
"""바닥막이·생태연못 — 임도 미사용 확정으로 레지스트리에서 삭제."""
present = {item.type_id for item in load_structure_types()}
leftovers = REMOVED_TYPE_IDS & present
assert not leftovers, f"삭제 대상 잔존: {leftovers}"
def test_b06_deferred_types_are_disabled_but_preserved():
"""B·F·G군은 B05 선택지에서 빠지되 정의는 남는다(B06 횡단도 옵션 재사용)."""
by_id = {item.type_id: item for item in load_structure_types()}
for type_id in DISABLED_TYPE_IDS:
assert type_id in by_id, f"{type_id} 정의가 삭제됨 — 보존 대상"
assert by_id[type_id].enabled is False, f"{type_id} enabled=False여야 함"
for type_id, item in by_id.items():
if type_id not in DISABLED_TYPE_IDS:
assert item.enabled is True, f"{type_id}는 활성이어야 함"
def test_b05_phase_options_are_never_required():
"""B05 배치 시 필수 입력이 남아 있으면 유무 단계가 깨진다 — 필수는 전부 detail.
C군 높이·길이는 한때 B05 필수였으나 기본값(2.5/10/5)이 사용자 확정되면서
필수가 풀렸다(2026-08-19) — 원칙은 다시 예외 없이 성립한다."""
offenders = [
f"{item.type_id}.{option.key}"
for item in load_structure_types()
for option in item.options
if option.required and option.phase != "detail"
]
assert offenders == [], f"B05 단계 필수 옵션: {offenders}"
def test_slope_group_computes_range_from_length():
"""C군 5종 — 전부 구간형이고 B05 길이·높이 옵션을 갖는다(2026-08-19 지시).
프론트(hasComputedRange)는 'B05 phase의 length_m 존재'로 범위 계산 UI를 켠다."""
by_id = {item.type_id: item for item in load_structure_types()}
for type_id in (
"retaining_wall",
"masonry_wet",
"masonry_dry",
"soil_guard",
"boulder_masonry",
):
item = by_id[type_id]
assert item.group == "C" and item.placement == "interval", type_id
# 기본값 = 높이 2.5·길이 10·전길이 5 (2026-08-19 사용자 확정 — 기본값이
# 생기며 required는 풀렸다). 후길이 = 길이 − 전길이는 화면 계산값이라 옵션이
# 아니다. 순서는 높이 → 길이 → 전길이.
options = {option.key: option for option in item.options}
expected = {"height_m": 2.5, "length_m": 10, "before_m": 5}
for key, default in expected.items():
assert key in options, f"{type_id}.{key} 옵션 없음"
assert options[key].phase == "b05", f"{type_id}.{key}는 b05 phase"
assert not options[key].required, f"{type_id}.{key}는 기본값 확정으로 비필수"
assert options[key].default == default, f"{type_id}.{key} 기본값"
assert options[key].input == "number" and options[key].unit == "m", key
keys = [option.key for option in item.options if option.phase == "b05"]
assert keys[:3] == ["height_m", "length_m", "before_m"], f"{type_id} 옵션 순서"
def test_retaining_wall_renamed_plain():
"""옹벽(철근콘크리트) → 옹벽 — 전역 명칭 단순화(2026-08-19 사용자 지시)."""
by_id = {item.type_id: item for item in load_structure_types()}
assert by_id["retaining_wall"].name == "옹벽"
def test_required_options_moved_to_detail_phase():
"""크로스체크 2차의 필수 원칙이 detail phase로 유지되는지 확인."""
detail_required = [
(item.type_id, option.key)
for item in load_structure_types()
for option in item.options
if option.phase == "detail" and option.required
]
assert len(detail_required) >= 18
def test_only_confirmed_values_keep_defaults():
"""미협의 재료·형식 선택지에 기본값이 남아 있으면 안 된다(지식DB 원칙).
⚠ 인정하는 길은 둘이다 — 아래 목록에 있거나, **정본에 `default_basis` 로 까닭을 적었거나**.
뒤쪽이 2026-09-09 신설이고 **앞으로는 그쪽을 쓴다** — 이 파일은 git 밖이라 목록만
고치면 다른 창에서 같은 시험이 깨진다(실제로 두 번 그랬다).
"""
offenders = [
f"{item.type_id}.{option.key}={option.default!r}"
for item in load_structure_types()
for option in item.options
if option.default not in (None, "")
and (item.type_id, option.key) not in ALLOWED_DEFAULT_KEYS
and not option.default_basis
]
assert offenders == [], f"근거 없는 기본값: {offenders}"
def test_default_basis_is_a_real_sentence():
"""까닭이 한 낱말이면 다음 사람이 못 되짚는다."""
thin = [
f"{item.type_id}.{option.key}"
for item in load_structure_types()
for option in item.options
if option.default_basis is not None and len(option.default_basis) < 12
]
assert thin == [], f"까닭이 너무 짧음: {thin}"
def test_options_without_default_are_required():
"""기본값도 없고 필수도 아니면 빈 값이 조용히 저장된다 — 어느 phase든 동일.
managed_by 타입(계곡 통과 시설)은 정본이 pipe_points라 structures.json 저장
검증을 거치지 않는다 — 배관 부속·세월교 관 옵션은 B05 유무 단계 입력이라
강제하지 않는다.
⚠⚠ **세 번째 갈래가 있다**(2026-09-09 신설) — **「비워 두는 것이 뜻인 칸」**.
비면 계산 쪽이 기준값으로 돌고 그 사실이 화면에 사유로 뜨며, 값을 넣으면 그 값이 이긴다
(전면 기울기 · 물빼기 구멍 · 뒷길이 …). 그런 칸은 **정본에 `empty_means` 로 까닭을 적어**
둔다.
⚠ **예외 목록을 이 파일에 두지 않는다.** 이 파일은 `tmp/tests` 라 **git 밖**이라,
정본(추적됨)만 바뀌고 예외(추적 안 됨)는 안 따라가 **다른 창에서만 시험이 깨졌다**
(2026-09-09 실제로 두 번). 까닭이 값 옆에 있으면 그 일이 안 생긴다.
"""
loose = [
f"{item.type_id}.{option.key}"
for item in load_structure_types()
if not item.managed_by
for option in item.options
if option.default in (None, "")
and not option.required
and option.input != "text"
and not option.empty_means
]
assert loose == [], f"기본값·필수·`empty_means` 어느 쪽도 아닌 옵션: {loose}"
def test_empty_means_is_a_real_sentence():
"""까닭이 한 낱말이면 다음 사람이 못 되짚는다 — 어디서 오는 기준인지 적혀야 한다."""
thin = [
f"{item.type_id}.{option.key}"
for item in load_structure_types()
for option in item.options
if option.empty_means is not None and len(option.empty_means) < 12
]
assert thin == [], f"까닭이 너무 짧음: {thin}"
def test_a6_a7_types_exist_for_manual_placement():
"""A6 노출형 횡단수로·A7 개거 — 수동 구조물 정본 소속 (2026-08-17 확정)."""
by_id = {item.type_id: item for item in load_structure_types()}
for type_id in ("cross_drain_exposed", "open_ditch"):
assert type_id in by_id, f"{type_id} 타입 없음"
item = by_id[type_id]
assert item.group == "A" and item.managed_by is None
assert item.placement == "point"
def test_ford_bridge_shares_pipe_kind_and_diameter():
"""세월교 구체 내 배관 = 배수관과 같은 관종·관경 선택지·기본값 (2026-08-17 지시)."""
by_id = {item.type_id: item for item in load_structure_types()}
ford = {option.key: option for option in by_id["ford_bridge"].options}
pipe = {option.key: option for option in by_id["pipe"].options}
for key in ("pipe_kind", "pipe_diameter_mm"):
assert ford[key].choices == pipe[key].choices, key
assert ford[key].default == pipe[key].default, key
assert "pipe_count" in ford, "세월교 수량(련)은 사용자 지정으로 남는다"
def test_valley_crossing_facilities_exist_as_managed_types():
"""BOX암거·물넘이·세월교 — 표시용 레지스트리 항목 (정본은 pipe_points)."""
by_id = {item.type_id: item for item in load_structure_types()}
for type_id in ("box_culvert", "ford_pavement", "ford_bridge"):
assert type_id in by_id, f"{type_id} 타입 없음"
item = by_id[type_id]
assert item.managed_by == "pipe_points"
assert item.style.get("abbr"), "종단 마크 약호 필요"
def test_pipe_accessory_options_defined():
"""배관 부속 옵션 — 관종·관경(기본 1000) + 유입구/유출구 그룹 (2026-08-17 UI 개편).
유입구는 집수정 또는 기슭막이 택일, 유출구는 기슭막이만(현재 다른 선택지 없음).
재질과 표면처리(찰/메)는 분리된 축이다 — 재질에 따라 표면처리가 달라진다
(돌쌓기 §1·§2: 찰=모르타르 채움, 메=건쌓기).
"""
by_id = {item.type_id: item for item in load_structure_types()}
options = {option.key: option for option in by_id["pipe"].options}
for key in (
"pipe_kind",
"pipe_diameter_mm",
"inlet_type",
"inlet_basin_form",
"inlet_basin_material",
"inlet_revet_form",
"inlet_revet_length_m",
"inlet_revet_height_m",
"outlet_revet_form",
"outlet_revet_length_m",
"outlet_revet_height_m",
):
assert key in options, f"pipe.{key} 옵션 없음"
# 보호공(돌붙임·도수로) 계열은 삭제 — 기슭막이는 사면 공정(2026-08-19 지시 1).
for gone in (
"inlet_protection",
"inlet_protection_area_m2",
"inlet_protection_width_m",
"outlet_protection",
"outlet_protection_area_m2",
"outlet_protection_width_m",
):
assert gone not in options, f"pipe.{gone}는 삭제됐다"
# 유형(flow_type)은 삭제 — 유입구 구조 선택으로 대체(2026-08-17 사용자 지시).
assert "flow_type" not in options
# 재질·표면처리는 하나로 합쳐 "형태"가 됐다(2026-08-17 사용자 지시 1).
for gone in ("inlet_revet_material", "inlet_revet_finish", "outlet_revet_finish"):
assert gone not in options, f"pipe.{gone}는 형태로 합쳐졌다"
assert options["inlet_type"].choices == ["기슭막이", "집수정"]
assert options["inlet_type"].default == "기슭막이"
assert options["pipe_diameter_mm"].default == "1000"
assert options["pipe_diameter_mm"].choices == ["800", "1000", "1200", "1500"]
# 돌붙임은 바닥 구조라 형태 목록에서 뺐다 — 별도 항목이 있다(2026-08-17 지시 1).
assert options["inlet_revet_form"].choices == [
"돌쌓기(찰)",
"돌쌓기(메)",
"콘크리트",
"돌망태",
"통나무·목재틀",
"바자",
]
assert options["inlet_revet_form"].default == "돌쌓기(찰)"
assert options["outlet_revet_form"].default == "돌쌓기(메)"
# 유출구도 구조 항목을 둔다 — 선택지는 기슭막이뿐이지만 양식을 맞춘다(지시 2).
assert options["outlet_type"].choices == ["기슭막이"]
# 구 두 축(있음/없음 + 표면처리)은 남아 있으면 안 된다.
for gone in (
"inlet_pitching",
"inlet_pitching_finish",
"inlet_pitching_area_m2",
"outlet_pitching",
"outlet_pitching_finish",
"outlet_pitching_area_m2",
):
assert gone not in options, f"pipe.{gone}는 보호공으로 합쳐졌다"
# 기본 치수 — 길이 10m·높이 2.5m.
for side in ("inlet", "outlet"):
assert options[f"{side}_revet_length_m"].default == 10
assert options[f"{side}_revet_height_m"].default == 2.5
# 유무·길이·관경·유입 구조 = b05, 형태·치수 제원 = detail.
for key in ("inlet_type", "inlet_revet_length_m", "outlet_revet_length_m"):
assert options[key].phase == "b05", f"pipe.{key}는 b05 phase"
assert options["pipe_kind"].default == "파형강관"
for key in (
"inlet_basin_form",
"inlet_basin_material",
"inlet_revet_form",
"outlet_revet_form",
"outlet_revet_height_m",
):
assert options[key].phase == "detail", f"pipe.{key}는 detail phase"
def test_revetment_range_options_like_slope_group():
"""독립 기슭막이(D4) — C군과 같은 범위 계산 방식(2026-08-19 사용자 지시 3).
옵션 = 형태·높이·길이·기준측점 전/후, 기본 돌쌓기(메)·2.5·10·5·5. 보호공(돌붙임)
계열은 삭제 — 기슭막이는 사면 공정이다. 형태 선택지는 배수관 부속과 공유.
2026-08-28 이관: 정본이 관 지점(`pipe_points`)으로 옮겨가 배치형태가 점형이 됐다.
구간은 여기 옵션(전/후)이 만든다 — 관 시설의 start_m/end_m가 그 값이다."""
by_id = {item.type_id: item for item in load_structure_types()}
item = by_id["revetment"]
options = {option.key: option for option in item.options}
pipe_options = {option.key: option for option in by_id["pipe"].options}
assert item.placement == "point" and item.managed_by == "pipe_points"
expected = {"height_m": 2.5, "length_m": 10, "before_m": 5, "after_m": 5}
for key, default in expected.items():
assert key in options, f"revetment.{key} 옵션 없음"
assert options[key].phase == "b05" and not options[key].required, key
assert options[key].default == default, f"revetment.{key} 기본값"
assert options["form"].phase == "b05" and options["form"].default == "돌쌓기(메)"
for gone in ("protection", "protection_area_m2", "protection_width_m"):
assert gone not in options, f"revetment.{gone}는 삭제됐다"
# 같은 선택지를 써야 배수관 부속과 수량 산출이 어긋나지 않는다.
assert options["form"].choices == pipe_options["inlet_revet_form"].choices
def test_bed_sill_revived_as_point_type():
"""바닥막이 부활(2026-08-19 사용자 지시 2·4·5) — D군 점형, 기준측점만.
옵션 = 형태(돌붙임 찰/메)·면적·높이, 기본 돌붙임(메)·10㎡·0.3m."""
by_id = {item.type_id: item for item in load_structure_types()}
item = by_id["bed_sill"]
assert item.group == "D" and item.placement == "point" and item.enabled
options = {option.key: option for option in item.options}
assert options["form"].choices == ["돌붙임(찰)", "돌붙임(메)"]
assert options["form"].default == "돌붙임(메)" and options["form"].phase == "b05"
assert options["area_m2"].default == 10 and options["area_m2"].unit == ""
assert options["height_m"].default == 0.3
for option in item.options:
# 돌 조달·전면 기울기(2026-09-09 신설)는 상세 제원이라 detail phase 다
# — B05 는 유무·위치만 받는다.
if option.key in {
"stone_supply",
"face_slope_ratio",
"foundation",
"stone_coeff_basis",
"fill_concrete_mpa",
}:
continue
assert option.phase == "b05" and not option.required, option.key
def test_box_culvert_wing_wall_options_defined():
"""BOX암거 날개벽 — 유입·유출 개별: 설치 유무 + 짧은쪽 높이/길이/각도.
2026-08-17 사용자 확정으로 기본값이 붙었다: 설치 있음·높이 1m·길이 2m·각도 45°.
기본값이 있으니 required는 풀고, B05 서브폼이 실제로 받는 값이라 phase도 b05다
(이전의 detail 표기는 폼과 어긋난 정본이었다).
"""
by_id = {item.type_id: item for item in load_structure_types()}
options = {option.key: option for option in by_id["box_culvert"].options}
expected = {"height_m": 1, "length_m": 2, "angle_deg": 45}
for side in ("in", "out"):
toggle = options[f"wing_{side}"]
assert toggle.phase == "b05" and not toggle.required
assert toggle.choices == ["있음", "없음"]
assert toggle.default == "있음", f"wing_{side} 기본 설치"
for dim, value in expected.items():
option = options[f"wing_{side}_{dim}"]
assert option.phase == "b05" and not option.required, f"wing_{side}_{dim}"
assert option.default == value, f"wing_{side}_{dim} 기본값"
def test_box_culvert_body_size_defaults_to_first_preset():
"""BOX암거 본체 — 프리셋 2.0×2.0·3.0×3.0 중 첫 항목이 기본값 (2026-08-17 확정).
프리셋 선택은 폼 전용이라 레지스트리에 별도 키를 두지 않는다 — 저장 키는
프리셋이든 사용자 지정이든 body_width_m·body_height_m 숫자 그대로다.
"""
by_id = {item.type_id: item for item in load_structure_types()}
options = {option.key: option for option in by_id["box_culvert"].options}
for key in ("body_width_m", "body_height_m"):
assert options[key].phase == "b05" and not options[key].required
assert options[key].default == 2.0, key
assert options[key].input == "number"
# 규격 프리셋 전용 키를 만들면 하류가 폭·높이 대신 문자열을 읽게 된다.
assert "body_size" not in options
@@ -0,0 +1,298 @@
"""B05 구조물 정본 저장·조회·검증 테스트 (크로스체크 지적 2 반영 강화판)."""
import json
import pytest
from B05_Profile.B05_Profile_Structures_Repository import (
StructureRevisionConflict,
load_structures,
save_structures,
structures_file_path,
)
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
@pytest.fixture()
def project_root(tmp_path):
return str(tmp_path / "project")
def _point(chainage=100.0, type_id="erosion_check", **overrides):
# 골막이 형식(재료)은 설계자 선택이라 필수 — 길이·높이는 사용자 확정 기본값이 있다.
data = {
"type_id": type_id,
"placement": "point",
"chainage_m": chainage,
"options": {"form": ""},
}
data.update(overrides)
return StructureInstance.model_validate(data)
def _interval(start=10.0, end=40.0, type_id="ditch_side", **overrides):
# 측구 형식도 필수 선택 — 깊이는 별표2 단일값이라 기본값이 있다.
data = {
"type_id": type_id,
"placement": "interval",
"start_m": start,
"end_m": end,
"options": {"form": "일반형(제형)"},
}
data.update(overrides)
return StructureInstance.model_validate(data)
# ── 기본 왕복·판번호 (기존 회귀) ────────────────────────────────────────────
def test_load_empty_project_returns_revision_zero(project_root):
assert load_structures(project_root) == (0, [])
def test_disabled_type_structures_still_save_and_load(project_root):
"""B06 이관으로 enabled=False가 된 타입(예: 측구)의 기존 저장분 — 백엔드는
전체 레지스트리로 검증하므로 로드·재저장이 계속 통과해야 한다(2026-08-17 재편).
화면 노출만 프론트 enabled 필터가 막는다."""
revision = save_structures(project_root, [_interval(type_id="ditch_side")], base_revision=0)
assert revision == 1
_, loaded = load_structures(project_root)
assert loaded[0].type_id == "ditch_side"
def test_save_then_load_roundtrip(project_root):
revision = save_structures(project_root, [_point(), _interval()], base_revision=0)
assert revision == 1
loaded_revision, loaded = load_structures(project_root)
assert loaded_revision == 1
assert len(loaded) == 2
def test_save_assigns_and_keeps_structure_ids(project_root):
save_structures(project_root, [_point()], base_revision=0)
_, loaded = load_structures(project_root)
kept = loaded[0].structure_id
assert kept
loaded[0].chainage_m = 150.0
save_structures(project_root, loaded, base_revision=1)
_, again = load_structures(project_root)
assert again[0].structure_id == kept
def test_stale_base_revision_raises_conflict(project_root):
save_structures(project_root, [_point()], base_revision=0)
with pytest.raises(StructureRevisionConflict):
save_structures(project_root, [_point(300.0)], base_revision=0)
def test_pipe_managed_types_are_rejected(project_root):
with pytest.raises(ValueError):
save_structures(project_root, [_point(50.0, type_id="pipe")], base_revision=0)
def test_unknown_type_id_is_rejected(project_root):
with pytest.raises(ValueError):
save_structures(project_root, [_point(50.0, type_id="no_such_type")], base_revision=0)
def test_saved_file_is_valid_json_with_revision(project_root):
save_structures(project_root, [_point()], base_revision=0)
with open(structures_file_path(project_root), encoding="utf-8") as handle:
payload = json.load(handle)
assert payload["revision"] == 1
def test_corrupt_file_does_not_crash_load(project_root):
save_structures(project_root, [_point()], base_revision=0)
with open(structures_file_path(project_root), "w", encoding="utf-8") as handle:
handle.write("{ broken json")
assert load_structures(project_root) == (0, [])
# ── 폐지 필드(설치측·이격) 이관 — 2026-08-17 사용자 지시 ────────────────────
def _write_raw(project_root, structures):
save_structures(project_root, [_point()], base_revision=0)
with open(structures_file_path(project_root), "w", encoding="utf-8") as handle:
json.dump({"revision": 3, "structures": structures}, handle, ensure_ascii=False)
def test_legacy_side_and_offset_keys_are_dropped_on_load(project_root):
# 구 저장분을 그대로 넘기면 extra="forbid"에 걸려 정본이 통째로 버려진다.
_write_raw(
project_root,
[
{
"structure_id": "abc",
"type_id": "erosion_check",
"placement": "point",
"chainage_m": 100.0,
"side": "left",
"offset_m": 2.5,
"options": {"form": ""},
}
],
)
revision, loaded = load_structures(project_root)
assert revision == 3
assert len(loaded) == 1
assert loaded[0].structure_id == "abc"
assert not hasattr(loaded[0], "side")
assert not hasattr(loaded[0], "offset_m")
def test_legacy_keys_disappear_from_file_after_next_save(project_root):
_write_raw(
project_root,
[
{
"type_id": "erosion_check",
"placement": "point",
"chainage_m": 100.0,
"side": "left",
"offset_m": 2.5,
"options": {"form": ""},
}
],
)
_, loaded = load_structures(project_root)
save_structures(project_root, loaded, base_revision=3)
with open(structures_file_path(project_root), encoding="utf-8") as handle:
payload = json.load(handle)
assert "side" not in payload["structures"][0]
assert "offset_m" not in payload["structures"][0]
def test_other_unknown_keys_are_still_rejected(project_root):
# 두 키만 떨어낸다 — 오타 키까지 통과시키면 정본이 조용히 썩는다.
_write_raw(
project_root,
[
{
"type_id": "erosion_check",
"placement": "point",
"chainage_m": 100.0,
"sidee": "left",
"options": {"form": ""},
}
],
)
assert load_structures(project_root) == (0, [])
# ── 크로스체크 지적 2: 서버 검증 강화 ──────────────────────────────────────
def test_placement_mismatching_registry_is_rejected(project_root):
"""옹벽(레지스트리 interval)을 point로 보내면 거절해야 한다."""
wall_as_point = _point(50.0, type_id="retaining_wall", options={"height_m": 2.0})
with pytest.raises(ValueError, match="배치형태"):
save_structures(project_root, [wall_as_point], base_revision=0)
def test_undefined_option_key_is_rejected(project_root):
bad = _point(options={"form": "", "no_such_option": 1})
with pytest.raises(ValueError, match="옵션"):
save_structures(project_root, [bad], base_revision=0)
def test_negative_number_option_is_rejected(project_root):
bad = _point(options={"form": "", "height_m": -999})
with pytest.raises(ValueError, match="0 이상"):
save_structures(project_root, [bad], base_revision=0)
def test_non_numeric_number_option_is_rejected(project_root):
bad = _point(options={"form": "", "height_m": "높음"})
with pytest.raises(ValueError, match="숫자"):
save_structures(project_root, [bad], base_revision=0)
def test_select_option_outside_choices_is_rejected(project_root):
bad = _interval(options={"form": "없는형식"})
with pytest.raises(ValueError, match="선택지"):
save_structures(project_root, [bad], base_revision=0)
def test_detail_required_option_missing_is_accepted_at_b05(project_root):
"""옹벽 형식은 phase=detail — B05는 유무·위치 단계라 비워도 저장된다.
필수 원칙(미확정 기본값 = 필수)은 유지되되 강제 시점이 B06/B07로 옮겨졌다
(2026-08-17 사용자 확정). 길이·높이는 2026-08-19 지시로 B05 필수가 됐으므로
채워서 보낸다 — 비운 것은 detail(형식)뿐이다.
"""
wall = _interval(type_id="retaining_wall", options={"length_m": 30.0, "height_m": 2.5})
assert save_structures(project_root, [wall], base_revision=0) == 1
def test_b05_phase_required_option_is_still_enforced(project_root):
"""phase=b05인 필수 옵션이 생기면 여전히 강제된다 — 완화는 detail에 한한다."""
from B05_Profile import B05_Profile_Structures_Repository as repo_module
from B05_Profile.B05_Profile_Structures_Schema import (
StructureOptionField,
StructureType,
)
fake = StructureType(
type_id="fake_b05_required",
group="Z",
name="테스트",
placement="point",
options=[
StructureOptionField(
key="must", label="필수", input="number", required=True, phase="b05"
)
],
)
original = repo_module.structure_type_map
def patched():
mapping = dict(original())
mapping[fake.type_id] = fake
return mapping
repo_module.structure_type_map = patched
try:
bad = _point(type_id="fake_b05_required", options={})
with pytest.raises(ValueError, match="필수"):
save_structures(project_root, [bad], base_revision=0)
finally:
repo_module.structure_type_map = original
def test_required_option_present_is_accepted(project_root):
wall = _interval(
type_id="retaining_wall",
options={"form": "반중력식", "length_m": 30.0, "height_m": 2.5},
)
assert save_structures(project_root, [wall], base_revision=0) == 1
def test_duplicate_structure_ids_are_rejected(project_root):
first = _point()
second = _point(200.0)
first.structure_id = second.structure_id = "dup"
with pytest.raises(ValueError, match="중복"):
save_structures(project_root, [first, second], base_revision=0)
def test_position_beyond_route_length_is_rejected(project_root):
with pytest.raises(ValueError, match="연장"):
save_structures(project_root, [_point(999.0)], base_revision=0, max_chainage_m=500.0)
with pytest.raises(ValueError, match="연장"):
save_structures(
project_root, [_interval(490.0, 600.0)], base_revision=0, max_chainage_m=500.0
)
def test_position_within_route_length_is_accepted(project_root):
assert (
save_structures(project_root, [_point(499.0)], base_revision=0, max_chainage_m=500.0) == 1
)
def test_no_route_length_skips_range_check(project_root):
"""노선 연장을 모르면(None) 범위 검증은 건너뛴다 — 저장 자체는 허용."""
assert save_structures(project_root, [_point(9999.0)], base_revision=0) == 1
@@ -0,0 +1,293 @@
"""B05 구조물 라우터 테스트 (지적 5 반영: 404·STALE 실패 정합 포함)."""
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import B05_Profile.B05_Profile_Structures_Router as router_module
from common_util.common_util_drainage_pipes import pipe_points_path_in
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
@pytest.fixture()
def invalidated_calls():
return []
def _make_client(tmp_path, monkeypatch, invalidated_calls, *, stale_ok=True, route_length=None):
async def fake_project_root(project_id):
return str(tmp_path / "project")
async def fake_invalidate(project_id):
invalidated_calls.append(str(project_id))
return stale_ok
async def fake_route_length(project_id):
return route_length
monkeypatch.setattr(router_module, "_project_root", fake_project_root)
monkeypatch.setattr(router_module, "_invalidate_downstream", fake_invalidate)
monkeypatch.setattr(router_module, "_route_length", fake_route_length)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
@pytest.fixture()
def client(tmp_path, monkeypatch, invalidated_calls):
return _make_client(tmp_path, monkeypatch, invalidated_calls)
def _point_payload(base_revision=0, chainage=100.0, type_id="erosion_check"):
return {
"base_revision": base_revision,
"structures": [
{
"type_id": type_id,
"placement": "point",
"chainage_m": chainage,
"options": {"form": ""},
}
],
}
def test_structure_types_endpoint_returns_registry(client):
response = client.get("/api/projects/structure-types")
assert response.status_code == 200
body = response.json()
assert body["schema_version"] >= 1
assert len(body["types"]) >= 30
# 2026-08-19 C군 개편: 높이는 사용자 확정 기본값 2.5를 갖고 필수 강제는 풀렸다
# (기본값 원칙 ② — 구 저장분 저장 거부 재발 방지). 상세 정책은 registry_policy 테스트.
wall = next(item for item in body["types"] if item["type_id"] == "retaining_wall")
height = next(option for option in wall["options"] if option["key"] == "height_m")
assert height["required"] is False and height["default"] == 2.5
def test_read_structures_empty_project(client):
response = client.get(f"/api/projects/{PROJECT_ID}/route/structures")
assert response.status_code == 200
assert response.json()["revision"] == 0
def test_save_then_read_roundtrip(client):
saved = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
assert saved.status_code == 200
assert saved.json()["revision"] == 1
read = client.get(f"/api/projects/{PROJECT_ID}/route/structures")
assert read.json()["structures"][0]["structure_id"]
def test_stale_revision_returns_409(client):
client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
conflict = client.put(
f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload(base_revision=0)
)
assert conflict.status_code == 409
assert conflict.json()["revision"] == 1
def test_pipe_type_returns_400(client):
response = client.put(
f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload(type_id="pipe")
)
assert response.status_code == 400
def test_invalid_placement_returns_422(client):
response = client.put(
f"/api/projects/{PROJECT_ID}/route/structures",
json={
"base_revision": 0,
"structures": [
{
"type_id": "ditch_side",
"placement": "interval",
"start_m": 50.0,
"end_m": 10.0,
"options": {"form": "L형"},
}
],
},
)
assert response.status_code == 422
def test_registry_placement_mismatch_returns_400(client):
"""레지스트리와 다른 배치형태(옹벽을 point로)는 400."""
response = client.put(
f"/api/projects/{PROJECT_ID}/route/structures",
json=_point_payload(type_id="retaining_wall"),
)
assert response.status_code == 400
def test_missing_project_returns_404(tmp_path, monkeypatch, invalidated_calls):
"""없는 프로젝트 — 저장 경로 조회가 LookupError를 던져도 404로 응답해야 한다."""
client = _make_client(tmp_path, monkeypatch, invalidated_calls)
async def raising_project_root(project_id):
raise LookupError("프로젝트 또는 프로젝트 저장 경로를 찾을 수 없습니다.")
monkeypatch.setattr(router_module, "_project_root", raising_project_root)
assert client.get(f"/api/projects/{PROJECT_ID}/route/structures").status_code == 404
assert (
client.put(
f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload()
).status_code
== 404
)
def test_design_change_invalidates_downstream(client, invalidated_calls):
client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
moved = client.put(
f"/api/projects/{PROJECT_ID}/route/structures",
json=_point_payload(base_revision=1, chainage=180.0),
)
assert moved.json()["invalidated_downstream"] is True
assert invalidated_calls == [PROJECT_ID, PROJECT_ID]
def test_unchanged_save_does_not_invalidate_downstream(client, invalidated_calls):
saved = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
invalidated_calls.clear()
again = client.put(
f"/api/projects/{PROJECT_ID}/route/structures",
json={"base_revision": saved.json()["revision"], "structures": stored},
)
assert again.json()["invalidated_downstream"] is False
assert invalidated_calls == []
def test_stale_failure_is_not_reported_as_success(tmp_path, monkeypatch, invalidated_calls):
"""STALE 갱신이 실패하면 invalidated_downstream=false — 성공한 척 금지(지적 5).
화면이 "필요했는데 못 했다"를 구분해 알릴 수 있도록 needs 플래그도 함께 준다
(2차 크로스체크 지적 3).
"""
client = _make_client(tmp_path, monkeypatch, invalidated_calls, stale_ok=False)
response = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
body = response.json()
assert response.status_code == 200 # 저장 자체는 성공
assert body["invalidated_downstream"] is False
assert body["needs_downstream_invalidation"] is True
def test_unchanged_save_needs_no_invalidation_flag(tmp_path, monkeypatch, invalidated_calls):
"""설계 영향이 없으면 needs 플래그도 false — 실패 안내가 뜨지 않아야 한다."""
client = _make_client(tmp_path, monkeypatch, invalidated_calls, stale_ok=False)
saved = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
again = client.put(
f"/api/projects/{PROJECT_ID}/route/structures",
json={"base_revision": saved.json()["revision"], "structures": stored},
)
assert again.json()["needs_downstream_invalidation"] is False
def test_route_length_limits_position(tmp_path, monkeypatch, invalidated_calls):
"""노선 연장이 있으면 범위 밖 배치는 400."""
client = _make_client(tmp_path, monkeypatch, invalidated_calls, route_length=500.0)
ok = client.put(
f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload(chainage=499.0)
)
assert ok.status_code == 200
bad = client.put(
f"/api/projects/{PROJECT_ID}/route/structures",
json=_point_payload(base_revision=1, chainage=999.0),
)
assert bad.status_code == 400
# ── 구 비정규 측점 이관 (2026-08-17 컨테이너 병합 3단계) ────────────────────
def _migrate_payload():
return {
"stations": [
{"chainage_m": 60.0, "structure": "파형강관 D800"}, # 배관 — 제외돼야 한다
{"chainage_m": 120.0, "structure": "기성막이"},
{"chainage_m": 200.0, "structure": "대피로 2.0m"},
{"chainage_m": 260.0, "structure": "낙석방지책"}, # 기타
]
}
def _pipe_points_file(tmp_path, points=()):
"""관 지점 정본을 미리 깔아 둔다 — 기슭막이 이관은 이 파일이 있어야 나간다."""
path = pipe_points_path_in(tmp_path / "project")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"route_signature": "test-signature", "points": list(points)}),
encoding="utf-8",
)
return path
def test_migrate_converts_legacy_stations(client, tmp_path):
"""기슭막이는 관 지점 정본으로, 나머지는 구조물 정본으로 갈린다(2026-09-01 확정)."""
path = _pipe_points_file(tmp_path)
response = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload()
)
assert response.status_code == 200
body = response.json()
assert body["migrated"] == 3 # 배관 제외 (구조물 2 + 관 시설 1)
assert body["pipe_facilities"] == 1
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
type_ids = sorted(item["type_id"] for item in stored)
assert type_ids == ["etc", "refuge"] # 기슭막이는 구조물 목록에 없다
(pipe,) = json.loads(path.read_text(encoding="utf-8"))["points"]
assert pipe["facility"] == "revetment" and pipe["chainage_m"] == 120.0
# 구간·제원은 레지스트리 기본값 승계 — 기준측점 전/후 5m.
assert (pipe["start_m"], pipe["end_m"]) == (115.0, 125.0)
assert pipe["options"]["form"] == "돌쌓기(메)" and pipe["options"]["height_m"] == 2.5
def test_migrate_without_pipe_points_defers_revetment(client):
"""관 지점 정본이 없으면 기슭막이만 미룬다 — 원천 측점이 남아 다음에 다시 이관된다."""
response = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload()
)
assert response.status_code == 200
assert response.json()["migrated"] == 2 and response.json()["pipe_facilities"] == 0
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
assert sorted(item["type_id"] for item in stored) == ["etc", "refuge"]
def test_migrate_is_idempotent(client, tmp_path):
path = _pipe_points_file(tmp_path)
client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload())
again = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload()
)
assert again.status_code == 200
assert again.json()["migrated"] == 0
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
assert len(stored) == 2
assert len(json.loads(path.read_text(encoding="utf-8"))["points"]) == 1
def test_migrate_keeps_existing_structures(client, tmp_path):
_pipe_points_file(tmp_path)
client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
response = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload()
)
assert response.json()["migrated"] == 3
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
assert len(stored) == 3 # 기존 1 + 이관 2(기슭막이는 관 지점으로)
def test_migrate_empty_list_is_noop(client):
response = client.post(
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"stations": []}
)
assert response.status_code == 200
assert response.json()["migrated"] == 0
@@ -0,0 +1,115 @@
"""구간형 기준점·옵션 phase — 스키마 확장 (PLAN 2026-08-17 컨테이너 병합 1단계).
사용자 확정: 점형 = 기준 측점이 마킹 위치. 구간형 = **기준점에 마킹** + 시작·종료 측점.
기존 스키마는 구간형에 chainage_m을 금지하고 start_m에 마킹했다 — 기준점을 허용하고
시작≤기준≤종료를 검증한다. 기존 저장분(기준점 없음)은 start_m으로 채운다(하위 호환).
"""
import pytest
from pydantic import ValidationError
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
StructureOptionField,
)
def _interval(**overrides):
data = {
"type_id": "ditch_side",
"placement": "interval",
"start_m": 100.0,
"end_m": 140.0,
"options": {},
}
data.update(overrides)
return StructureInstance.model_validate(data)
# ── 구간형 기준점 ───────────────────────────────────────────────────────────
def test_interval_accepts_anchor_within_span():
item = _interval(chainage_m=115.0)
assert item.chainage_m == 115.0
assert item.anchor_m() == 115.0
def test_interval_without_anchor_defaults_to_start():
"""기존 저장분(기준점 없음) — start_m이 기준점이 된다 (하위 호환)."""
item = _interval()
assert item.chainage_m == 100.0
assert item.anchor_m() == 100.0
def test_interval_anchor_outside_span_is_rejected():
with pytest.raises(ValidationError, match="기준점"):
_interval(chainage_m=90.0)
with pytest.raises(ValidationError, match="기준점"):
_interval(chainage_m=141.0)
def test_interval_anchor_at_boundaries_is_accepted():
assert _interval(chainage_m=100.0).anchor_m() == 100.0
assert _interval(chainage_m=140.0).anchor_m() == 140.0
def test_interval_still_requires_ordered_span():
with pytest.raises(ValidationError):
_interval(start_m=140.0, end_m=100.0)
with pytest.raises(ValidationError):
_interval(start_m=100.0, end_m=None)
# ── 점형·부지형은 기존 규칙 유지 ────────────────────────────────────────────
def test_point_still_rejects_span_fields():
with pytest.raises(ValidationError):
StructureInstance.model_validate(
{
"type_id": "erosion_check",
"placement": "point",
"chainage_m": 50.0,
"start_m": 40.0,
"end_m": 60.0,
"options": {},
}
)
def test_point_anchor_is_chainage():
item = StructureInstance.model_validate(
{"type_id": "erosion_check", "placement": "point", "chainage_m": 50.0, "options": {}}
)
assert item.anchor_m() == 50.0
# ── 옵션 phase — B05(배치) / detail(B06·B07 상세) ──────────────────────────
def test_option_phase_defaults_to_b05():
field = StructureOptionField.model_validate(
{"key": "form", "label": "형식", "input": "select", "choices": ["A"]}
)
assert field.phase == "b05"
def test_option_phase_detail_roundtrip():
field = StructureOptionField.model_validate(
{
"key": "height_m",
"label": "높이",
"input": "number",
"required": True,
"phase": "detail",
}
)
assert field.phase == "detail" and field.required is True
def test_option_phase_rejects_unknown_value():
with pytest.raises(ValidationError):
StructureOptionField.model_validate(
{"key": "x", "label": "x", "input": "text", "phase": "b07"}
)
@@ -0,0 +1,82 @@
"""층따기 밑수 — 성토부 아래 원지반의 지표면 길이(m).
근거(2026-09-09 사용자 확정: 단위 ㎡) — 별표2 · 임도기술교본 6장 4절
「경사가 1:4보다 급한 지반 위에 성토를 하는 경우 원지반 표면에 층따기」.
면적은 측점 사이를 이어 B08 이 내고, 여기서는 **측점 하나의 밑수 길이**만 낸다.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Areas import ( # noqa: E402
_BENCH_CUT_MIN_GROUND_SLOPE,
_bench_cut_length,
)
def test_완만한_지반은_층따기를_안_한다() -> None:
"""1:4(25%)보다 완만하면 대상이 아니다 — 성토가 있어도 0."""
offsets = [0.0, 1.0, 2.0]
grounds = [0.0, 0.2, 0.4] # 20 % 경사
diffs = [-1.0, -1.0, -1.0] # 전 구간 성토
assert _bench_cut_length(offsets, grounds, diffs) == 0.0
def test_급한_지반의_성토부만_빗변으로_센다() -> None:
offsets = [0.0, 1.0, 2.0]
grounds = [0.0, 0.5, 1.0] # 50 % 경사 — 대상
diffs = [-1.0, -1.0, -1.0]
expected = 2 * math.hypot(1.0, 0.5)
assert _bench_cut_length(offsets, grounds, diffs) == expected
def test_절토부는_안_센다() -> None:
"""층따기는 성토부 아래 원지반에만 한다."""
offsets = [0.0, 1.0]
grounds = [0.0, 0.5]
diffs = [1.0, 1.0] # 절토
assert _bench_cut_length(offsets, grounds, diffs) == 0.0
def test_절성토_경계는_영교점까지만_센다() -> None:
offsets = [0.0, 1.0]
grounds = [0.0, 0.5]
diffs = [1.0, -1.0] # 가운데에서 절토→성토
expected = math.hypot(1.0, 0.5) * 0.5
assert math.isclose(_bench_cut_length(offsets, grounds, diffs), expected, rel_tol=1e-12)
def test_내리막도_오르막과_같이_센다() -> None:
"""기울기의 방향이 아니라 급한지만 본다."""
up = _bench_cut_length([0.0, 1.0], [0.0, 0.5], [-1.0, -1.0])
down = _bench_cut_length([0.0, 1.0], [0.5, 0.0], [-1.0, -1.0])
assert math.isclose(up, down, rel_tol=1e-12)
def test_문턱값이_법정_1대4_이다() -> None:
assert _BENCH_CUT_MIN_GROUND_SLOPE == 0.25
def test_설계_결과에_밑수가_실린다() -> None:
"""엔진 결과 dict 에 키가 있어야 B08 이 읽는다."""
from B06_Section.B06_Section_Engine_Design import compute_cross_design
ground = [
{"offset_m": offset / 2.0, "elevation_m": 100.0 - (offset / 2.0) * 0.5}
for offset in range(-24, 25)
]
result = compute_cross_design(
ground,
100.0,
ground_type="soil",
section_mode="left_cut",
)
assert "bench_cut_length_m" in result
assert result["bench_cut_length_m"] >= 0.0
+66
View File
@@ -0,0 +1,66 @@
"""B06 BOX암거 세트(백엔드) 자체검증 — 2026-08-25.
확인 대상:
· `_box_set()`이 정본·레지스트리 기본값으로 제원을 만든다(두께는 세월교 승계).
· 날개벽 각도가 저판 편측 연장(길이×cos각)을 만든다 — 세월교와 같은 산식.
· `attach_culvert_sets()`가 BOX암거를 **소유 측점 한 곳에만** 붙이고
`box` 키로 실어 배수관·세월교 소비처와 섞이지 않는다.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
BOX_COVER_M,
FORD_SLAB_THICKNESS_M,
FORD_WALL_THICKNESS_M,
_box_set,
attach_culvert_sets,
)
def test_box_set_defaults() -> None:
"""저장 옵션이 없으면 레지스트리 기본 2.0×2.0에 세월교 두께를 얹는다."""
spec = _box_set(None)
assert spec["type"] == "box"
assert spec["inner_width_m"] == 2.0
assert spec["inner_height_m"] == 2.0
assert spec["wall_thickness_m"] == FORD_WALL_THICKNESS_M
assert spec["slab_thickness_m"] == FORD_SLAB_THICKNESS_M
assert spec["top_thickness_m"] == FORD_SLAB_THICKNESS_M
assert spec["cover_m"] == BOX_COVER_M
# 도로 방향 길이 = 내공 폭 + 측벽 두 장.
assert spec["span_m"] == 2.0 + 2 * FORD_WALL_THICKNESS_M
def test_box_wing_extends_slab() -> None:
"""저판 편측 연장 = 날개벽 길이 × cos(각도). 세월교와 같은 산식이다."""
spec = _box_set({"body_width_m": 3, "body_height_m": 3, "wing_in_length_m": 2})
assert spec["span_m"] == 3 + 2 * FORD_WALL_THICKNESS_M
assert spec["wing_in"]["slab_extend_m"] == round(2 * math.cos(math.radians(45)), 3)
def test_attach_box_only_owner_station(tmp_path: Path) -> None:
"""BOX암거는 **소유 측점 한 곳에만** `box` 키로 붙는다(2026-08-25 사용자 확정).
구체 폭만큼 옆 측점까지 붙이면 횡단도가 한 벌 더 그려지고 3D 솔리드도 겹친다.
"""
edits = tmp_path / "B04_PreProcess" / "drainage" / "edits"
edits.mkdir(parents=True)
(edits / "pipe_points.json").write_text(
'{"points": [{"chainage_m": 100.0, "facility": "box_culvert", "options": {}}]}',
encoding="utf-8",
)
sections = [{"chainage_m": value} for value in (98.5, 99.0, 100.0, 101.0, 101.5)]
attached = attach_culvert_sets(tmp_path, sections)
assert attached == 1
assert [("box" in section) for section in sections] == [False, False, True, False, False]
assert all("culvert" not in section and "ford" not in section for section in sections)
+142
View File
@@ -0,0 +1,142 @@
"""측점 설계 묶음 저장 — 한 행짜리 함수와 **같은 결과**를 내는지 본다.
왜 (2026-09-06) — [저장]이 측점마다 `SELECT`+`UPDATE` 두 왕복을 냈고, 원격 DB 라
22행이면 670ms 였다. 세 문장으로 묶었는데 값이 달라지면 안 된다.
DB 는 가짜 커서로 대신한다 — 검사 대상은 **어떤 SQL 을 몇 번 내고, 어떤 JSON 이 되는가**다.
"""
import asyncio
import json
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
class FakeCursor:
def __init__(self, rows):
self._rows = rows
self.calls = []
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def execute(self, sql, params=None):
self.calls.append((" ".join(sql.split()), list(params) if params else []))
async def fetchall(self):
return self._rows
class FakeConnection:
def __init__(self, rows):
self.cursor_obj = FakeCursor(rows)
def cursor(self):
return self.cursor_obj
def _rows():
"""측점 셋 — 하나는 design 이 이미 있고, 하나는 비었고, 하나는 문자열 JSON."""
return [
(11, 0.0, {"design": {"ground_type": "soil"}, "summary": "keep"}),
(12, 20.0, {}),
(13, 40.0, json.dumps({"design": {"paved": True}})),
]
def _written(connection):
"""UPDATE 문에 실린 (id, data) 짝을 돌려준다."""
for sql, params in connection.cursor_obj.calls:
if sql.startswith("UPDATE cross_sections SET data = CASE"):
pairs = {}
# params = [id, blob, id, blob, …, id, id, …]
half = len(params) // 3 * 2
for index in range(0, half, 2):
pairs[params[index]] = json.loads(params[index + 1])
return pairs
return {}
def test_한_문장으로_여러_행을_쓴다():
connection = FakeConnection(_rows())
written = asyncio.run(
merge_cross_section_designs(
connection,
route_id=7,
entries=[(0.0, {"a": 1}), (20.0, {"b": 2}), (40.0, {"c": 3})],
replace=True,
)
)
assert written == 3
sqls = [sql for sql, _ in connection.cursor_obj.calls]
# SELECT 한 번 + UPDATE 한 번 = 왕복 두 번. 행 수와 무관해야 한다.
assert len(sqls) == 2, sqls
assert sqls[0].startswith("SELECT id, chainage_m, data")
assert _written(connection) == {
11: {"design": {"a": 1}, "summary": "keep"},
12: {"design": {"b": 2}},
13: {"design": {"c": 3}},
}
def test_patch_는_기존_design_을_보존한다():
connection = FakeConnection(_rows())
asyncio.run(
merge_cross_section_designs(
connection,
route_id=7,
entries=[(0.0, {"cut_area_m2": 1.5}), (40.0, {"cut_area_m2": 2.5})],
replace=False,
)
)
written = _written(connection)
# replace=False 는 키만 얹는다 — 옛 design 과 형제 키(summary)가 남아야 한다.
assert written[11] == {
"design": {"ground_type": "soil", "cut_area_m2": 1.5},
"summary": "keep",
}
assert written[13] == {"design": {"paved": True, "cut_area_m2": 2.5}}
def test_측점_허용오차는_1cm():
connection = FakeConnection(_rows())
asyncio.run(
merge_cross_section_designs(
connection, route_id=7, entries=[(20.005, {"x": 1})], replace=True
)
)
assert list(_written(connection)) == [12]
far = FakeConnection(_rows())
asyncio.run(
merge_cross_section_designs(far, route_id=7, entries=[(20.5, {"x": 1})], replace=True)
)
# 1cm 밖이면 붙일 행이 없다 — project_id 가 없으므로 아무 것도 안 쓴다.
assert not [sql for sql, _ in far.cursor_obj.calls if sql.startswith("UPDATE")]
def test_행이_없으면_project_id_로_새로_만든다():
connection = FakeConnection(_rows())
written = asyncio.run(
merge_cross_section_designs(
connection,
route_id=7,
entries=[(999.0, {"x": 1})],
replace=True,
project_id="11111111-2222-3333-4444-555555555555",
)
)
assert written == 1
inserts = [sql for sql, _ in connection.cursor_obj.calls if sql.startswith("INSERT")]
assert len(inserts) == 1
def test_빈_목록은_왕복을_내지_않는다():
connection = FakeConnection(_rows())
assert (
asyncio.run(merge_cross_section_designs(connection, route_id=7, entries=[], replace=True))
== 0
)
assert connection.cursor_obj.calls == []
@@ -0,0 +1,355 @@
"""횡단 설계선·단면적 거울 테스트 — 파이썬 엔진과 TS 짝이 **같은 값**을 내는지 대조한다.
짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`)
↔ `common_util/common_util_cross_design.ts` (`computeCrossDesign`)
(단면적은 그 안에서 `..._Engine_Areas.py` ↔ `..._cross_design_areas.ts` 를 탄다)
**계산 자리 규칙**(CLAUDE.md 5장)이 「초기값은 서버, 조작 중은 브라우저, 코드는 한 벌」이라
이 짝은 반드시 같은 값을 내야 한다. 한쪽만 고치면 **화면에 보이는 수량과 저장되는 수량이
갈린다** — 그런데 이 짝에는 거울 테스트가 없었다(2026-09-07 신설).
표준단면 수치는 파이썬 config 를 그대로 TS 에 넘긴다 — 출처가 같아야 기본값 차이가 아니라
**산식 차이**만 잡힌다(배수관 세트 거울 테스트와 같은 방식).
"""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
# 값이 소수점 아래에서 갈리는지 보려면 자릿수를 그대로 비교해야 한다. 두 쪽 다 반올림해
# 내보내므로 완전 일치를 기대하되, 부동소수 마지막 자리만 어긋나는 것은 허용한다.
_TOLERANCE = 1e-9
def _ground(slope: float, *, gap: tuple[float, float] | None = None) -> list[dict]:
"""가로 -12m ~ +12m 를 0.5m 간격으로 훑은 지반선. `gap` 구간은 결측으로 둔다."""
samples = []
offset = -12.0
while offset <= 12.0001:
invalid = gap is not None and gap[0] <= offset <= gap[1]
samples.append(
{
"offset_m": round(offset, 3),
"elevation_m": None if invalid else round(100.0 + slope * offset, 4),
"valid": not invalid,
}
)
offset += 0.5
return samples
# 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교
# 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간.
CASES = [
{
"name": "토사 좌절토 일반측구",
"samples": _ground(0.35),
"design_elevation_m": 100.4,
"options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"},
},
{
"name": "리핑암 우절토 L형측구 2단경사",
"samples": _ground(-0.42),
"design_elevation_m": 99.6,
"options": {
"ground_type": "ripping_rock",
"section_mode": "right_cut",
"ditch_side": "right",
"ditch_type": "l_type",
"rock_boundary_offset_m": -0.8,
"two_stage_slope": True,
},
},
{
"name": "발파암 2단경사 해제",
"samples": _ground(0.55),
"design_elevation_m": 101.2,
"options": {
"ground_type": "blasting_rock",
"section_mode": "left_cut",
"ditch_side": "left",
"rock_boundary_offset_m": -1.4,
"two_stage_slope": False,
},
},
{
"name": "포장 + 측구 없음",
"samples": _ground(0.12),
"design_elevation_m": 100.05,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"paved": True,
"ditch_enabled": False,
},
},
{
"name": "세월교 노면 하강",
"samples": _ground(0.2),
"design_elevation_m": 100.6,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"surface_drop_m": 0.45,
},
},
{
"name": "곡선부 확폭 — 바깥쪽 좌",
"samples": _ground(0.28),
"design_elevation_m": 100.3,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"plan_radius_m": 18.0,
"curve_outer_side": "left",
},
},
{
"name": "곡선부 확폭 — 저장된 확폭량 우",
"samples": _ground(-0.3),
"design_elevation_m": 100.9,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"plan_radius_m": 25.0,
"curve_outer_side": "right",
"curve_widening_m": 0.62,
},
},
{
"name": "지반선 결측 구간(사면이 안 닫힐 수 있음)",
"samples": _ground(0.9, gap=(6.0, 12.0)),
"design_elevation_m": 100.0,
"options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"},
},
{
# 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 1:0.4 를 1:1.0 으로 눕힘.
# 무릎 아래(암)만 바뀌고 위(토사)는 표준 그대로라, 두 쪽이 같은 지점에서 꺾여야 한다.
"name": "암 절토 경사를 측점에서 바꿈",
"samples": _ground(-0.5),
"design_elevation_m": 99.8,
"options": {
"ground_type": "ripping_rock",
"section_mode": "left_cut",
"ditch_side": "left",
"rock_boundary_offset_m": -1.0,
"two_stage_slope": True,
"cut_slope_ratio": 1.0,
},
},
{
# 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토(2026-09-09 사용자 확정).
# ⚠ 그 바깥 성토는 노선 몫이 아니라 사토장 몫이라 `fill_area_m2` 에서 빠진다.
"name": "사토장이 선 측점",
"samples": _ground(-0.5),
"design_elevation_m": 99.8,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"spoil_fill": {"side": "left", "width_m": 4.0},
},
},
{
"name": "사토장 — 기울기를 따로 준 측점",
"samples": _ground(-0.5),
"design_elevation_m": 99.8,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"spoil_fill": {"side": "left", "width_m": 8.0, "slope_ratio_n": 2.0},
},
},
]
_TS_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { computeCrossDesign } from "./common_util_cross_design.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const camel = {
ground_type: "groundType",
section_mode: "sectionMode",
ditch_side: "ditchSide",
ditch_type: "ditchType",
paved: "paved",
rock_boundary_offset_m: "rockBoundaryOffsetM",
two_stage_slope: "twoStageSlope",
cut_slope_ratio: "cutSlopeRatio",
ditch_enabled: "ditchEnabled",
surface_drop_m: "surfaceDropM",
plan_radius_m: "planRadiusM",
curve_outer_side: "curveOuterSide",
curve_widening_m: "curveWideningM",
};
// 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다.
const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" };
const results = input.cases.map((item) => {
const options = { standard: input.standard };
for (const [key, value] of Object.entries(item.options)) {
if (key === "spoil_fill") {
const spoil = {};
for (const [k, v] of Object.entries(value)) spoil[spoilCamel[k]] = v;
options.spoilFill = spoil;
continue;
}
options[camel[key]] = value;
}
return computeCrossDesign(item.samples, item.design_elevation_m, options);
});
writeFileSync(process.argv[3], JSON.stringify(results));
"""
def _python_results() -> list[dict]:
results = []
for case in CASES:
results.append(
compute_cross_design(
case["samples"],
case["design_elevation_m"],
standard=STANDARD_CROSS_SECTION,
**case["options"],
)
)
return results
def _ts_results(tmp_path: Path) -> list[dict]:
"""TS 짝을 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다."""
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_cross_design.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
# tsc 가 낸 상대 import 에는 확장자가 없어 Node ESM 이 못 읽는다(번들러가 붙여 주던 몫).
# 짝 파일이 서로를 부르므로 여기서 `.js` 를 채워 준다.
for emitted in out.glob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_TS_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(
json.dumps(
{
"cases": [
{
"samples": case["samples"],
"design_elevation_m": case["design_elevation_m"],
"options": case["options"],
}
for case in CASES
],
"standard": STANDARD_CROSS_SECTION,
},
ensure_ascii=False,
),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _diff(left, right, path: str = "") -> list[str]:
"""두 값이 갈린 자리를 경로와 함께 모은다 — 어느 항목이 틀렸는지 바로 보이게."""
if isinstance(left, dict) and isinstance(right, dict):
problems = []
for key in sorted(set(left) | set(right)):
if key not in left:
problems.append(f"{path}.{key}: 파이썬에 없음(TS={right[key]!r})")
elif key not in right:
problems.append(f"{path}.{key}: TS 에 없음(파이썬={left[key]!r})")
else:
problems.extend(_diff(left[key], right[key], f"{path}.{key}"))
return problems
if isinstance(left, list) and isinstance(right, list):
if len(left) != len(right):
return [f"{path}: 길이 {len(left)} vs {len(right)}"]
problems = []
for index, (one, other) in enumerate(zip(left, right, strict=True)):
problems.extend(_diff(one, other, f"{path}[{index}]"))
return problems
if isinstance(left, bool) or isinstance(right, bool):
return [] if left == right else [f"{path}: {left!r} vs {right!r}"]
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
if abs(float(left) - float(right)) <= _TOLERANCE:
return []
return [f"{path}: {left!r} vs {right!r}"]
return [] if left == right else [f"{path}: {left!r} vs {right!r}"]
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_횡단_설계선과_단면적이_두_쪽에서_같다(tmp_path: Path) -> None:
expected = _python_results()
actual = _ts_results(tmp_path)
assert len(actual) == len(expected)
problems: list[str] = []
for case, left, right in zip(CASES, expected, actual, strict=True):
problems.extend(_diff(left, right, case["name"]))
assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
def test_사토장_갈래가_빈_값으로_통과하지_않는다() -> None:
"""사토장 측점이 0 ㎡ 면 대조가 아무것도 안 잠근다 — 값이 실제로 서는지 본다."""
spoil = [
result
for case, result in zip(CASES, _python_results(), strict=True)
if case["name"].startswith("사토장")
]
assert spoil, "사토장 사례가 있어야 한다"
for result in spoil:
assert result["spoil_fill_area_m2"] > 0
assert result["spoil_fill_replaced_fill_m2"] > 0
assert len(result["spoil_fill_line"]) >= 3
# 기울기를 눕히고 넓히면 더 담긴다.
assert spoil[1]["spoil_fill_area_m2"] > spoil[0]["spoil_fill_area_m2"]
def test_시험이_빈_결과로_통과하지_않는다() -> None:
"""면적이 실제로 나오는 경우가 섞여 있어야 대조가 뜻을 가진다."""
results = _python_results()
assert len(results) == len(CASES)
assert sum(1 for item in results if item["cut_area_m2"] > 0) >= 4
assert sum(1 for item in results if item["fill_area_m2"] > 0) >= 1
assert any(item.get("widening_left_m") or item.get("widening_right_m") for item in results)
assert any(item["cut_rock_area_m2"] > 0 for item in results)
+123
View File
@@ -0,0 +1,123 @@
"""B06 배수관 세트(배관·기슭막이·보호공) 엔진 검증.
정본(`pipe_points.json`) + 레지스트리 기본값 조합이 계획서(3-0 근거 표)대로
나오는지, 배관 외 시설(BOX암거 등)이 이번 범위에서 빠지는지 확인한다.
"""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
APRON_LENGTH_FACTOR,
APRON_THICKNESS_M,
MIN_PIPE_COVER_M,
REVET_FACE_SLOPE,
_culvert_set,
attach_culvert_sets,
pipe_points_file,
)
def test_cross_reference_constants_match_plan():
"""계획서 3-0 표 값 — 토피 0.5m·보호공 길이 2배·두께 0.45m(사용자 확정)·전면 1:0.3."""
assert MIN_PIPE_COVER_M == 0.5
assert APRON_LENGTH_FACTOR == 2.0
assert APRON_THICKNESS_M == 0.45
assert REVET_FACE_SLOPE == 0.3
# 배관은 수평도 가능 — 세트에 경사 강제값을 싣지 않는다(2026-08-20 사용자 확정).
assert "min_slope" not in _culvert_set(None)
def test_empty_options_fall_back_to_registry_defaults():
"""옵션이 빈 저장분은 레지스트리 기본값(Ø1000·기슭막이 H2.5/L10)으로 채운다."""
spec = _culvert_set(None)
assert spec["type"] == "pipe"
assert spec["diameter_m"] == 1.0
assert spec["min_cover_m"] == MIN_PIPE_COVER_M
for side in ("inlet", "outlet"):
part = spec[side]
assert part["structure"] == "기슭막이"
assert part["revet_height_m"] == 2.5
assert part["revet_length_m"] == 10
assert part["face_slope"] == REVET_FACE_SLOPE
# 보호공 = 낙차고(2.5) × 2 = 5.0m, 두께 0.45m(사용자 확정).
assert part["apron_length_m"] == 5.0
assert part["apron_thickness_m"] == 0.45
# 레지스트리 기본 형태: 유입 찰 / 유출 메.
assert spec["inlet"]["revet_form"] == "돌쌓기(찰)"
assert spec["outlet"]["revet_form"] == "돌쌓기(메)"
def test_basin_inlet_skips_revet_and_apron():
"""유입구 = 집수정이면 그쪽 기슭막이·보호공을 만들지 않는다(라벨만)."""
spec = _culvert_set({"inlet_type": "집수정"})
assert spec["inlet"]["structure"] == "집수정"
assert "revet_height_m" not in spec["inlet"]
assert "apron_length_m" not in spec["inlet"]
# 유출측은 그대로 세트를 갖춘다.
assert spec["outlet"]["structure"] == "기슭막이"
assert spec["outlet"]["apron_length_m"] == 5.0
def test_string_diameter_and_custom_height():
"""관경 문자열 저장분("800")과 사용자 높이 변경이 그대로 반영된다."""
spec = _culvert_set({"pipe_diameter_mm": "800", "outlet_revet_height_m": 1.5})
assert spec["diameter_m"] == 0.8
assert spec["outlet"]["revet_height_m"] == 1.5
assert spec["outlet"]["apron_length_m"] == 3.0 # 1.5 × 2
def test_attach_only_pipe_facilities(tmp_path):
"""배관은 `culvert`, 세월교는 `ford`, BOX암거는 `box` 키로 얹는다.
배관 매칭은 chainage ±0.02m 그대로다. 세월교·BOX암거는 구체 폭만큼 이어져
기준 측점 전후 절반까지 붙는다(2026-08-25 사용자 확정) — 아래 100.0m은
어느 쪽 범위에도 들지 않는다.
"""
target = pipe_points_file(tmp_path)
target.parent.mkdir(parents=True)
target.write_text(
json.dumps(
{
"route_signature": "x",
"points": [
{
"chainage_m": 84.3,
"source": "spacing",
"options": {"pipe_diameter_mm": 1000},
},
{"chainage_m": 149.73, "source": "stream", "facility": "ford_bridge"},
{"chainage_m": 200.92, "source": "spacing", "facility": "box_culvert"},
],
},
ensure_ascii=False,
),
encoding="utf-8",
)
cross_sections = [
{"chainage_m": 84.3, "samples": []},
{"chainage_m": 149.73, "samples": []},
{"chainage_m": 200.92, "samples": []},
{"chainage_m": 100.0, "samples": []},
]
attached = attach_culvert_sets(tmp_path, cross_sections)
assert attached == 3
assert "culvert" in cross_sections[0]
assert cross_sections[0]["culvert"]["diameter_m"] == 1.0
# 세월교·BOX암거는 그림이 달라 키를 나눈다 — 배수관 소비처가 집어가면 안 된다.
assert cross_sections[1]["ford"]["type"] == "ford"
assert cross_sections[2]["box"]["type"] == "box"
assert all("culvert" not in section for section in cross_sections[1:])
assert all("ford" not in section for section in (cross_sections[0], *cross_sections[2:]))
def test_attach_without_file_is_noop(tmp_path):
"""정본 파일이 없으면 아무 것도 얹지 않고 0을 돌려준다."""
sections = [{"chainage_m": 10.0}]
assert attach_culvert_sets(tmp_path, sections) == 0
assert "culvert" not in sections[0]
@@ -0,0 +1,164 @@
"""배수관 세트 거울 테스트 — 파이썬 엔진과 TS 짝이 **같은 값**을 내는지 대조한다.
짝: `B06_Section/B06_Section_Engine_Culvert.py` ↔ `common_util/common_util_culvert_sets.ts`
한쪽만 고치면 화면과 저장본이 갈리므로, 다섯 시설을 모두 태워 딕셔너리째 비교한다.
레지스트리 기본값은 파이썬이 읽은 것을 그대로 TS 에 넘긴다 — 출처가 같아야 기본값
차이가 아니라 **산식 차이**만 잡힌다.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
attach_culvert_sets,
pipe_points_file,
)
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
# 다섯 시설 × 기본값 폴백/명시값 섞기. 물넘이포장은 폭 절반이 옆 측점까지 걸친다.
POINTS = [
{"chainage_m": 20.0, "facility": "pipe", "options": None},
{
"chainage_m": 40.0,
"facility": "pipe",
"options": {
"pipe_diameter_mm": "800",
"pipe_kind": "흄관",
"inlet_type": "집수정",
"inlet_basin_length_m": 2.5,
"outlet_revet_height_m": 1.8,
"outlet_revet_length_m": 7.5,
"outlet_revet_form": "돌쌓기",
},
},
{
"chainage_m": 60.0,
"facility": "ford_bridge",
"options": {
"ford_width_m": 12.0,
"ford_height_m": 0.4,
"pipe_count": 2,
"wing_in": "있음",
"wing_in_length_m": 3.0,
"wing_in_angle_deg": 30.0,
"wing_out": "없음",
},
},
{
"chainage_m": 80.0,
"facility": "box_culvert",
"options": {"body_width_m": 3.0, "body_height_m": 2.5, "wing_out_length_m": 2.0},
},
{
"chainage_m": 100.0,
"facility": "ford_pavement",
"options": {"ford_width_m": 9.0, "ford_height_m": 0.25, "ford_slope_pct": 3.0},
},
{
"chainage_m": 120.0,
"facility": "revetment",
"options": {
"side": "",
"tiers": 2,
"inlet_revet_height_m": 1.2,
"outlet_revet_length_m": 6.0,
"form": "메쌓기",
},
},
]
# 물넘이포장(폭 9m)이 ±4.5m 까지 걸치는지 보려고 95·105 측점을 함께 둔다.
CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 95.0, 100.0, 105.0, 120.0, 140.0]
_TS_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { attachCulvertSets, buildCulvertSets } from "./common_util_culvert_sets.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const sets = buildCulvertSets(input.points, input.registry);
const sections = input.chainages.map((chainage) => ({ chainage_m: chainage }));
attachCulvertSets(sections, sets);
writeFileSync(process.argv[3], JSON.stringify(sections));
"""
def _python_sections() -> list[dict]:
"""파이썬 엔진이 얹은 결과 — 정본 파일을 거쳐 공개 경로로 부른다."""
import tempfile
with tempfile.TemporaryDirectory(prefix="culvert_mirror_") as workdir:
root = Path(workdir)
target = pipe_points_file(root)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps({"points": POINTS}, ensure_ascii=False), encoding="utf-8")
sections = [{"chainage_m": value} for value in CHAINAGES]
attach_culvert_sets(root, sections)
return sections
def _registry() -> dict:
types = structure_type_map()
table = {}
for type_id in ("pipe", "ford_bridge", "ford_pavement", "box_culvert"):
entry = types.get(type_id)
table[type_id] = {option.key: option.default for option in entry.options} if entry else {}
return table
def _ts_sections(tmp_path: Path) -> list[dict]:
"""TS 짝을 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다."""
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_culvert_sets.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_TS_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(
json.dumps({"points": POINTS, "registry": _registry(), "chainages": CHAINAGES}),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_culvert_sets_match_between_python_and_ts(tmp_path: Path) -> None:
expected = _python_sections()
actual = _ts_sections(tmp_path)
assert len(actual) == len(expected)
for index, (left, right) in enumerate(zip(expected, actual, strict=True)):
assert left == right, f"{CHAINAGES[index]}m 측점 세트가 갈렸다"
# 시설이 실제로 붙었는지 — 빈 결과끼리 같아서 통과하는 것을 막는다.
assert sum(1 for item in expected if len(item) > 1) >= 6
@@ -0,0 +1,31 @@
/* 곡선부 확폭 표 — 브라우저 짝이 파이썬과 같은 값을 내는지 확인(2026-09-06).
기대값은 `test_b06_curve_widening.py` 의 TABLE_CASES 와 같아야 한다. */
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
// TS 를 그대로 못 읽으므로 표·함수 부분만 떼어 평가한다(빌드 없이 도는 확인).
const source = readFileSync("common_util/common_util_cross_design_geometry.ts", "utf8");
const table = source.slice(
source.indexOf("const CURVE_WIDENING_TABLE_M"),
source.indexOf("/** 짝: `_SectionGeometry`"),
);
const js = table
.replace(/: ReadonlyArray<readonly \[number, number, number\]>/, "")
.replace(/export /g, "")
.replace(/: number \| null \| undefined/g, "")
.replace(/: number/g, "")
.replace(/planRadiusM\)/g, "planRadiusM)");
const { curveWideningM } = await import(
`data:text/javascript,${encodeURIComponent(js + "\nexport { curveWideningM, CURVE_WIDENING_MAX_WIDTH_M };")}`
);
const cases = [
[9.9, 0], [10, 2.25], [12.999, 2.25], [13, 2], [14, 1.75], [15, 1.5],
[18, 1.25], [20, 1], [25, 0.75], [30, 0.5], [40, 0.25], [45, 0], [200, 0],
];
for (const [radius, expected] of cases) {
assert.equal(curveWideningM(radius), expected, `R=${radius}`);
}
assert.equal(curveWideningM(null), 0);
assert.equal(curveWideningM(undefined), 0);
console.log("확폭표 브라우저 짝 일치 —", cases.length + 2, "건 확인");
+150
View File
@@ -0,0 +1,150 @@
"""곡선부 확폭 — 표·경계·편측 적용·상한을 확인한다(2026-09-06).
파이썬과 브라우저가 한 세트이므로 같은 기대값을 `test_b06_curve_widening.mjs` 도 쓴다.
값이 갈리면 두 화면이 다른 단면을 그린다.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import numpy as np # noqa: E402
from B05_Profile.B05_Profile_Engine_Sections_Core import _plan_radii # noqa: E402
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from config.config_system_design import curve_widening_m # noqa: E402
# (반경 m, 기대 확폭 m) — 별표2 .2.나.(4). 경계는 "이상 ~ 미만".
TABLE_CASES = [
(9.9, 0.0),
(10.0, 2.25),
(12.999, 2.25),
(13.0, 2.0),
(14.0, 1.75),
(15.0, 1.5),
(18.0, 1.25),
(20.0, 1.0),
(25.0, 0.75),
(30.0, 0.5),
(40.0, 0.25),
(45.0, 0.0),
(200.0, 0.0),
]
def _flat_samples() -> list[dict]:
return [{"offset_m": float(o), "elevation_m": 100.0, "valid": True} for o in range(-25, 26)]
def test_widening_table_boundaries() -> None:
for radius, expected in TABLE_CASES:
assert curve_widening_m(radius) == expected, radius
assert curve_widening_m(None) == 0.0
def test_widening_applies_to_outer_side_only() -> None:
samples = _flat_samples()
base = compute_cross_design(samples, 100.0, ground_type="soil", section_mode="both_fill")
right = compute_cross_design(
samples,
100.0,
ground_type="soil",
section_mode="both_fill",
plan_radius_m=16.0,
curve_outer_side="right",
)
left = compute_cross_design(
samples,
100.0,
ground_type="soil",
section_mode="both_fill",
plan_radius_m=16.0,
curve_outer_side="left",
)
assert base["widening_left_m"] == 0.0 and base["widening_right_m"] == 0.0
assert right["widening_right_m"] == 1.5 and right["widening_left_m"] == 0.0
assert left["widening_left_m"] == 1.5 and left["widening_right_m"] == 0.0
# 확폭은 붙은 쪽 차도 끝만 밖으로 민다.
assert right["carriageway_edges"]["left"] == base["carriageway_edges"]["left"]
assert right["carriageway_edges"]["right"]["offset_m"] < base["carriageway_edges"]["right"]["offset_m"]
assert right["carriageway_width_m"] == base["carriageway_width_m"] + 1.5
def test_widening_capped_by_legal_max_width() -> None:
"""확폭을 더한 유효너비는 5m 를 넘지 않는다(별표2 — 최대 5미터까지)."""
samples = _flat_samples()
design = compute_cross_design(
samples,
100.0,
ground_type="soil",
section_mode="both_fill",
plan_radius_m=11.0, # 표값 2.25m
curve_outer_side="right",
)
assert design["carriageway_width_m"] <= 5.0 + 1e-9
# 규격 3.0m 이면 2.0m 까지만 붙는다.
assert design["widening_right_m"] == 2.0
def test_plan_radius_and_outer_side_from_polyline() -> None:
"""반지름 50m 원호에서 반경 50m 가 나오고, 좌회전이면 바깥은 우측이다."""
angles = np.linspace(0.0, np.pi / 2, 400)
radius = 50.0
left_turn = np.column_stack([radius * np.cos(angles), radius * np.sin(angles)])
chainage = np.r_[
0.0, np.cumsum(np.hypot(np.diff(left_turn[:, 0]), np.diff(left_turn[:, 1])))
]
radii, sides = _plan_radii(left_turn, chainage, np.array([40.0]), float(chainage[-1]))
assert abs(radii[0] - radius) < 0.1
assert sides[0] == "right"
right_turn = np.column_stack([radius * np.cos(-angles), radius * np.sin(-angles)])
chainage2 = np.r_[
0.0, np.cumsum(np.hypot(np.diff(right_turn[:, 0]), np.diff(right_turn[:, 1])))
]
_, sides2 = _plan_radii(right_turn, chainage2, np.array([40.0]), float(chainage2[-1]))
assert sides2[0] == "left"
def test_straight_route_has_no_radius() -> None:
line = np.array([[0.0, 0.0], [300.0, 0.0]])
radii, sides = _plan_radii(line, np.array([0.0, 300.0]), np.array([50.0, 150.0]), 300.0)
assert radii == [None, None]
assert sides == [None, None]
def test_widening_taper_runs_before_and_after_curve() -> None:
"""곡선 앞뒤 10m 안 측점은 확폭이 0 → W 로 이어진다(2026-09-06)."""
from B05_Profile.B05_Profile_Engine_Sections_Core import _curve_widenings
# 5m 간격 측점: 20~30m 구간만 곡선(R=16 → 1.5m), 나머지는 직선.
chainage = np.arange(0.0, 55.0, 5.0)
radii: list[float | None] = [None] * len(chainage)
sides: list[str | None] = [None] * len(chainage)
for index, value in enumerate(chainage):
if 20.0 <= value <= 30.0:
radii[index] = 16.0
sides[index] = "right"
widenings, out_sides = _curve_widenings(chainage, radii, sides)
by_chainage = dict(zip(chainage.tolist(), widenings, strict=True))
# 곡선 안은 표값 그대로.
assert by_chainage[20.0] == 1.5 and by_chainage[25.0] == 1.5 and by_chainage[30.0] == 1.5
# 앞뒤 10m 는 선형으로 줄어든다 — 5m 지점에서 절반.
assert by_chainage[15.0] == 0.75 and by_chainage[35.0] == 0.75
assert by_chainage[10.0] == 0.0 and by_chainage[40.0] == 0.0
# 테이퍼 측점도 확폭이 붙는 쪽을 물려받는다.
assert out_sides[chainage.tolist().index(15.0)] == "right"
# 테이퍼 밖은 방향이 없다.
assert out_sides[chainage.tolist().index(5.0)] is None
def test_widening_without_curve_stays_zero() -> None:
from B05_Profile.B05_Profile_Engine_Sections_Core import _curve_widenings
chainage = np.arange(0.0, 60.0, 20.0)
widenings, sides = _curve_widenings(chainage, [None] * 3, [None] * 3)
assert widenings == [0.0, 0.0, 0.0]
assert sides == [None, None, None]
@@ -0,0 +1,66 @@
"""절토 경사 — **법정 기울기 판정을 두지 않는다**(2026-09-07 사용자 확정으로 폐기).
원래 이 시험은 별표2(경암 1:0.3~0.8 …) 범위 밖이면 카드에 경고를 띄우는 기능을 지켰다.
그 기능은 폐기됐다. 사용자 원문:
「연암과 경암 / 발파암과 리핑암 선택은 횡단도에서 선택 안함. 사유는 향후 설계내역에서
설계자가 직접 비율로 지정하기로 함. 암반 지정과 범위 애매모호한 경우가 있어.
실무자는 그렇게 하기로 판단함. 대신 경고부분은 삭제해주고 대신 각도를 사용자가
넣을수 있게 반영.」
왜 폐기인가 — **암반 지정·범위가 실무에서 애매하다.** 측점마다 암질(연암·경암)을 못 박아야
판정이 서는데, 그 못 박음 자체가 틀린 전제다. 암 비율은 **설계내역 단계에서 설계자가
비율로** 넣는다. 판정 기준이 없으니 경고도 없다.
그래서 이 파일은 **되살아나지 않게 막는 시험**으로 남긴다. 하루 사이에
「굴착 공법으로 암질 유추」 → 「암질 선택 버튼」 → 「아예 안 고름」으로 좁혀진 건이라,
누가 「왜 만들다 말았나」로 다시 꺼내면 여기서 걸린다.
⚠ `cut_slope_segments`(구간별 경사)는 **지우지 않는다** — 소단 기하가 그 위에 서 있고
구간 경사를 아는 값이라 뒤에 쓸 자리가 있다(2026-09-07 계획 창 지시).
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
_B06 = PROJECT_ROOT / "B06_Section"
_CONFIG = PROJECT_ROOT / "config" / "config_system_design.py"
_CHROME = _B06 / "B06_Section_UI_Cross_Card_Chrome.ts"
_PANEL = _B06 / "B06_Section_UI_Standard_Panel.ts"
def _sources() -> dict[str, str]:
files = [_CONFIG, _CHROME, _PANEL, _B06 / "B06_Section_Schema.py", _B06 / "B06_Section_Router.py"]
return {path.name: path.read_text(encoding="utf-8") for path in files}
def test_법정_기울기_검사가_없다():
"""판정 모듈 자체가 없어야 한다 — 파일이 되살아나면 여기서 걸린다."""
assert not (_B06 / "B06_Section_Cut_Slope_Check.ts").exists(), "폐기한 판정 모듈이 되살아났음"
def test_카드에_절토_경사_경고가_없다():
"""카드 경고는 성토사면 길이·미폐합만 남는다."""
chrome = _CHROME.read_text(encoding="utf-8")
assert "violationsOf" not in chrome, "폐기한 경고 배지가 카드에 다시 붙었음"
def test_암질을_고르는_자리가_없다():
"""암질(연암·경암)은 횡단도에서 고르지 않는다 — 설계내역에서 비율로 넣는다."""
for name, source in _sources().items():
assert "rock_quality" not in source, f"{name} 에 암질 선택이 남아 있음"
assert "soft_rock" not in source, f"{name} 에 별표2 암질 구분이 남아 있음"
def test_구간별_경사값은_남긴다():
"""⚠ 판정은 지웠지만 `cut_slope_segments` 는 지우지 않는다 — 소단이 그 위에 선다."""
geometry = (PROJECT_ROOT / "common_util" / "common_util_cross_design_geometry.ts").read_text(
encoding="utf-8"
)
engine = (_B06 / "B06_Section_Engine_Design.py").read_text(encoding="utf-8")
assert "cutSlopeSegments" in geometry, "구간별 경사 계산이 사라졌음"
assert "cut_slope_segments" in engine, "구간별 경사가 서버 결과에서 사라졌음"
@@ -0,0 +1,150 @@
"""절토 경사를 **사용자가 직접 넣는** 길이 끊기지 않는지 (2026-09-07 사용자 지시).
사용자 원문 — 「대신 경고부분은 삭제해주고 대신 각도를 사용자가 넣을수 있게 반영. 전체
공통으로 변경하는 경우에는 기본값 지정으로 하면 되지만 횡단도 하나만 변경하는 폼은 가져야함」
및 「개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함 / 전체 변경을 위해서는 좌측 패널의
표준 횡단면 설정을 이용. (표준 횡단면 설정으로 변경시 사용자가 기본값을 사용하지 않는 값들은
변경되면 안됨.)」
⚠ **경사는 「나르는 값」이 아니라 「기하 입력」이다.** 계산이 끝난 결과에 키만 베껴 붙이면
설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다 — 소단에서 겪은 자리와 같다.
그래서 여기서는 ① 값이 실제로 **계산에 들어가** 절토 면적이 달라지는지 ② 부르는 자리마다
계산 **전에** 넘어가는지를 함께 지킨다.
"""
import re
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from B06_Section.B06_Section_Router_Design import ( # noqa: E402
USER_TOUCHED_KEYS,
stored_cut_slope,
)
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
_B06 = PROJECT_ROOT / "B06_Section"
_REFRESH = _B06 / "B06_Section_Cross_Refresh.ts"
_CARD = _B06 / "B06_Section_UI_Cross_CutSlope.ts"
_CHROME = _B06 / "B06_Section_UI_Cross_Card_Chrome.ts"
_ROUTER_DESIGN = _B06 / "B06_Section_Router_Design.py"
def _ground(slope: float) -> list[dict]:
"""가로 -12~+12m 를 0.5m 간격으로 훑은 비탈 지반선."""
samples = []
offset = -12.0
while offset <= 12.0001:
samples.append(
{
"offset_m": round(offset, 3),
"elevation_m": round(100.0 + slope * offset, 4),
"valid": True,
}
)
offset += 0.5
return samples
def _design(**options):
return compute_cross_design(
_ground(-0.5),
99.8,
ground_type="ripping_rock",
section_mode="left_cut",
ditch_side="left",
standard=STANDARD_CROSS_SECTION,
rock_boundary_offset_m=-1.0,
**options,
)
def test_넣은_경사가_실제로_그려진다():
"""표준 1:0.4 → 사용자 1:1.0. 경사비도 절토 면적도 함께 달라져야 한다."""
base = _design()
changed = _design(cut_slope_ratio=1.0)
assert base["cut_slope_ratio"] == 0.4, "표준 절토 경사가 바뀐 듯 — 전제 확인 필요"
assert changed["cut_slope_ratio"] == 1.0, "넣은 경사가 설계에 안 들어감"
# 사면이 눕는 만큼 더 파므로 절토 면적이 커진다 — 숫자가 그대로면 그림만 바뀐 것이다.
assert changed["cut_area_m2"] > base["cut_area_m2"], "경사를 눕혔는데 절토량이 그대로임"
def test_무릎_위_토사_경사는_그대로다():
"""사용자가 넣는 것은 **암 절토각**이다 — 무릎 위(토사)는 표준값 그대로 간다."""
changed = _design(cut_slope_ratio=1.0, two_stage_slope=True)
assert changed["soil_cut_slope_ratio"] == STANDARD_CROSS_SECTION["soil"]["cut_slope_ratio"]
def test_0_은_표준값으로_되돌림이다():
"""되돌리기(↺)는 세션에서 지우는 게 아니라 **0 을 남긴다** — 지우면 정본의 옛 값이
되살아나 표준으로 못 돌아간다."""
assert stored_cut_slope({"cut_slope_ratio_user": 0}) is None
assert stored_cut_slope({"cut_slope_ratio_user": 0.8}) == 0.8
assert stored_cut_slope({}) is None
def test_다시_계산해도_사용자_값이_남는다():
"""표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 (사용자 원문 끝줄)."""
assert "cut_slope_ratio_user" in USER_TOUCHED_KEYS
assert '"cut_slope_ratio_user"' in _REFRESH.read_text(encoding="utf-8")
def test_계산_전에_넘어간다():
"""⚠ 이 시험이 이 건의 요지다 — 값을 계산 **인자**로 넘기는지.
재계산 경로(포장 강제·세월교 하강·선형 재계산)가 모두 인자로 받아야 한다. 한 곳이라도
빠지면 그 경로에서만 옛 경사로 그려지고 값만 새것이 된다.
"""
router = _ROUTER_DESIGN.read_text(encoding="utf-8")
assert router.count("cut_slope_ratio=") >= 3, "재계산 경로 중 값을 안 넘기는 곳이 있음"
refresh = _REFRESH.read_text(encoding="utf-8")
assert "cutSlopeRatio: cutSlopeAt(" in refresh, "브라우저 재계산이 값을 안 넘김"
# 계산이 끝난 뒤 베껴 붙이는 꼴이면 안 된다.
assert not re.search(r"design\.cut_slope_ratio\s*=", refresh), "계산 뒤에 값만 덮어쓰고 있음"
def test_각도와_경사비가_서로_바뀐다():
"""화면은 각도(°), 속은 경사비(1:n). 1:0.4 = 68.2° · 1:1.0 = 45°."""
source = _CARD.read_text(encoding="utf-8")
assert "ratioToDegrees" in source and "degreesToRatio" in source
# 45°는 1:1 — 두 함수가 서로의 역이어야 한다(코드에 그 식이 있는지).
assert "Math.atan(1 / Math.max(ratio, 1e-6))" in source
assert "1 / Math.tan((clamped * Math.PI) / 180)" in source
def test_칸은_암_측점에만_선다():
"""토사 측점은 암반이 없어 암 절토각이 쓰이지 않는다(2026-09-07 사용자 확정)."""
chrome = _CHROME.read_text(encoding="utf-8")
assert 'cutSlope && section.design?.geometry_preset === "rock"' in chrome
# ── 지반유형 버튼 정리 (2026-09-07 사용자 확정) ────────────────────────────────
# 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만 존재하고 이값은
# 활성화/비활성화로 반영(기본값은 비활성화)」 — 켜면 토사, 끄면 암이다.
_DESIGN_UI = _B06 / "B06_Section_UI_Cross_Design.ts"
def test_지반유형은_토사_토글_하나다():
source = _DESIGN_UI.read_text(encoding="utf-8")
assert "GROUND_OPTIONS" not in source, "지반유형 버튼 셋이 남아 있음"
assert "groundToggle" in source, "토사 토글이 없음"
# 저장값은 종전 그대로 — 끔 = ripping_rock, 켬 = soil.
assert 'GROUND_ROCK_DEFAULT: GroundType = "ripping_rock"' in source
def test_기본은_암이다():
"""사용자 원문 「기본값은 비활성화」 — 설계가 없는 측점도 암으로 선다."""
source = _DESIGN_UI.read_text(encoding="utf-8")
assert "design?.ground_type ?? GROUND_ROCK_DEFAULT" in source
def test_옛_발파암_자료도_암으로_읽힌다():
"""버튼은 없앴지만 저장분에 남은 `blasting_rock` 은 그대로 암 기하로 계산된다."""
from config.config_system_design import SECTION_GROUND_TYPE_PRESET
assert SECTION_GROUND_TYPE_PRESET["blasting_rock"] == "rock"
assert SECTION_GROUND_TYPE_PRESET["ripping_rock"] == "rock"
@@ -0,0 +1,88 @@
"""사용자 값이 재계산에서 사라지지 않는지 — **키 이름으로** 지킨다.
왜 (2026-09-07 전수 조사) — 횡단 `design` 딕셔너리는 사용자 값과 계산 값이 한 칸에 섞여
있고, 재계산(`enforce_pavement_ranges`·`enforce_ford_surface_drops`)은 결과를 통째로
갈아 끼운다. 그래서 사용자 값이 살아남는 길은 **둘뿐**이다:
① 재계산에 **인자로 되먹여** 결과에 그대로 나오는 것
(`ground_type`·`section_mode`·`ditch_side`·`ditch_type`·`paved`·
`two_stage_slope`·`rock_boundary_offset_m`)
② 계산이 만들지 않아 **목록으로 베껴 넣는** 것 (`USER_TOUCHED_KEYS`)
어느 쪽에도 안 걸린 사용자 값은 **저장할 때 조용히 사라진다**. 실제로 그렇게 사라졌던
것이 `extra_spans` 였다(`b6941bd2`). 이 시험은 그 구멍을 다시 못 나게 못박는다 —
새 사용자 값을 `CrossSectionPatch` 에 더하면서 ①·② 어느 쪽에도 안 넣으면 여기서 깨진다.
"""
import inspect
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS # noqa: E402
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
#: 측점을 가리키는 열쇠 — 값이 아니다.
_KEY_FIELDS = {"chainage_m"}
#: 사용자 값이 아니라 **계산 값**이라 재계산이 다시 내는 것이 옳은 필드.
#: 브라우저가 조작 중 값을 함께 보내지만 정본은 서버가 다시 낸다(CLAUDE.md 5장).
_COMPUTED_FIELDS = {
"cut_area_m2",
"fill_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
# 배수관 연장(m, 2026-09-08) — 사용자 값이 아니라 **기하가 낸 값**이다. 면적 넷과 **같은
# 다리**(`STRUCTURE_ROW_KEYS` → `B06_Section_Server_Calc_Node` →
# `recompute_server_side`)로 서버가 다시 내므로 재계산에서 안 사라진다.
"pipe_length_m",
}
def _recompute_inputs() -> set[str]:
"""재계산에 되먹이는 인자 이름 — ① 갈래."""
return set(inspect.signature(compute_cross_design).parameters)
def test_사용자_값은_되먹임이거나_보존목록이다():
fed = _recompute_inputs()
kept = set(USER_TOUCHED_KEYS)
missing = [
name
for name in CrossSectionPatch.model_fields
if name not in _KEY_FIELDS
and name not in _COMPUTED_FIELDS
and name not in fed
and name not in kept
]
assert not missing, (
"재계산에서 사라질 사용자 값: "
+ ", ".join(missing)
+ " — 재계산 인자로 넣거나 USER_TOUCHED_KEYS 에 더할 것"
)
def test_보존목록에_계산값이_섞이지_않았다():
"""반대 방향 — 계산 값을 베껴 두면 옛 값이 새 계산을 덮는다."""
overlap = sorted(set(USER_TOUCHED_KEYS) & _COMPUTED_FIELDS)
assert not overlap, f"계산 값이 보존 목록에 있음: {overlap}"
def test_되먹임_갈래가_실제로_여덟_가지():
"""①이 줄면(인자에서 빠지면) 그 값은 조용히 기본값으로 되돌아간다 — 수를 못박는다."""
fed = _recompute_inputs()
expected = {
"ground_type",
"section_mode",
"ditch_side",
"ditch_type",
"paved",
"two_stage_slope",
"ditch_enabled",
"rock_boundary_offset_m",
}
assert expected <= fed, f"재계산 인자에서 빠진 것: {sorted(expected - fed)}"
@@ -0,0 +1,63 @@
"""상세 제원(뒷길이·돌규격·형식)을 **B06 에서 받는지** — 2026-09-08 데스크탑 창 보고.
무슨 일이었나 — 레지스트리는 그 칸들을 `phase: "detail"` 로 두고 「B05 는 유무·종류·위치만,
상세 치수는 B06/B07 에서」(2026-08-17 사용자 확정)로 갈라 놓았는데, **그 화면이 아직 안 받고
있었다.** 그래서 실무 프로젝트의 구조물 옵션에 `back_len_cm`·`stone_cm`·`form` 이 통째로 비어
있었고, **수량이 품셈 갈래를 못 골라** 금액이 0 으로 남았다(13-4 돌쌓기 뒷길이 · 13-6 큰돌쌓기
직경 · 12-3 옹벽 형식).
고침 — 폼이 `includeDetail` 을 받아 **B06 에서만** 상세 칸까지 그린다. B05 는 종전 그대로다.
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types # noqa: E402
_PANEL = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts"
_B06 = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Panel.ts"
_B05 = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts"
def _type(type_id: str):
return next(item for item in load_structure_types() if item.type_id == type_id)
def test_B06_은_상세_칸까지_받는다():
assert "includeDetail: true" in _B06.read_text(encoding="utf-8")
def test_B05_는_종전대로_유무_종류_위치만():
"""2026-08-17 사용자 확정 — B05 폼에 상세 칸이 생기면 안 된다."""
assert "includeDetail" not in _B05.read_text(encoding="utf-8")
def test_기본값_없는_필수_항목은_빈_칸으로_연다():
"""첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다."""
panel = _PANEL.read_text(encoding="utf-8")
assert 'choices.unshift(["", "— 선택 —"])' in panel
assert 'option.required === true && (option.default ?? "") === ""' in panel
def test_큰돌쌓기에_메_찰_구분이_있다():
"""품셈이 13-6-1 메쌓기 / 13-6-2 찰쌓기로 갈라 두는데 구분 칸이 없어 못 골랐다
(2026-09-08 데스크탑 창 요청). 돌쌓기는 이미 타입이 갈려 있다(찰/메)."""
bond = next(o for o in _type("boulder_masonry").options if o.key == "bond")
assert bond.choices == ["메쌓기", "찰쌓기"]
assert bond.default is None, "기본값을 두면 안 된다 — 설계자가 고를 값이다"
assert bond.required is True
assert bond.phase == "detail", "상세 제원이라 B06/B07 에서 받는다"
def test_규격_축이_타입마다_다르다():
"""⚠ 큰돌쌓기는 **직경**(13-6), 돌쌓기는 **뒷길이**(13-4) — 섞으면 엉뚱한 계수로 돈다
(2026-09-08 데스크탑 창이 실제로 그 결함을 잡았다)."""
stone = next(o for o in _type("boulder_masonry").options if o.key == "stone_cm")
back = next(o for o in _type("masonry_wet").options if o.key == "back_len_cm")
assert stone.choices == ["40~60", "60~80", "80~100"], "큰돌쌓기 = 직경 축"
assert "35" in back.choices and "75" in back.choices, "돌쌓기 = 뒷길이 축"
assert set(stone.choices).isdisjoint(set(back.choices))
+89
View File
@@ -0,0 +1,89 @@
"""측구 — **선택과 결과를 가른다** (2026-09-09 정리, 계획서 0장).
⚠⚠ 한 칸(`ditch_enabled`)에 두 뜻이 담겨 있었다 — 결과(실제 섰나)를 그대로 다시 입력으로
넣어 읽었으므로 **한 번 저장되면 자동 판정이 영영 다시 안 돌았다.** 계획고를 내려
절토가 생겨도 측구가 안 서고 아무 말도 안 나왔다.
⇒ **선택은 `ditch_choice`**(없음 = 자동) · **결과는 `ditch_enabled`**(실제 섰나).
⚠ 옛 저장분은 `ditch_enabled` 로 오고, **자동값과 다를 때만** 선택으로 살린다 —
같으면 자동이 그렇게 냈던 것이고, 다르면 사용자가 일부러 바꾼 것이다(설계 의도 보존).
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
def _ground(slope: float) -> list[dict]:
return [
{"offset_m": round(x * 0.5, 3), "elevation_m": round(100.0 + slope * x * 0.5, 4)}
for x in range(-24, 25)
]
def _design(**kwargs):
return compute_cross_design(
_ground(kwargs.pop("slope", 0.35)),
100.4,
ground_type="soil",
section_mode="left_cut",
ditch_side="left",
**kwargs,
)
def test_자동이면_선택이_비어_있다() -> None:
design = _design()
assert design["ditch_enabled"] is True # 절토측이라 자동으로 섬
assert design["ditch_choice"] is None # **선택한 적 없음**
def test_결과를_다시_넣어도_자동이_계속_돈다() -> None:
"""⚠ 이것이 고친 자리 — 종전에는 결과가 선택으로 굳어 자동이 다시 안 돌았다."""
first = _design()
second = _design(ditch_enabled=first["ditch_enabled"])
assert second["ditch_choice"] is None, "결과가 선택으로 굳었다"
assert second["ditch_enabled"] == first["ditch_enabled"]
def test_옛_저장분이_자동과_다르면_사용자_뜻으로_본다() -> None:
"""일부러 끈 것은 살린다 — 설계 의도를 잃지 않는다."""
design = _design(ditch_enabled=False)
assert design["ditch_choice"] is False
assert design["ditch_enabled"] is False
def test_자동과_같은_선택은_자동으로_푼다() -> None:
"""화면 토글이 2단이라 「자동으로 되돌리기」 단추가 없다 — 원래 값으로 다시 누른 것을
선택으로 굳히면 **다시 같은 병**(지형이 바뀌어도 안 따라감)이 된다.
"""
design = _design(ditch_choice=True) # 자동도 True 인 지형
assert design["ditch_enabled"] is True
assert design["ditch_choice"] is None, "자동과 같은 선택이 굳었다"
def test_선택은_자동을_이긴다() -> None:
off = _design(ditch_choice=False)
assert off["ditch_enabled"] is False and off["ditch_choice"] is False
on = _design(slope=-0.35, ditch_choice=True)
assert on["ditch_enabled"] is True and on["ditch_choice"] is True
def test_양성은_선택보다_기하가_먼저다() -> None:
"""양성 단면은 측구가 설 자리가 없다 — 켜도 안 선다."""
design = compute_cross_design(
_ground(-0.35),
102.0,
ground_type="soil",
section_mode="both_fill",
ditch_side="left",
ditch_choice=True,
)
assert design["ditch_enabled"] is False
+110
View File
@@ -0,0 +1,110 @@
"""측구터파기 단면적을 토사/암으로 가르는 규칙 (2026-09-09).
별표2 Ⅰ.1.나.(5) 「측구터파기 단면적」이 횡단도 표의 법정 칸이라 반만 채워 나가면 안 된다.
⚠ **새 입력을 만들지 않는다** — 절토 분리와 같은 근거(지반 유형 + 암반 경계선)를 쓰고,
근거가 없으면 **나누지 않고 사유를 낸다**. 절반을 임의로 가르는 것이 가장 나쁘다.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Areas import _split_ditch_area # noqa: E402
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
_STANDARD = {"type": "standard", "top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}
_L_TYPE = {"type": "l_type", "width_m": 0.6, "depth_m": 0.3}
_TOTAL_STANDARD = (0.9 + 0.3) / 2 * 0.3
def test_경계선이_바닥보다_깊으면_전량_토사() -> None:
soil, rock = _split_ditch_area(_STANDARD, 5.0)
assert soil == pytest.approx(_TOTAL_STANDARD)
assert rock == pytest.approx(0.0)
def test_경계선이_상단_위면_전량_암() -> None:
soil, rock = _split_ditch_area(_STANDARD, -1.0)
assert soil == pytest.approx(0.0)
assert rock == pytest.approx(_TOTAL_STANDARD)
def test_중간이면_사다리꼴을_가로로_가른다() -> None:
"""깊이 절반 지점 — 위쪽이 넓어 토사분이 절반보다 크다."""
soil, rock = _split_ditch_area(_STANDARD, 0.15)
assert soil + rock == pytest.approx(_TOTAL_STANDARD)
assert soil > _TOTAL_STANDARD / 2 # 위가 넓다
# 폭 W(d) = 0.9 2·d 를 0~0.15 적분: 0.9·0.15 2·0.15²/2
assert soil == pytest.approx(0.9 * 0.15 - (0.9 - 0.3) * 0.15**2 / (2 * 0.3))
def test_L형도_합이_보존된다() -> None:
total = 0.6 * 0.3 / 2
for depth in (0.0, 0.1, 0.2, 0.3, 1.0):
soil, rock = _split_ditch_area(_L_TYPE, depth)
assert soil + rock == pytest.approx(total), depth
def test_측구가_없으면_둘_다_0() -> None:
assert _split_ditch_area({"type": "none"}, 0.2) == (0.0, 0.0)
def _ground(slope: float = 0.5) -> list[dict]:
return [
{"offset_m": offset / 2.0, "elevation_m": 100.0 - (offset / 2.0) * slope}
for offset in range(-24, 25)
]
def test_토사_지반은_전량_토사이고_사유가_남는다() -> None:
result = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="left_cut")
assert result["ditch_split_basis"] in {"soil_ground", "no_ditch"}
assert result["ditch_rock_area_m2"] == 0.0
assert result["ditch_soil_area_m2"] + result["ditch_rock_area_m2"] == pytest.approx(
result["ditch_area_m2"]
)
def test_암_지반에_경계선이_없으면_전량_암() -> None:
result = compute_cross_design(
_ground(),
100.0,
ground_type="ripping_rock",
section_mode="left_cut",
rock_boundary_offset_m=None,
ditch_enabled=True,
)
if result["ditch_split_basis"] == "no_ditch":
pytest.skip("이 표본에는 측구가 안 선다")
assert result["ditch_split_basis"] == "rock_ground_no_boundary"
assert result["ditch_soil_area_m2"] == 0.0
def test_암_지반에_경계선이_있으면_그것으로_가른다() -> None:
result = compute_cross_design(
_ground(),
100.0,
ground_type="ripping_rock",
section_mode="left_cut",
rock_boundary_offset_m=0.5,
ditch_enabled=True,
)
if result["ditch_split_basis"] == "no_ditch":
pytest.skip("이 표본에는 측구가 안 선다")
assert result["ditch_split_basis"] == "rock_boundary"
assert result["ditch_soil_area_m2"] + result["ditch_rock_area_m2"] == pytest.approx(
result["ditch_area_m2"]
)
def test_합계_키는_그대로_남는다() -> None:
"""B08 이 읽는 `ditch_area_m2` 를 안 깨뜨린다 — 갈래는 덧붙인 것이다."""
result = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="left_cut")
assert "ditch_area_m2" in result
@@ -0,0 +1,120 @@
"""추가(다단) 기슭막이의 **좌우 이동 하한**이 화면에 알려지는지.
왜 (2026-09-07 원인 확정) — 「좌우 이동 1.0m 를 넣어도 벽이 안 움직인다」는 보고가
다섯 측점 중 넷에서 났다. 지형이 막은 것이 아니었다. 선반 길이가 음수가 되지 않도록
좌우 이동은 **1.2 × 상하 내림** 아래로 못 내려가는데(`shelfFloor`), 실제 저장값이
d 2.6m 이면 하한이 3.12m 라 1.0m 요청은 애초에 아무 변화도 못 낸다. 그런데도
「지형에 막힘」 수치(`shiftBlockedM`)는 0 으로 나와 까닭을 알 길이 없었다.
이 시험은 그 하한이 실제로 걸리는지와, 걸렸을 때 `shiftFloorM` 으로 알리는지를 지킨다.
실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(배수관 세트 거울 시험과 같은 방식).
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
MODULE = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Extra.ts"
# 상하 내림 2.0m → 하한 2.4m. 좌우 0.5m 를 요청해도 2.4m 로 밀려 나가야 한다.
REQUESTED_X = 0.5
REQUESTED_D = 2.0
EXPECTED_FLOOR = 2.4
_RUNNER = """
const { buildOutletExtras } = require(process.argv[3]);
const { writeFileSync } = require("node:fs");
// 계류측으로 1:2 로 내려가는 민민한 원지반 — 성토선(1:1.2)보다 완만해 지형이 막을 일이 없다.
// 시작점은 원지반보다 5m 위 — 그래야 다단이 설 자리(성토부)가 생긴다.
const groundAt = (offset) => 95 - offset / 2;
const result = buildOutletExtras({
start: { offset: 0, elevation: 100 },
startBottomElevation: 100,
outward: 1,
groundAt,
limitOffset: 60,
adjusts: [{ x: %(x)s, d: %(d)s, h: null, m: null }],
});
writeFileSync(process.argv[2], JSON.stringify({
count: result.walls.length,
appliedX: result.appliedAdjusts[0]?.x ?? null,
floor: result.walls[0]?.shiftFloorM ?? null,
blocked: result.walls[0]?.shiftBlockedM ?? null,
}));
"""
def _run(tmp_path: Path, requested_x: float = REQUESTED_X) -> dict:
out = tmp_path / "out"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
"--ignoreConfig",
"--target",
"es2022",
# 프로젝트 소스는 확장자 없는 상대 경로를 쓴다(번들러 기준) — CommonJS 로 옮겨야
# Node 가 그대로 찾아 준다. 러너도 `.cjs` 라 `type` 설정과 무관하게 CJS 로 돈다.
"--module",
"commonjs",
"--skipLibCheck",
"--outDir",
str(out),
str(MODULE),
],
cwd=str(PROJECT_ROOT),
# 딸려 오는 화면 모듈에 별칭 경로(`@config/…`)가 있어 형 검사는 실패한다 —
# 그래도 JS 는 나온다. 형 검사는 `npm run typecheck` 몫이고, 여기서는 **돌려 보는** 것이 목적.
check=False,
capture_output=True,
)
compiled = next(out.rglob("B06_Section_UI_Cross_Culvert_Extra.js"), None)
assert compiled is not None, "화면 코드가 JS 로 안 나옴 — tsc 실패"
(out / "runner.cjs").write_text(
_RUNNER % {"x": requested_x, "d": REQUESTED_D}, encoding="utf-8"
)
result = tmp_path / "result.json"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(out / "runner.cjs"), str(result), str(compiled)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_좌우_이동은_상하_내림의_1_2배까지_따라_나간다(tmp_path: Path) -> None:
produced = _run(tmp_path)
assert produced["count"] >= 1, "시험용 자리에서 단이 하나도 안 섰음"
assert produced["appliedX"] == pytest.approx(EXPECTED_FLOOR, abs=0.05), (
f"요청 {REQUESTED_X}m 가 하한 {EXPECTED_FLOOR}m 로 안 밀림 — {produced['appliedX']}"
)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_하한에_눌린_사실을_알린다(tmp_path: Path) -> None:
"""요청이 통째로 무시됐으면 그 까닭이 남아야 한다 — 화면이 툴팁으로 읽는다."""
produced = _run(tmp_path)
assert produced["floor"] == pytest.approx(EXPECTED_FLOOR, abs=0.05)
# 지형이 막은 것이 아니므로 「지형에 막힘」은 뜨면 안 된다 — 두 까닭이 섞이면 오해한다.
assert not produced["blocked"], "지형 탓으로 잘못 알림"
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_요청이_없으면_알리지_않는다(tmp_path: Path) -> None:
"""기본 자리(좌우 0)에서는 하한이 걸려도 알릴 것이 없다 — 모든 벽에 뜨면 소리만 된다."""
produced = _run(tmp_path, requested_x=0.0)
assert produced["count"] >= 1
assert not produced["floor"], "손대지 않은 벽에도 하한을 알림"
+78
View File
@@ -0,0 +1,78 @@
"""B06 세월교 세트(백엔드) 자체검증 — 2026-08-25.
확인 대상:
· `_ford_set()`이 정본·레지스트리 기본값으로 제원을 만든다.
· 날개벽 각도가 바닥판 편측 연장(길이×cos각)을 만든다. 설치 "없음"이면 0.
· `attach_culvert_sets()`가 세월교를 **소유 측점 한 곳에만** 붙이고,
배수관은 종전대로 ±0.02m 측점 일치로만 붙인다.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
FORD_SLAB_THICKNESS_M,
FORD_WALL_THICKNESS_M,
_ford_set,
attach_culvert_sets,
)
def test_ford_set_defaults() -> None:
"""저장 옵션이 없으면 레지스트리 기본값(파형강관 Ø1000·월류 폭 10m)을 쓴다."""
spec = _ford_set(None)
assert spec["type"] == "ford"
assert spec["pipe_kind"] == "파형강관"
assert spec["diameter_m"] == 1.0
assert spec["pipe_count"] == 1
assert spec["span_m"] == 10.0
assert spec["slab_thickness_m"] == FORD_SLAB_THICKNESS_M
assert spec["wall_thickness_m"] == FORD_WALL_THICKNESS_M
def test_wing_angle_drives_slab_extension() -> None:
"""바닥판 편측 연장 = 날개벽 길이 × cos(각도). 45°·2m면 1.414m."""
spec = _ford_set({"wing_in_length_m": 2, "wing_in_angle_deg": 45})
assert spec["wing_in"]["slab_extend_m"] == round(2 * math.cos(math.radians(45)), 3)
steep = _ford_set({"wing_out_length_m": 2, "wing_out_angle_deg": 30})
assert steep["wing_out"]["slab_extend_m"] == round(2 * math.cos(math.radians(30)), 3)
def test_wing_absent_gives_no_extension() -> None:
"""날개벽 설치 '없음'이면 연장 0 — 바닥판은 구체 폭만 남는다."""
spec = _ford_set({"wing_in": "없음", "wing_in_length_m": 2, "wing_in_angle_deg": 45})
assert spec["wing_in"]["installed"] is False
assert spec["wing_in"]["slab_extend_m"] == 0.0
def test_attach_ford_only_owner_station(tmp_path: Path) -> None:
"""세월교도 배수관과 같이 **소유 측점 한 곳에만** 붙는다(2026-08-25 사용자 확정).
월류 폭 절반까지 옆 측점에 붙이면 그 측점 횡단도에 같은 구체가 한 벌 더 그려지고
3D 솔리드도 어긋난 채 겹친다. 3D 스윕 길이는 소유 측점이 `span_m`으로 직접 낸다.
"""
pipe_points = tmp_path / "B04_PreProcess" / "drainage" / "edits"
pipe_points.mkdir(parents=True)
(pipe_points / "pipe_points.json").write_text(
'{"points": ['
'{"chainage_m": 100.0, "facility": "ford_bridge", "options": {"ford_width_m": 10}},'
'{"chainage_m": 200.0, "facility": "pipe", "options": {}}]}',
encoding="utf-8",
)
sections = [{"chainage_m": value} for value in (94.0, 95.5, 100.0, 104.5, 106.0, 200.0, 201.0)]
attached = attach_culvert_sets(tmp_path, sections)
kinds = [
("ford" if "ford" in section else "culvert" if "culvert" in section else None)
for section in sections
]
assert kinds == [None, None, "ford", None, None, "culvert", None]
assert attached == 2
@@ -0,0 +1,71 @@
"""측점 없는 구조물 알림 — 조용히 빠지던 것을 드러낸다 (계획서 3-14 ㉯, 2026-09-09).
관을 나중에 놓거나 옮기면 그 측점이 안 생긴다 — 측점을 만드는 자리가 B05 노선 [확정]
한 곳뿐이기 때문이다. 그 관은 횡단도에도 안 서고 수량·금액에서 통째로 빠진다.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Router_Confirm import ( # noqa: E402
load_sampling_snapshot,
sampling_snapshot_path,
)
from B06_Section.B06_Section_Router_Stations import ( # noqa: E402
STATION_MATCH_TOLERANCE_M,
_station_chainages,
router,
)
def test_두_길이_다_열려_있다() -> None:
"""점검(GET)과 만들기(POST)가 같은 주소에 선다 — 화면이 하나만 알면 된다."""
paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes}
assert ("/api/projects/{project_id}/section/missing-stations", ("GET",)) in paths
assert ("/api/projects/{project_id}/section/missing-stations", ("POST",)) in paths
def test_종단_정본에서_측점을_읽는다(tmp_path: Path) -> None:
folder = tmp_path / "B06_Section" / "longitudinal"
folder.mkdir(parents=True)
(folder / "longitudinal.json").write_text(
json.dumps({"stations": [{"chainage_m": 0.0}, {"chainage_m": 85.05}, {"bad": 1}]}),
encoding="utf-8",
)
assert _station_chainages(tmp_path) == [0.0, 85.05]
def test_측점이_없으면_빈_목록(tmp_path: Path) -> None:
"""파일이 없다고 터지지 않는다 — 이 줄은 덤이라 화면을 막으면 안 된다."""
assert _station_chainages(tmp_path) == []
def test_같은_자리_판정은_50cm() -> None:
"""⚠ 스냅 때문이다 — 관 440.241 은 **측점 440.0** 위에 선다(2026-09-09 실측).
측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅하므로(횡단 파일명이
정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드) 최대 어긋남이 0.5m 다.
0.05m 로 보면 **있는 측점을 없다고 세어** 또 만들라고 한다.
"""
assert STATION_MATCH_TOLERANCE_M == 0.5
def test_샘플링_조건이_없으면_None(tmp_path: Path) -> None:
"""조건을 지어내지 않는다 — 그 측점만 다른 지표에서 뽑히면 지반고가 어긋난다."""
assert load_sampling_snapshot(tmp_path) is None
path = sampling_snapshot_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"filter_key": "", "method": "dtm"}), encoding="utf-8")
assert load_sampling_snapshot(tmp_path) is None
path.write_text(
json.dumps({"filter_key": "csf", "method": "tin", "smooth": True}), encoding="utf-8"
)
snapshot = load_sampling_snapshot(tmp_path)
assert snapshot is not None and snapshot["method"] == "tin" and snapshot["smooth"] is True
@@ -0,0 +1,145 @@
"""포장 구간 정본 + 물넘이포장 횡단 스펙 — 2026-08-28 사용자 확정 스펙.
- 포장은 **사용자가 구간으로 지정**한다(구조물 정본 G군). 종단경사 자동 판정은 더 이상
포장을 켜지 않고 경고(`pavement_suggested`)로만 남는다.
- 물넘이포장은 노면을 월류 폭만큼 판 자리다 — 범위 안 모든 횡단도에 붙고, 그 범위는
언제나 포장이다.
"""
import json
from B06_Section.B06_Section_Engine_Culvert import (
FORD_PAVEMENT_DEFAULT_WIDTH_M,
attach_culvert_sets,
load_culvert_sets,
)
from B06_Section.B06_Section_Router_Design import pavement_ranges, paved_at
def _write_pipe_points(root, points):
edits = root / "B04_PreProcess" / "drainage" / "edits"
edits.mkdir(parents=True, exist_ok=True)
(edits / "pipe_points.json").write_text(
json.dumps({"route_signature": "무관", "points": points}, ensure_ascii=False),
encoding="utf-8",
)
def _write_structures(root, structures):
route_dir = root / "B05_Profile" / "route"
route_dir.mkdir(parents=True, exist_ok=True)
(route_dir / "structures.json").write_text(
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
encoding="utf-8",
)
FORD = {
"chainage_m": 100.0,
"source": "user",
"facility": "ford_pavement",
"options": {"ford_width_m": 6.0, "ford_height_m": 0.4, "ford_slope_pct": 3.0},
}
# ── 물넘이 스펙 ────────────────────────────────────────────────────────────
def test_ford_pavement_becomes_its_own_spec(tmp_path):
_write_pipe_points(tmp_path, [FORD])
(spec,) = load_culvert_sets(tmp_path).values()
assert spec["type"] == "ford_pavement"
assert spec["span_m"] == 6.0
assert spec["depth_m"] == 0.4
assert spec["slope_pct"] == 3.0
def test_ford_pavement_without_values_keeps_default_width_and_no_depth(tmp_path):
"""깊이는 지어내지 않는다 — 없으면 None이라 화면이 파임을 그리지 않는다."""
_write_pipe_points(tmp_path, [{"chainage_m": 50.0, "facility": "ford_pavement"}])
(spec,) = load_culvert_sets(tmp_path).values()
assert spec["span_m"] == FORD_PAVEMENT_DEFAULT_WIDTH_M
assert spec["depth_m"] is None
assert spec["slope_pct"] is None
def test_ford_pavement_attaches_to_every_section_in_span(tmp_path):
"""월류 폭 6m → 기준측점 ±3m 안의 측점 전부에 붙는다(다른 시설은 소유 측점 한 곳)."""
_write_pipe_points(tmp_path, [FORD])
sections = [{"chainage_m": value} for value in (94.0, 97.5, 100.0, 102.5, 106.0)]
attach_culvert_sets(tmp_path, sections)
attached = [s["chainage_m"] for s in sections if "ford_pavement" in s]
assert attached == [97.5, 100.0, 102.5]
assert all("culvert" not in s and "ford" not in s for s in sections)
# ── 포장 구간 ──────────────────────────────────────────────────────────────
def test_pavement_range_comes_from_group_g_structure(tmp_path):
_write_structures(
tmp_path,
[
{
"type_id": "pavement_concrete",
"chainage_m": 200.0,
"start_m": 180.0,
"end_m": 230.0,
"placement": "interval",
}
],
)
assert pavement_ranges(tmp_path) == [(180.0, 230.0)]
def test_ford_pavement_range_is_always_paved(tmp_path):
_write_pipe_points(tmp_path, [FORD])
ranges = pavement_ranges(tmp_path)
assert ranges == [(97.0, 103.0)]
assert paved_at(100.0, ranges) is True
assert paved_at(97.0, ranges) is True
assert paved_at(96.9, ranges) is False
def test_other_groups_are_not_pavement(tmp_path):
_write_structures(
tmp_path,
[
{
"type_id": "revetment",
"chainage_m": 100.0,
"start_m": 100.0,
"end_m": 115.0,
"placement": "interval",
}
],
)
assert pavement_ranges(tmp_path) == []
def test_missing_truth_files_are_not_an_error(tmp_path):
assert pavement_ranges(tmp_path) == []
# ── 포장 판정 규칙 ─────────────────────────────────────────────────────────
def test_outside_range_keeps_user_value_and_defaults_unpaved():
"""경사 제안은 더 이상 포장을 켜지 않는다 — 구간 밖은 사용자 값(없으면 비포장)."""
ranges = [(10.0, 20.0)]
assert paved_at(30.0, ranges) is False
assert paved_at(30.0, ranges, True) is True
assert paved_at(30.0, ranges, False) is False
def test_inside_range_overrides_user_off_switch():
"""구간 안은 강제 포장 — 물넘이는 콘크리트 노면이고 지정 구간은 자동 반영이다."""
assert paved_at(15.0, [(10.0, 20.0)], False) is True
# ── 독립 기슭막이 ─────────────────────────────────────────────────────────
#
# 2026-08-28 이관으로 기슭막이는 구조물 정본 D군을 떠나 관 지점 시설이 됐다. 전용 엔진
# (`B06_Section_Engine_Revetment`)은 호출자가 사라져 2026-09-01 삭제했다 — 벽은 관 세트
# (`B06_Section_Engine_Culvert._revet_set`)가 만든다. 그쪽 검증은
# `test_revet_span_source.py`에 있다.
+107
View File
@@ -0,0 +1,107 @@
"""배수관 연장(m)이 **정본까지 간다** — 2026-09-08 B08 창 요청.
무슨 일이었나 — 관 길이는 횡단 기하가 **m 단위 올림까지** 끝낸 값인데(`CulvertLayout.pipe.lengthM`)
**정본(`design`)에 없었다.** 그래서 수량(B08)이 관 11개를 보고도 **연장을 못 내** 배수관 공종이
막혀 있었다. B08 창은 「계산을 서버로 옮기든가 저장 때 남기든가」 둘로 갈래를 냈는데, 실제로는
**그 계산이 이미 서버 Node 로 돌고 있었다**(`B06_Section_Server_Calc_Node` ←
`Server_Calc_Prebuild.recompute_server_side`, [저장]·[확정] 때). 그래서 **그 다리에 값 한 줄만**
더 실었다 — 계산을 두 벌로 짜지 않는다(CLAUDE.md 5장).
⚠ 이름은 `pipe_length_m` 로 **못 박았다**. 오늘 `back_len_cm` 을 엔진이 `stone_back_length_cm`
로 읽어 **저장값이 영영 안 닿던** 사고가 있었다 — 키 이름이 어긋나면 값은 조용히 사라진다.
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
_LAYOUTS = PROJECT_ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts"
_PERSIST = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts"
_TYPES = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Types.ts"
def test_패치로_받는다():
patch = CrossSectionPatch(chainage_m=85.05, pipe_length_m=7.0)
assert patch.pipe_length_m == 7.0
assert CrossSectionPatch(chainage_m=0).pipe_length_m is None
def test_음수는_막는다():
import pytest
with pytest.raises(ValueError):
CrossSectionPatch(chainage_m=0, pipe_length_m=-1)
def test_같은_길로_정본에_실린다():
"""구조물 면적이 실리는 그 목록에 함께 있어야 [저장]·[확정]·서버 초기값이 모두 나른다."""
source = _LAYOUTS.read_text(encoding="utf-8")
assert "export const STRUCTURE_ROW_KEYS" in source
block = source[source.index("STRUCTURE_ROW_KEYS = [") :][:300]
assert '"pipe_length_m"' in block, "관 길이가 정본에 얹는 키 목록에 없다"
assert '"cut_area_m2"' in block, "면적과 같은 목록이어야 한다(길이 하나만 따로 나르지 않는다)"
assert "STRUCTURE_ROW_KEYS" in _PERSIST.read_text(encoding="utf-8")
def test_면적을_못_내는_측점에서도_길이는_낸다():
"""설계선이 모자라 폐회로 면적을 못 내는 자리에도 **관은 서 있다** — 수량은 그 길이가 필요하다."""
source = _LAYOUTS.read_text(encoding="utf-8")
assert "return pipeRow;" in source, "면적 실패 시 null 로 떨어지면 관 길이까지 사라진다"
assert "layouts.culvert?.pipe?.lengthM" in source
def test_이름이_한_벌이다():
"""`back_len_cm` ↔ `stone_back_length_cm` 사고 재발 방지 — 이름을 적는 자리가 갈리면 안 된다.
저장 흐름(`_PERSIST`)은 이름을 **직접 안 적고** 공용 목록(`STRUCTURE_ROW_KEYS`)을 돈다 —
그것이 이름이 갈리지 않게 하는 방법이다. 그래서 여기서는 그 점을 지킨다.
"""
for path in (_LAYOUTS, _TYPES):
assert "pipe_length_m" in path.read_text(encoding="utf-8"), path.name
persist = _PERSIST.read_text(encoding="utf-8")
assert "pipe_length_m" not in persist, "이름을 또 적으면 갈릴 자리가 하나 더 생긴다"
assert "STRUCTURE_ROW_KEYS" in persist
def test_서버_화이트리스트가_TS_목록과_짝이다():
"""⚠ 서버에도 **받을 키 목록**이 따로 있다(`_AREA_KEYS`). 한쪽만 늘리면 Node 가 값을 내도
거기서 조용히 버려진다 — 관 길이를 더하며 실제로 걸린 자리다(2026-09-08).
"""
import re
from B06_Section.B06_Section_Server_Calc_Prebuild import _AREA_KEYS
block = _LAYOUTS.read_text(encoding="utf-8")
block = block[block.index("STRUCTURE_ROW_KEYS = [") :]
block = block[: block.index("] as const")]
ts_keys = set(re.findall(r'"([a-z_0-9]+)"', block))
assert ts_keys == set(_AREA_KEYS), (
f"짝이 갈림 — TS {sorted(ts_keys)} vs 서버 {sorted(_AREA_KEYS)}"
)
def test_소유_측점에만_싣는다():
"""⚠ 세트는 **폭의 절반까지 옆 측점에도** 붙는다(`attach_culvert_sets`). 그 자리에 길이를
실으면 **같은 관을 두 번** 센다 — 실측에서 관 9개에 값이 10곳 실렸다(2026-09-08).
가리는 열쇠는 스펙에 함께 얹는 `chainage_m`(그 시설이 **놓인** 자리)이다. 파이썬·TS
두 쪽이 같이 얹어야 한다 — 한쪽만 얹으면 세트 거울 시험이 깨진다.
⚠ **주인을 가리는 법이 2026-09-09 에 바뀌었다** — 거리(0.02m)로 자르던 것을
**가장 가까운 측점 하나**로 바꿨다. 관 자리와 측점 자리가 **스냅 때문에 최대 0.5m
어긋나기 때문**이다(관 440.241 → 측점 440.0). 거리로 자르면 그런 관은 주인이 없어
길이가 아무 데도 안 실리고 B08 이 「연장 없음」으로 막는다(실측: 배수관 넷).
**하나만 고르는 것**은 그대로라 두 번 세지 않는다.
"""
layouts = _LAYOUTS.read_text(encoding="utf-8")
assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다"
assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다"
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(encoding="utf-8")
ts = (PROJECT_ROOT / "common_util" / "common_util_culvert_sets.ts").read_text(encoding="utf-8")
assert '"chainage_m": pipe_chainage' in py
assert "chainage_m: pipeChainage" in ts
@@ -0,0 +1,109 @@
"""포장 구간 재계산이 **사용자 값을 지우지 않는지**.
왜 (실사고 `b6941bd2`) — 포장 구간·세월교 측점은 저장 때 서버가 횡단을 **다시 계산해
통째로 갈아 끼운다**. 계산이 만들지 않는 값(구조물 조정·다단 구간값 등)은 그 자리에서
따로 베껴 넣어야 살아남는데, `extra_spans` 가 목록에서 빠져 조용히 사라졌다.
이 시험이 보는 것은 **베껴 넣는 그 자리**다. 지형·계획고는 시험 대상이 아니므로 가짜로
바꿔 끼운다(그 둘은 각자 시험이 있다). 목록 자체가 갈리는 것은
`test_b06_user_touched_keys.py`, 새 사용자 값이 목록에서 새는 것은
`test_b06_design_key_split.py` 가 지킨다.
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402
USER_VALUES = {
"display_half_width_m": 12.5,
"inlet_structure": "L",
"basin_adjust": {"innerWidthM": 1.5, "innerHeightM": 1.5, "lateralM": 0.5, "slopeM": 0.2},
"revet_adjust": {"outlet": {"x": 2.0, "d": 0.5, "h": None, "m": None}},
"ford_adjust": {"inlet": {"heightM": None, "lateralM": 1.0, "slopeM": 0.0}},
"box_adjust": {"left": {"lengthM": 2.0, "riseM": 0.0}},
"extra_wall_counts": {"outlet": 2, "basin": 0},
"extra_spans": {"extra0": {"length_m": 15.0, "before_m": 8.0, "after_m": 7.0}},
"revet_link_detached": True,
"revet_follow_grade": False,
}
#: 재계산 인자로 되먹여 살아남는 갈래 — 결과에 그대로 나와야 한다.
FED_BACK = {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "left",
"ditch_type": "l_type",
"two_stage_slope": False,
"rock_boundary_offset_m": -1.5,
}
def _section() -> dict:
design = {
"paved": False,
"status": "ok",
"pavement_suggested": True,
**USER_VALUES,
**FED_BACK,
}
return {"chainage_m": 100.0, "samples": [], "design": design}
def _install_fakes(monkeypatch) -> dict:
"""지형·계획고·포장구간을 가짜로 — 이 시험의 대상은 「값을 베껴 넣는 자리」다."""
seen: dict = {}
def fake_compute(samples, elevation, **kwargs):
seen.update(kwargs)
# 계산이 내는 값만 담은 새 설계 — 사용자 값은 하나도 없다(실제 엔진과 같다).
return {
"paved": True,
"cut_area_m2": 1.0,
"fill_area_m2": 2.0,
**{
key: kwargs[key]
for key in (
"ground_type",
"section_mode",
"ditch_side",
"ditch_type",
"two_stage_slope",
"rock_boundary_offset_m",
)
if key in kwargs
},
}
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)])
monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute)
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
return seen
def test_포장_재계산_뒤에도_사용자_값이_남는다(monkeypatch):
_install_fakes(monkeypatch)
sections = [_section()]
changed = design_mod.enforce_pavement_ranges({}, sections, Path("."), None)
assert changed == 1, "재계산이 아예 안 돌았음 — 시험 전제가 깨짐"
result = sections[0]["design"]
for key, value in USER_VALUES.items():
assert result.get(key) == value, f"{key} 가 재계산에서 사라짐"
assert result["status"] == "ok"
assert result["pavement_suggested"] is True
assert result["paved"] is True, "포장 구간인데 포장으로 안 바뀜"
def test_되먹임_값이_인자로_실제로_들어간다(monkeypatch):
"""①갈래는 「인자로 넣어」 지킨다 — 안 넣으면 기본값으로 되돌아간다."""
seen = _install_fakes(monkeypatch)
sections = [_section()]
design_mod.enforce_pavement_ranges({}, sections, Path("."), None)
for key, value in FED_BACK.items():
assert seen.get(key) == value, f"{key} 가 재계산 인자로 안 들어감(넘긴 값: {seen.get(key)})"
assert seen.get("paved") is True, "포장 구간 재계산은 포장으로 켜야 함"
@@ -0,0 +1,76 @@
"""[저장]이 여는 창구 넷이 **비어 있으면 안 나가는지**.
왜 (2026-09-07 조사) — [저장]은 창구 다섯을 순서대로 연다(관 옵션·상단측·관 목록·구조물·
`sections/save`). 넷은 초안이 비면 **요청 없이 즉시 돌아와야** 한다. 그래야 보통 저장이
1~2건으로 끝나고, 하나가 실패해도 나머지가 나간다(2026-08-29 사고 뒤 일부러 만든 구조).
이 성질이 깨지면 저장마다 빈 요청이 붙고, 「초안을 한 덩어리로 합치자」는 명분이 되살아난다.
합치지 않기로 한 근거(0-6)가 이 성질에 기대고 있으므로 그물을 남긴다.
코드를 읽어 지키는 시험이다 — 화면 왕복 없이 무너짐을 잡는 것이 목적이다.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
#: (파일, 함수 이름, 비었을 때 돌아가는 표시)
CHANNELS = [
(
ROOT / "B05_Profile" / "B05_Profile_Api_Pipes_Draft.ts",
"flushPendingPipes",
"관 목록",
),
(
ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts",
"flushUphillOverrides",
"상단측(측구 방향)",
),
(
ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts",
"flushPendingStructures",
"구조물",
),
(
ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts",
"flushCulvertOptions",
"관 옵션",
),
]
#: 비었을 때 빠져나가는 모양 — `return;` 만 있는 이른 반환.
_EARLY_RETURN = re.compile(r"\breturn\s*;")
def _body(path: Path, name: str) -> str:
"""함수 머리부터 다음 최상위 선언 전까지 — 이른 반환이 있는지만 보면 되므로 넉넉히 자른다."""
text = path.read_text(encoding="utf-8")
match = re.search(rf"(?:async\s+)?(?:export\s+)?(?:async\s+)?function\s+{name}\s*\(", text)
if match is None:
match = re.search(rf"\b{name}\s*\([^)]*\)\s*(?::[^{{]+)?\{{", text)
assert match is not None, f"{path.name} 에서 {name} 을 못 찾음"
return text[match.start() : match.start() + 900]
def test_창구_넷은_초안이_비면_요청을_안_낸다():
problems = []
for path, name, label in CHANNELS:
body = _body(path, name)
if not _EARLY_RETURN.search(body):
problems.append(f"{label}({path.name}:{name}) — 빈 초안에서 빠져나가는 자리가 없음")
assert not problems, "\n".join(problems)
def test_저장이_창구를_다섯_다_거친다():
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다."""
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
for token in (
"flushCulvertOptions()",
"flushUphillOverrides(",
"flushPendingPipes(",
"flushPendingStructures(",
):
assert token in persist, f"{token} 가 저장 앞단에서 사라짐"
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물은 `catch` 로 감싸 둔다.
assert persist.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
@@ -0,0 +1,118 @@
"""[저장]이 「행은 있는데 설계가 빈 측점」을 채우는지 (2026-09-09).
재생성이 `cross_sections` 행을 지우고 다시 쓰면 `data.design` 에 면적 4키만 남는다.
`get_cross_section_designs` 는 `ground_type` 있는 줄만 담으므로 그 상태는 「설계 없음」이고,
B08 토적표가 통째로 0 이 됐다(창 셋에서 같은 증상). 예전에는 체인이 뒤이어 부르는 [확정]이
그 자리를 채워 가려져 있었다.
⚠ **값이 있는 행은 안 건드린다**가 이 시험의 핵심이다 — 사용자 조작값·이월분이 거기 있다.
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from typing import Any
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import B06_Section.B06_Section_Router_Confirm as confirm # noqa: E402
class _Conn:
async def begin(self) -> None:
return None
async def commit(self) -> None:
return None
async def rollback(self) -> None:
return None
class _Acquire:
async def __aenter__(self) -> _Conn:
return _Conn()
async def __aexit__(self, *_: Any) -> None:
return None
class _Pool:
def acquire(self) -> _Acquire:
return _Acquire()
def _async(value: Any):
async def _inner(*_: Any, **__: Any) -> Any:
return value
return _inner
def _run_save(monkeypatch: pytest.MonkeyPatch, missing: list[float]) -> list[float]:
"""`save_sections` 를 돌리고 **기본 설계를 계산하라고 넘긴 측점 목록**을 돌려준다."""
seen: list[float] = []
monkeypatch.setattr(confirm, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(
confirm, "get_longitudinal_section", _async({"longitudinal_file_path": "long.json"})
)
monkeypatch.setattr(confirm, "get_project_storage_relative_path", _async("1/3/proj"))
monkeypatch.setattr(confirm, "get_cross_section_chainages", _async([0.0, 20.0, 40.0]))
monkeypatch.setattr(confirm, "get_cross_sections_missing_design_chainages", _async(missing))
monkeypatch.setattr(confirm, "resolve_stored_project_path", lambda _p: str(PROJECT_ROOT))
# 행 자체가 없는 측점은 이 시험의 관심사가 아니다 — 빈 목록으로 고정한다.
monkeypatch.setattr(confirm, "_rowless_station_chainages", lambda *_a, **_k: [])
def _spy(_root, _path, chainages, _standard):
seen.extend(chainages)
return [(value, {"ground_type": "soil"}) for value in chainages]
monkeypatch.setattr(confirm, "_compute_default_designs", _spy)
monkeypatch.setattr(confirm, "_apply_section_edits", _async(None))
monkeypatch.setattr(confirm, "_recompute_stored_designs", _async(None))
from uuid import UUID
response = asyncio.run(
confirm.save_sections(
project_id=UUID("fa76c162-71c7-46e5-a95d-fb3930665a45"), route_id=184, request=None
)
)
assert getattr(response, "confirmed", None) is False, response
return seen
def test_설계가_빈_측점을_채운다(monkeypatch: pytest.MonkeyPatch) -> None:
seen = _run_save(monkeypatch, [20.0, 40.0])
assert sorted(seen) == [20.0, 40.0]
def test_값이_있는_행은_안_건드린다(monkeypatch: pytest.MonkeyPatch) -> None:
"""빈 측점이 없으면 기본 설계를 아예 계산하지 않는다 — 덮어쓸 일이 없다."""
seen = _run_save(monkeypatch, [])
assert seen == []
def test_빈_설계의_뜻이_ground_type_없음이다() -> None:
"""판정은 `get_cross_sections_missing_design_chainages` 한 곳에서만 한다."""
import inspect
from B06_Section.B06_Section_Repository import get_cross_sections_missing_design_chainages
source = inspect.getsource(get_cross_sections_missing_design_chainages)
assert 'design.get("ground_type")' in source
def test_저장도_확정과_같은_판정을_쓴다() -> None:
"""두 갈래가 같은 조회를 써야 「저장했는데 확정과 다르다」가 안 생긴다."""
import inspect
source = inspect.getsource(confirm.save_sections)
assert "get_cross_sections_missing_design_chainages" in source
@@ -0,0 +1,52 @@
/* Node 진입점(구조물 면적) 연결 확인 — 2026-09-06.
*
* 면적 산식 자체는 `test_b06_structure_areas.mjs` 가 본다. 여기서 지키는 것은 하나:
* **구조물이 없는 측점은 결과에 나오면 안 된다.** 나오면 서버가 표준 계산값을
* 폐회로 보정값으로 덮어써 수량이 조용히 달라진다.
*
* 실행: node tmp/tests/test_b06_structure_areas_node.mjs
*/
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import assert from "node:assert/strict";
const ROOT = resolve(import.meta.dirname, "../..");
const BUNDLE = join(ROOT, "config/server_calc_node/B06_Section_Server_Calc_Node.js");
/** 평지 위 표준 횡단 한 측점 — 구조물 없음. */
function plainSection(chainage) {
const samples = [];
for (let offset = -15; offset <= 15; offset += 1) {
samples.push({ offset_m: offset, elevation_m: 100, valid: true });
}
return {
chainage_m: chainage,
samples,
design: {
design_elevation_m: 100,
design_line: [
{ offset_m: -3, elevation_m: 100 },
{ offset_m: 3, elevation_m: 100 },
],
road_edges: { left: { offset_m: -3, elevation_m: 100 }, right: { offset_m: 3, elevation_m: 100 } },
cut_area_m2: 1.23,
fill_area_m2: 4.56,
},
};
}
const work = mkdtempSync(join(tmpdir(), "areas-node-"));
const input = join(work, "input.json");
const output = join(work, "output.json");
writeFileSync(
input,
JSON.stringify({ detail: { cross_sections: [plainSection(0), plainSection(20)] } }),
);
execFileSync("node", [BUNDLE, input, output], { cwd: ROOT });
const { areas: rows } = JSON.parse(readFileSync(output, "utf8"));
assert.ok(Array.isArray(rows), "결과는 배열이어야 한다");
assert.equal(rows.length, 0, `구조물 없는 측점은 결과에 없어야 한다 (받은 수: ${rows.length})`);
console.log("OK — 구조물 없는 측점은 면적을 덮지 않는다");
@@ -0,0 +1,188 @@
"""사토장 — 용량에서 폭을 정해 측점마다 단면을 세운다 (2026-09-09).
⚠ 잠그는 것
① 용량이 커질수록 폭이 넓어지고, 상한에서 멈추면 못 담은 몫이 값으로 남는다.
② 사토장이 선 측점은 **노선 성토가 줄고** 그만큼이 사토장 몫으로 간다(두 번 세지 않기).
③ 「자동」이면 그 측점의 **성토 쪽**에 선다.
④ 구간 밖 측점은 안 건드린다 — **새 측점을 만들지 않는다**.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills # noqa: E402
def _samples() -> list[dict]:
"""우측(-offset)이 높고 좌측(+offset)이 낮은 지반 — 좌가 성토 쪽이다."""
return [
{"offset_m": round(offset, 3), "elevation_m": round(100.0 - 0.5 * offset, 4), "valid": True}
for offset in [x * 0.5 for x in range(-30, 31)]
]
def _sections(chainages: list[float]) -> list[dict]:
return [
{
"chainage_m": chainage,
"samples": _samples(),
"design": compute_cross_design(
_samples(),
99.8,
ground_type="soil",
section_mode="right_cut",
ditch_side="right",
),
}
for chainage in chainages
]
_LONGITUDINAL = {
"design_profiles": [
{"samples": [{"chainage_m": float(c), "elevation_m": 99.8} for c in range(0, 201, 20)]}
]
}
def _write_site(root: Path, capacity_m3: float, **options) -> None:
(root / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True)
(root / "B05_Profile" / "route" / "structures.json").write_text(
json.dumps(
{
"revision": 1,
"structures": [
{
"structure_id": "sp-1",
"type_id": "spoil_bank",
"placement": "interval",
"start_m": 100.0,
"end_m": 160.0,
"options": {"capacity_m3": capacity_m3, **options},
}
],
},
ensure_ascii=False,
),
encoding="utf-8",
)
def _run(root: Path, capacity_m3: float, **options) -> list[dict]:
_write_site(root, capacity_m3, **options)
sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0])
enforce_spoil_fills(_LONGITUDINAL, sections, root)
return sections
def _placed(sections: list[dict]) -> float:
inside = [s for s in sections if 100.0 <= s["chainage_m"] <= 160.0]
return float(inside[0]["design"].get("spoil_fill_placed_m3") or 0.0)
def test_용량이_클수록_폭이_넓어진다(tmp_path: Path) -> None:
small = _run(tmp_path, 200.0)
large = _run(tmp_path, 800.0)
narrow = small[1]["design"]["spoil_fill_width_m"]
wide = large[1]["design"]["spoil_fill_width_m"]
assert 0 < narrow < wide
assert _placed(small) == pytest.approx(200.0, rel=0.02)
assert _placed(large) == pytest.approx(800.0, rel=0.02)
def test_담을_수_없으면_남은_몫이_값으로_남는다(tmp_path: Path) -> None:
"""상한에서 멈춘다 — 임의로 더 넓히지 않는다."""
sections = _run(tmp_path, 1_000_000.0)
design = sections[1]["design"]
assert design["spoil_fill_unplaced_m3"] > 0
assert design["spoil_fill_width_m"] == pytest.approx(design["spoil_fill_max_width_m"])
def test_노선_성토가_그만큼_줄어든다(tmp_path: Path) -> None:
"""같은 흙을 두 번 세지 않는다(확정 ㉠)."""
plain = _sections([120.0])[0]["design"]
sections = _run(tmp_path, 400.0)
inside = sections[2]["design"]
assert inside["spoil_fill_area_m2"] > 0
assert inside["spoil_fill_replaced_fill_m2"] > 0
assert inside["fill_area_m2"] == pytest.approx(
plain["fill_area_m2"] - inside["spoil_fill_replaced_fill_m2"], abs=1e-6
)
def test_자동이면_성토_쪽에_선다(tmp_path: Path) -> None:
sections = _run(tmp_path, 400.0, side="자동(성토 쪽)")
# right_cut = 우측 절토 ⇒ 성토는 좌측(+offset).
assert sections[1]["design"]["spoil_fill_side"] == "left"
def test_구간_밖_측점은_안_건드린다(tmp_path: Path) -> None:
"""새 측점을 만들지 않고, 구간 밖은 종전 그대로다(확정 ③)."""
sections = _run(tmp_path, 400.0)
assert len(sections) == 6
for section in sections:
design = section["design"]
if 100.0 <= section["chainage_m"] <= 160.0:
assert design["spoil_fill_area_m2"] > 0
else:
assert design.get("spoil_fill_area_m2", 0) == 0
def test_용량이_없으면_아무것도_안_세운다(tmp_path: Path) -> None:
(tmp_path / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True)
(tmp_path / "B05_Profile" / "route" / "structures.json").write_text(
json.dumps(
{
"revision": 1,
"structures": [
{
"structure_id": "sp-1",
"type_id": "spoil_bank",
"placement": "interval",
"start_m": 100.0,
"end_m": 160.0,
"options": {},
}
],
}
),
encoding="utf-8",
)
sections = _sections([120.0])
assert enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) == 0
assert sections[0]["design"]["spoil_fill_area_m2"] == 0
def test_사토장을_지우면_값도_사라진다(tmp_path: Path) -> None:
"""⚠ 화면 실측으로 잡은 자리(2026-09-09) — 얹기만 하고 **지우지 않아** 구조물을 없앤 뒤에도
횡단도에 계속 그려지고 면적표에도 서 있었다.
"""
sections = _run(tmp_path, 400.0)
inside = sections[2]["design"]
assert inside["spoil_fill_area_m2"] > 0
plain_fill = _sections([120.0])[0]["design"]["fill_area_m2"]
# 구조물을 지운다 — 빈 목록으로 덮어쓴다.
(tmp_path / "B05_Profile" / "route" / "structures.json").write_text(
json.dumps({"revision": 2, "structures": []}, ensure_ascii=False), encoding="utf-8"
)
changed = enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path)
assert changed > 0, "지운 사토장을 되돌리지 않았다"
for section in sections:
design = section["design"]
# 칸은 남아도 **값이 0** 이어야 한다 — 설계 결과는 사토장이 없어도 칸을 늘 싣는다.
assert float(design.get("spoil_fill_area_m2") or 0) == 0, section["chainage_m"]
assert float(design.get("spoil_fill_width_m") or 0) == 0, section["chainage_m"]
assert design.get("spoil_fill_capacity_m3") in (None, 0), section["chainage_m"]
# 노선 성토도 원래 값으로 돌아온다 — 사토장 몫을 빼 두었던 것이 복구된다.
assert sections[2]["design"]["fill_area_m2"] == pytest.approx(plain_fill, abs=1e-6)
@@ -0,0 +1,64 @@
"""저장된 표준 횡단면이 **브라우저까지 닿는지**.
왜 (2026-09-07 실측) — 표준단면 편집값은 sessionStorage 에만 살아서 **탭을 새로 열면**
사라졌다. 그때 브라우저는 config 기본값으로, 서버는 저장분으로 계산해 같은 측점이 갈렸다.
용화(route 169) 실측 — 저장분은 암반 횡단경사 **5%** · 측구 상단폭 **0.9m**,
config 기본값은 **3%** · **0.69m**. 표준단면은 모든 측점의 횡단 모양을 정하므로
면적·유토곡선·수량까지 그대로 흐른다.
고침은 두 줄이다 — `sections/context` 가 저장분을 **한 칸 더** 실어 보내고(기존 기본값 칸은
그대로), 브라우저가 **세션이 비었을 때만** 그 값으로 세운다. 이 시험은 그 두 성질을 지킨다.
"""
import re
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Schema import SectionContextResponse # noqa: E402
_FETCH = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts"
_ROUTER = PROJECT_ROOT / "B06_Section" / "B06_Section_Router.py"
def test_응답에_저장분_칸이_따로_있다():
"""기존 기본값 칸은 그대로 두고 한 칸만 더한 것 — 옛 화면이 안 깨진다."""
fields = SectionContextResponse.model_fields
assert "standard_cross_section" in fields, "기본값 칸이 사라짐"
assert "stored_standard_cross_section" in fields, "저장분 칸이 없음"
# 저장분이 없는 프로젝트도 있다 — 그때는 None 이어야 기본값으로 선다.
empty = SectionContextResponse(project_id="x", defaults=_defaults())
assert empty.stored_standard_cross_section is None
def _defaults():
from B06_Section.B06_Section_Schema import SectionOptionDefaults
return SectionOptionDefaults(
station_interval_m=20.0,
cross_half_width_m=20.0,
cross_sample_interval_m=0.5,
long_sample_interval_m=1.0,
vertical_exaggeration=2.0,
)
def test_라우터가_저장분을_실어_보낸다():
source = _ROUTER.read_text(encoding="utf-8")
assert "stored_standard_cross_section=stored_standard" in source, "응답에 안 실림"
assert "_stored_standard_cross_section(longitudinal_row)" in source, "저장분을 안 읽음"
def test_브라우저는_세션이_빈_경우에만_저장분으로_선다():
"""사용자가 그 탭에서 고친 값이 있으면 건드리면 안 된다 — 초안이 언제나 우선."""
source = _FETCH.read_text(encoding="utf-8")
assert "function seedStandardCross(" in source, "세우는 자리가 없음"
guard = re.search(
r"if \(stored && readState<unknown>\(\"std-cross\", projectId\) === null\)", source
)
assert guard, "세션이 있어도 덮어쓰는 모양임"
# 두 갈래(캐시·새 조회) 모두 지나야 새 탭에서도 선다.
assert source.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움"
@@ -0,0 +1,67 @@
/* 구조물 폐회로 면적 — 벽이 성토 사면을 끊으면 면적이 그만큼 줄어드는지 확인.
(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-areas-"));
execFileSync(
process.execPath,
[
"./config/node_modules/typescript/bin/tsc",
"common_util/common_util_cross_design_areas.ts",
"common_util/common_util_cross_structure_areas.ts",
"--outDir", out,
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig",
],
{ stdio: "inherit" },
);
// tsc 는 확장자 없는 상대 import 를 그대로 둔다 — node 가 읽게 `.js` 를 붙인다.
const { readFileSync, writeFileSync } = await import("node:fs");
const target = join(out, "common_util_cross_structure_areas.js");
writeFileSync(
target,
readFileSync(target, "utf8").replace(
/from "\.\/common_util_cross_design_areas"/,
'from "./common_util_cross_design_areas.js"',
),
);
const { computeStructureAreas } = await import(pathToFileURL(target).href);
// 평지 지반(표고 100). 설계선이 좌(−offset)로 갈수록 지반 아래로 파고든다 = 절토.
const ground = [];
const designLine = [];
for (let o = -20; o <= 20; o += 1) {
ground.push({ offset: o, elevation: 100 });
designLine.push({ offset_m: o, elevation_m: o >= 0 ? 100 : 100 + o * 0.5 });
}
const wide = computeStructureAreas({ designLine, ground, trim: { minOffset: -20, maxOffset: 20 } });
const trimmed = computeStructureAreas({ designLine, ground, trim: { minOffset: -5, maxOffset: 20 } });
assert.ok(wide && trimmed, "면적 계산 결과가 나와야 한다");
// 전 구간: 0.5 * 20 * 10 = 100㎡ / 벽 안쪽만: 0.5 * 5 * 2.5 = 6.25㎡
assert.ok(Math.abs(wide.cutAreaM2 - 100) < 0.01, `전 구간 절토 100㎡ — 실제 ${wide.cutAreaM2}`);
assert.ok(Math.abs(trimmed.cutAreaM2 - 6.25) < 0.01, `벽 안쪽 6.25㎡ — 실제 ${trimmed.cutAreaM2}`);
// 벽 바깥을 성토부선이 대신 그리면 그만큼 다시 잡힌다(폐회로가 넓어진다).
const withSlope = computeStructureAreas({
designLine,
ground,
trim: {
minOffset: -5,
maxOffset: 20,
minSlope: { points: [{ offset: -10, elevation: 97.5 }, { offset: -5, elevation: 97.5 }] },
},
});
assert.ok(withSlope, "성토부선이 있는 경우도 계산돼야 한다");
assert.ok(
withSlope.cutAreaM2 > trimmed.cutAreaM2,
`구조물 선이 덮은 만큼 늘어야 한다: ${withSlope.cutAreaM2} > ${trimmed.cutAreaM2}`,
);
console.log(
`구조물 폐회로 면적 확인 — 전구간 ${wide.cutAreaM2.toFixed(2)}㎡ · 벽에서 끊음 ${trimmed.cutAreaM2.toFixed(2)}㎡ · 구조물선 포함 ${withSlope.cutAreaM2.toFixed(2)}`,
);
@@ -0,0 +1,69 @@
"""구조물 종방향 구간값(길이·기준측점 전/후) 검증 — 2026-08-24 사용자 확정.
기슭막이는 길이 10m·전/후 5·5, 집수정은 길이 2m·전/후 1·1이 기본이다. 세 값이
곧 그 구조물이 덮는 종방향 범위이고, 그 범위에 걸린 옆 측점 횡단도에 같은
기슭막이가 연동으로 선다. 별도 폭 필드는 없다(PLAN.md §4-9).
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types # noqa: E402
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, _side_spec # noqa: E402
def _pipe_option_default(key: str):
for item in load_structure_types():
if item.type_id != "pipe":
continue
for option in item.options:
if option.key == key:
return option.default
raise AssertionError(f"레지스트리에 배관 옵션 {key}가 없다")
def test_registry_defines_basin_before_after():
"""집수정 길이 2m·전/후 각 1m가 레지스트리 기본값으로 있어야 한다."""
assert _pipe_option_default("inlet_basin_length_m") == 2
assert _pipe_option_default("inlet_basin_before_m") == 1
assert _pipe_option_default("inlet_basin_after_m") == 1
def test_basin_side_spec_carries_before_after():
"""유입이 집수정이면 세트 제원에 길이와 전/후가 함께 실린다(레지스트리 기본값 경유)."""
spec = _culvert_set({"inlet_type": "집수정"})["inlet"]
assert spec["structure"] == "집수정"
assert spec["basin_length_m"] == 2.0
assert spec["basin_before_m"] == 1.0
assert spec["basin_after_m"] == 1.0
# 집수정 쪽에는 기슭막이가 없다 — 벽 제원이 섞이면 링크 판정이 10m로 번진다.
assert "revet_length_m" not in spec
def test_basin_before_after_follow_saved_options():
"""저장분이 있으면 그대로 쓴다 — 비대칭(전 0.5 · 후 1.5)도 허용."""
spec = _side_spec(
{
"inlet_type": "집수정",
"inlet_basin_length_m": 2,
"inlet_basin_before_m": 0.5,
"inlet_basin_after_m": 1.5,
},
{},
"inlet",
)
assert (spec["basin_before_m"], spec["basin_after_m"]) == (0.5, 1.5)
def test_revet_span_defaults_unchanged():
"""기슭막이 기본은 그대로 길이 10m·전/후 5·5다(이번 변경이 건드리지 않는다)."""
culvert = _culvert_set(None)
for role in ("inlet", "outlet"):
spec = culvert[role]
assert spec["revet_length_m"] == 10.0
assert spec["revet_before_m"] == 5.0
assert spec["revet_after_m"] == 5.0
@@ -0,0 +1,152 @@
"""C군 벽 제원 — 파이썬·TS 짝 거울 테스트.
`B06_Section_Engine_Structures_Wall`(서버, 저장분 기준)과
`common_util/common_util_structure_walls.ts`(브라우저, 미저장 목록 기준)가 같은 입력에
같은 제원을 내는지 대조한다(CLAUDE.md 5장 — 짝을 두면 거울 테스트 필수).
TS 는 프로젝트 tsc 로 옮겨 **실제 코드 그대로** 돌린다(배수관 세트 거울 테스트와 같은 방식).
"""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
STRUCTURES = [
{
"structure_id": "abc",
"type_id": "masonry_wet",
"placement": "interval",
"chainage_m": 40.0,
"start_m": 35.0,
"end_m": 45.0,
"options": {"height_m": 2.5, "length_m": 10, "before_m": 5, "after_m": 5},
},
{
"structure_id": None,
"type_id": "retaining_wall",
"placement": "interval",
"chainage_m": 120.0,
# 뒤집힌 구간 — 두 벌 다 작은 값을 시작으로 정렬해야 한다.
"start_m": 130.0,
"end_m": 110.0,
"options": {"height_m": 3.0, "side": "", "tiers": 2},
},
{
"structure_id": "no-range",
"type_id": "soil_guard",
"placement": "interval",
"chainage_m": 200.0,
"start_m": None,
"end_m": None,
"options": {},
},
]
NAMES = {"masonry_wet": "돌쌓기(찰)", "retaining_wall": "옹벽", "soil_guard": "흙막이"}
CHAINAGES = [20.0, 40.0, 45.02, 60.0, 115.0, 200.0]
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { attachWallSpecs, wallSpecsFrom } from "./common_util_structure_walls.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const specs = wallSpecsFrom(input.structures, new Map(Object.entries(input.names)));
const sections = input.chainages.map((chainage_m) => ({ chainage_m }));
attachWallSpecs(sections, specs);
writeFileSync(process.argv[3], JSON.stringify({ specs, sections }));
"""
def _python_result() -> dict:
from B06_Section.B06_Section_Engine_Structures_Wall import _FORM_BY_TYPE, attach_wall_structures
specs = []
for item in STRUCTURES:
name = NAMES.get(item["type_id"])
start, end = item["start_m"], item["end_m"]
if not name or start is None or end is None:
continue
options = item["options"] or {}
specs.append(
{
"structure_id": item["structure_id"],
"type_id": item["type_id"],
"name": name,
"start_m": float(min(start, end)),
"end_m": float(max(start, end)),
"anchor_m": float(item["chainage_m"]),
"form": options.get("form") or _FORM_BY_TYPE.get(item["type_id"]),
"height_m": options.get("height_m"),
"side": options.get("side"),
# 기초 축(2026-09-09 신설) — 저장 칸과 같은 글자. 초안 경로에서 빠지면
# 터파기가 안 그려져 「그림이 값에 안 따라간다」가 된다.
"foundation": options.get("foundation"),
"tiers": options.get("tiers"),
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
}
)
# 얹기 규칙은 같은 함수를 쓸 수 없으므로(정본 파일을 읽는다) 여기서 같은 규칙으로 흉내낸다.
sections = [{"chainage_m": value} for value in CHAINAGES]
for section in sections:
for spec in specs:
if spec["start_m"] - 0.02 <= section["chainage_m"] <= spec["end_m"] + 0.02:
section["revetment"] = spec
break
assert callable(attach_wall_structures)
return {"specs": specs, "sections": sections}
def _ts_result(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_structure_walls.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(
json.dumps({"structures": STRUCTURES, "names": NAMES, "chainages": CHAINAGES}),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_wall_specs_mirror():
with tempfile.TemporaryDirectory() as workdir:
ts = _ts_result(Path(workdir))
py = _python_result()
assert len(ts["specs"]) == len(py["specs"]) == 2
assert ts["specs"] == py["specs"]
ts_marks = [(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"]]
py_marks = [(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in py["sections"]]
assert ts_marks == py_marks
# 구간 밖 측점에는 안 붙는다 / 경계 오차(0.02m) 안은 붙는다.
assert dict(ts_marks)[20.0] is None
assert dict(ts_marks)[45.02] == "masonry_wet"
@@ -0,0 +1,82 @@
"""사용자가 켠 「2단 절토」가 재계산에서 살아남는지.
왜 (2026-09-07 확정) — 엔진은 2단 절토를 **암 지반 + 암반 경계값이 있을 때만** 실제로
적용한다. 그런데 결과 딕셔너리에 「적용했나」(`geometry.two_stage`)를 담아 저장했고,
그 값이 다음 재계산에 **인자로 되먹여졌다**. 그래서 토사 측점에서 사용자가 켜 두면
저장되는 값이 False 로 바뀌고, 그 False 가 다시 인자가 되어 **켬이 영구히 사라졌다**.
고침은 「사용자가 켠 값을 그대로 돌려주기」다. 「실제로 적용됐나」를 읽는 곳은 코드 전체에
없고(2026-09-07 전수 확인), 2단 사면이 그려지는 것은 이 값이 아니라 **설계선 기하**
(`design_line`)가 정한다. 파이썬·TS 짝이 같은 값을 내는지는
`test_b06_cross_design_mirror.py` 가 함께 지킨다.
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
def _ground(slope: float = 0.35) -> list[dict]:
samples = []
offset = -12.0
while offset <= 12.0001:
samples.append(
{
"offset_m": round(offset, 3),
"elevation_m": round(100.0 + slope * offset, 4),
"valid": True,
}
)
offset += 0.5
return samples
def _design(ground_type: str, two_stage: bool, *, rock_offset: float | None = -1.5) -> dict:
return compute_cross_design(
_ground(),
100.4,
ground_type=ground_type,
section_mode="left_cut",
ditch_side=None,
ditch_type="standard",
paved=False,
standard=None,
rock_boundary_offset_m=rock_offset,
two_stage_slope=two_stage,
ditch_enabled=None,
)
def test_토사_측점에서도_켠_값이_남는다():
"""엔진이 못 쓰는 자리 — 예전에는 여기서 False 가 저장돼 켬이 사라졌다."""
result = _design("soil", True)
assert result["two_stage_slope"] is True, "토사 측점에서 사용자의 「켬」이 지워짐"
def test_다시_계산해도_켬이_유지된다():
"""되먹임 왕복 — 저장값을 그대로 인자로 넣어 두 번 돌려도 값이 안 눌린다."""
once = _design("soil", True)
twice = _design("soil", bool(once["two_stage_slope"]))
assert twice["two_stage_slope"] is True
def test_끈_값도_그대로_남는다():
"""반대 방향 — 사용자가 껐으면 암 지반이라도 켜지지 않는다."""
result = _design("ripping_rock", False)
assert result["two_stage_slope"] is False
def test_2단_사면은_이_값이_아니라_설계선이_그린다():
"""이 키가 「적용 결과」를 안 담아도 그림이 안 바뀌는 근거 — 기하가 갈린다."""
applied = _design("ripping_rock", True)
not_applied = _design("ripping_rock", False)
assert applied["design_line"] != not_applied["design_line"], (
"2단 적용 여부가 설계선에 안 나타남 — 그러면 이 키가 유일한 근거가 된다"
)
# 토사는 어느 쪽으로 켜도 기하가 같다(엔진이 애초에 안 씀) — 그래서 값만 남기면 된다.
assert _design("soil", True)["design_line"] == _design("soil", False)["design_line"]
@@ -0,0 +1,57 @@
"""사용자 조작값 목록이 서버·브라우저에서 갈라지지 않는지 지킨다.
왜 (2026-09-07 발견) — 같은 목록이 세 곳에 흩어져 있었고 서로 달랐다. 브라우저는
`extra_spans`(다단 구간값)를 살렸는데 서버 두 경로는 안 살려, 그 측점이 다시 계산되면
사용자가 넣은 단별 구간값이 조용히 사라졌다. 새 조작값을 만들 때 한쪽만 고치는 것을 막는다.
"""
import re
from pathlib import Path
from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS
ROOT = Path(__file__).resolve().parents[2]
TS_PATH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts"
# 브라우저 목록에만 있고 서버 목록에는 없는 둘 — 부르는 쪽이 따로 붙인다.
_CALLER_ADDED = {"status", "pavement_suggested"}
def _browser_keys() -> set[str]:
"""브라우저 쪽 사용자 값 목록 — 2026-09-07 부터 `USER_TOUCHED_KEYS` 한 벌이 정본이고
`PRESERVED_KEYS` 는 거기에 상태 둘을 더해 만든다."""
text = TS_PATH.read_text(encoding="utf-8")
block = re.search(r"export const USER_TOUCHED_KEYS = \[(.*?)\] as const;", text, re.S)
assert block, "USER_TOUCHED_KEYS 를 못 찾음"
return set(re.findall(r'"([a-z_]+)"', block.group(1)))
def test_다단_구간값이_서버_목록에_있다():
assert "extra_spans" in USER_TOUCHED_KEYS
def test_서버와_브라우저_목록이_같다():
assert _browser_keys() - _CALLER_ADDED == set(USER_TOUCHED_KEYS)
def test_브라우저_목록도_한_벌이다():
"""보존 목록·저장 payload 가 같은 한 벌을 쓰는지 — 손나열이 되살아나면 깨진다."""
text = TS_PATH.read_text(encoding="utf-8")
assert "...USER_TOUCHED_KEYS" in text, "PRESERVED_KEYS 가 목록을 다시 적고 있음"
store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8")
assert "USER_TOUCHED_KEYS" in store, "저장 payload 가 목록을 안 씀"
assert store.count('"extra_spans"') == 0, "저장 payload 에 손나열이 남아 있음"
def test_확정_payload_도_손나열이_없다():
"""서버 확정 경로 — 스키마를 통째로 덤프하므로 새 필드가 저절로 실린다."""
confirm = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text(encoding="utf-8")
assert "patch_item.model_dump()" in confirm
assert confirm.count('patch["extra_spans"]') == 0, "확정 경로에 손나열이 남아 있음"
def test_서버_목록이_한_벌이다():
"""라우터가 목록을 다시 적지 않고 그 한 벌을 쓴다."""
router = (ROOT / "B06_Section" / "B06_Section_Router.py").read_text(encoding="utf-8")
assert "USER_TOUCHED_KEYS" in router
assert router.count('"revet_follow_grade"') == 0, "라우터에 목록 사본이 남아 있음"
+320
View File
@@ -0,0 +1,320 @@
"""B07 토적도·유역도 엔진 — 좌표 환산과 표기가 척도대로 나오는지 확인한다."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import (
MM_V,
auto_scale_h,
build_mass_haul_drawing,
)
def _texts(drawing):
"""도면 안 모든 글자(자식 포함) — Text 라벨과 Table 칸 글자를 함께 훑는다.
정보표는 선·문자를 따로 두지 않고 Table 엔티티 하나로 나간다(DXF의 표로 내보내기
위함) — 칸 글자는 `shapeData.cells` 안에 있어 Text만 훑으면 통째로 안 보인다.
"""
out = []
def walk(entity):
if entity.get("type") == "Text":
out.append(entity["shapeData"]["label"])
if entity.get("type") == "Table":
for row in entity["shapeData"].get("cells") or []:
out.extend(cell["text"] for cell in row if cell and cell.get("text") is not None)
for child in entity.get("children") or []:
walk(child)
for entity in drawing["entities"]:
walk(entity)
return out
def _lines(drawing, layer_id):
"""레이어의 Line 좌표쌍 목록(PolyLine 자식 포함)."""
out = []
def walk(entity):
if entity.get("type") == "Line" and entity.get("layerId") == layer_id:
shape = entity["shapeData"]
out.append(
(
(shape["startPoint"]["x"], shape["startPoint"]["y"]),
(shape["endPoint"]["x"], shape["endPoint"]["y"]),
)
)
for child in entity.get("children") or []:
walk(child)
for entity in drawing["entities"]:
walk(entity)
return out
LONGITUDINAL = {
"stations": [
{"chainage_m": 0.0, "station_id": "s0"},
{"chainage_m": 20.0, "station_id": "s1"},
{"chainage_m": 40.0, "station_id": "s2"},
]
}
MASS_HAUL = {
"basis": "compacted",
"points": [
{"station_id": "s0", "chainage_m": 0.0, "cumulative_volume_m3": 0.0},
{"station_id": "s1", "chainage_m": 20.0, "cumulative_volume_m3": 1000.0},
{"station_id": "s2", "chainage_m": 40.0, "cumulative_volume_m3": 400.0},
],
"haul_plan": {
"blocks": [
{
"index": 1,
"from_m": 0.0,
"to_m": 40.0,
"base_m3": 0.0,
"volume_m3": 1000.0,
"direction": "forward",
"bands": [
{
"index": 1,
"equipment": "free_haul",
"volume_m3": 600.0,
"haul_distance_m": 18.5,
"ea_m3": 400.0,
"rr_m3": 150.0,
"br_m3": 50.0,
"level_base_m3": 0.0,
"level_apex_m3": 600.0,
"boundary_from_m": 0.0,
"boundary_to_m": 40.0,
"haul_from_m": 6.0,
"haul_to_m": 34.0,
}
],
}
],
"residuals": [
{
"index": 1,
"kind": "spoil",
"from_m": 30.0,
"to_m": 40.0,
"volume_m3": 120.0,
"natural_m3": 40.0,
"level_from_m3": 400.0,
"level_to_m3": 280.0,
"ea_m3": 80.0,
"rr_m3": 30.0,
"br_m3": 10.0,
}
],
"transfers": [],
},
}
def test_mass_haul_scale_follows_route_length():
"""가로 축척은 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림(2026-09-03 사용자 결정)."""
assert auto_scale_h(40.0) == 500 # 40m -> 80mm
assert auto_scale_h(1106.0) == 2000 # 용화 1,106m -> 553mm (1:1,500이면 737mm로 넘침)
assert auto_scale_h(3465.0) == 5000
assert MM_V == 0.02 # 세로는 고정(종이 1mm = 50㎥)
def test_mass_haul_curve_uses_paper_scale():
"""유토곡선 점은 x=거리xmm_h, y=토량/50mm로 옮겨진다. 40m 노선이면 H 1/500 -> 2.0mm/m."""
mm_h = 1000.0 / auto_scale_h(40.0)
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
curve = _lines(drawing, "b08-masshaul-curve")
assert curve[0][0] == (0.0, 0.0)
assert curve[0][1] == (20.0 * mm_h, 1000.0 * 0.02)
assert curve[1][1] == (40.0 * mm_h, 400.0 * 0.02)
def test_mass_haul_balloon_texts():
"""balloon 표기를 납품 도면에 맞춘다 — 종무대·등호 뒤 한 칸·M.N 소수 2자리
(2026-09-03 사용자 확정 8행)."""
labels = _texts(build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul"))
assert "종무대" in labels # 장비명 뒤 숫자는 정체 미확인이라 적지 않는다
assert "Q= 600.00M3" in labels
assert "L= 18.50M" in labels
assert "EA= 400.00M3" in labels
assert "사토 1" in labels
assert "M.N= 1+10.00" in labels # 측점 간격 20m 기준 30m 지점
assert "유 토 곡 선" in labels
assert "SCALE H=1:500 V=1:50,000" in labels # 40m 노선의 자동 축척
def test_mass_haul_table_marks_match_delivery_form():
"""측점은 `n+ 0.0`, 행 이름은 자간을 벌린 큰 글씨, 눈금은 정규 빨강·추가 회색."""
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
labels = _texts(drawing)
assert "1+ 0.0" in labels and "2+ 0.0" in labels
assert not any(text.startswith("No.") for text in labels)
assert "누 가 토 량" in labels and "측 점" in labels
# 눈금은 표 레이어의 짧은 세로선 — 정규 측점은 빨강(#ff4d4d).
ticks = [
entity
for entity in drawing["entities"]
if entity.get("layerId") == "b08-masshaul-table"
and entity.get("type") == "Line"
and entity.get("lineColor") == "#ff4d4d"
]
assert ticks, "표 눈금이 없다"
for tick in ticks:
shape = tick["shapeData"]
assert abs(shape["startPoint"]["x"] - shape["endPoint"]["x"]) < 1e-9 # 세로선
assert abs(shape["startPoint"]["y"] - shape["endPoint"]["y"]) == 2.0
def test_mass_haul_leader_is_stepped_from_corner():
"""지시선은 balloon 모서리에서 나가는 계단형(가로 → 세로) 폴리선이다."""
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
# 지시선은 띠 레이어의 자식 2개짜리 PolyLine(가로 → 대각) 이다.
leaders = [
entity
for entity in drawing["entities"]
if entity.get("type") == "PolyLine"
and entity.get("layerId") == "b08-masshaul-band"
and len(entity.get("children") or []) == 2
]
assert leaders, "지시선이 없다"
for leader in leaders:
first = leader["children"][0]["shapeData"]
assert abs(first["startPoint"]["y"] - first["endPoint"]["y"]) < 1e-9 # 첫 구간은 수평
def test_mass_haul_band_chords_sit_at_stored_levels():
"""경계현은 level_base, 운반현은 띠 중간 높이에 저장값 그대로 놓인다."""
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
chords = _lines(drawing, "b08-masshaul-band")
mm_h = 1000.0 / auto_scale_h(40.0)
assert ((0.0, 0.0), (40.0 * mm_h, 0.0)) in chords # 경계현 level_base=0
assert ((6.0 * mm_h, 300.0 * 0.02), (34.0 * mm_h, 300.0 * 0.02)) in chords # 중간 300㎥
BASINS = [
{
"ring": [(100.0, 100.0), (400.0, 100.0), (400.0, 400.0), (100.0, 400.0)],
"props": {
"kind": "detail_basin",
"index": 1,
"chainage_m": 120.0,
"area_m2": 15000.0,
"relief_m": 165.0,
"flow_length_m": 210.0,
"pipe_diameter_mm": 800,
"bridge_required": False,
},
}
]
def test_watershed_info_box_matches_delivery_form():
"""정보표는 유역면적 ha 환산·유역표고·유하거리·배수규격을 납품 표기로 적는다."""
drawing = build_watershed_drawing(
"watershed",
route_xy=[(0.0, 0.0), (500.0, 0.0)],
basins=BASINS,
contours=[[(0.0, 50.0), (500.0, 60.0)]],
streams=[[(200.0, 0.0), (200.0, 400.0)]],
interval_m=20.0,
)
labels = _texts(drawing)
# 측점 표기는 토적도와 같은 `n+ 0.0`(2026-09-03 사용자 확정 — 유역도까지 통일).
assert "(1) 6+ 0.0" in labels
assert "배수규격 Φ800mm" in labels
assert "1.50 ha" in labels # 15,000㎡
assert "165.0 m" in labels
assert "210.0 m" in labels
assert "수 리 집 수 면 적 유 역 도" in labels
assert "S = 1/6,000" in labels
def test_watershed_route_uses_plan_scale():
"""평면 좌표는 콘텐츠 최소점을 원점으로 1/6,000 (1m = 1/6mm)로 옮겨진다."""
drawing = build_watershed_drawing(
"watershed",
route_xy=[(0.0, 0.0), (600.0, 0.0)],
basins=[],
contours=[],
streams=[],
)
route = _lines(drawing, "b08-basin-route")
assert route[0][0] == (0.0, 0.0)
assert abs(route[0][1][0] - 100.0) < 1e-9 # 600m / 6 = 100mm
def test_clip_line_to_box_splits_outside_runs():
"""도엽 등고선처럼 긴 선은 범위 안 구간만 남고, 끝점은 경계 위에 정확히 놓인다.
2026-08-31 이전에는 경계 바깥 점을 하나씩 물고 나와 외곽이 들쭉날쭉했다.
지금은 교차점을 계산해 자른다(Liang-Barsky).
"""
from B07_DesignDetail.B07_DesignDetail_Router_Support import clip_line_to_box
box = (0.0, 0.0, 10.0, 10.0)
line = [(-5.0, 5.0), (2.0, 5.0), (5.0, 5.0), (20.0, 5.0), (30.0, 5.0), (8.0, 5.0)]
runs = clip_line_to_box(line, box)
assert runs == [
[(0.0, 5.0), (2.0, 5.0), (5.0, 5.0), (10.0, 5.0)],
[(10.0, 5.0), (8.0, 5.0)],
]
assert clip_line_to_box([(50.0, 50.0), (60.0, 60.0)], box) == []
# 남은 점이 상자를 벗어나지 않는다
for run in runs:
for x, y in run:
assert 0.0 - 1e-9 <= x <= 10.0 + 1e-9
assert 0.0 - 1e-9 <= y <= 10.0 + 1e-9
def test_watershed_diameter_label_snaps_to_standard_size():
"""소요 관경(Φ929)만 있는 옛 저장본도 도면에는 규격관(Φ1000)으로 적는다."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _diameter_label
assert _diameter_label({"pipe_diameter_mm": 929.4}) == "Φ1000mm"
assert (
_diameter_label({"pipe_diameter_mm": 929.4, "recommended_diameter_mm": 1200}) == "Φ1200mm"
)
assert _diameter_label({"pipe_diameter_mm": 2000}) == "Φ1500mm"
assert _diameter_label({"bridge_required": True, "pipe_diameter_mm": 1600}) == "물넘이포장"
def test_watershed_number_badge_is_circle_with_disc():
"""유역 번호는 흰 원판(solid 해치) + 원 테두리 + 숫자로 그려 해칭 위에서도 읽힌다."""
drawing = build_watershed_drawing(
"watershed", route_xy=[(0.0, 0.0), (500.0, 0.0)], basins=BASINS, contours=[], streams=[]
)
# 방위표 템플릿에도 원이 있으므로 유역 레이어만 센다.
circles = [
e
for e in drawing["entities"]
if e.get("type") == "Circle" and e.get("layerId") == "b08-basin-area"
]
assert len(circles) == 1
assert circles[0]["shapeData"]["radius"] == 3.6
disc = [
e
for e in drawing["entities"]
if e.get("type") == "Hatch" and e["shapeData"]["options"]["style"] == "solid"
]
assert len(disc) == 1
assert "1" in _texts(drawing)
def test_watershed_basin_has_hatch():
"""유역마다 격자 해칭(Hatch 엔티티)이 경계선과 함께 들어간다."""
drawing = build_watershed_drawing(
"watershed", route_xy=[(0.0, 0.0), (500.0, 0.0)], basins=BASINS, contours=[], streams=[]
)
hatches = [
e
for e in drawing["entities"]
if e.get("type") == "Hatch" and e["shapeData"]["options"]["style"] == "cross"
]
assert len(hatches) == 1
shape = hatches[0]["shapeData"]
assert shape["options"]["style"] == "cross"
assert len(shape["points"]) == 4
assert hatches[0]["layerId"] == "b08-basin-area"
@@ -0,0 +1,73 @@
"""표제란 SVG 서명을 CAD 폴리선으로 바꾸는 경로 검사 (2026-09-03 사용자 결정).
종전에는 `ImageEntity`(data URL)로 들어가 DXF 로 나가면 래스터가 됐다. SVG 만 벡터로
바꾸고 PNG·JPG 는 그대로 그림으로 둔다.
"""
from base64 import b64encode
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
from B07_DesignDetail.B07_DesignDetail_Engine_Template import _vectorize_svg_image
SIGN_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">'
'<path d="M 10 40 L 30 10 L 50 40"/>'
'<polyline points="60,10 90,10 90,40"/>'
"</svg>"
)
def _image_entity(payload: str, mime: str = "image/svg+xml") -> dict:
return {
"id": "sign-1",
"type": "Image",
"lineColor": "#101010",
"lineWidth": 1,
"layerId": "title",
"shapeData": {
"points": [
{"x": 10.0, "y": 20.0},
{"x": 50.0, "y": 20.0},
{"x": 50.0, "y": 40.0},
{"x": 10.0, "y": 40.0},
],
"imageData": f"data:{mime};base64,{b64encode(payload.encode()).decode()}",
},
}
def test_svg_polylines_reads_path_and_polyline():
view_box, runs = svg_polylines(SIGN_SVG)
assert view_box == (0.0, 0.0, 100.0, 50.0)
assert len(runs) == 2
assert runs[0][0] == (10.0, 40.0) and runs[0][-1] == (50.0, 40.0)
def test_fit_keeps_aspect_and_flips_y():
"""비율을 지키고 세로를 뒤집는다 — SVG 는 y 가 아래로, 도면은 위로 자란다."""
view_box, runs = svg_polylines(SIGN_SVG)
fitted = fit_polylines(runs, view_box, (10.0, 20.0, 50.0, 40.0))
xs = [x for run in fitted for x, _y in run]
ys = [y for run in fitted for _x, y in run]
assert 10.0 - 1e-9 <= min(xs) and max(xs) <= 50.0 + 1e-9
assert 20.0 - 1e-9 <= min(ys) and max(ys) <= 40.0 + 1e-9
# SVG 에서 y=10(위)이던 꼭짓점이 도면에서는 가장 높은 y 가 된다.
assert fitted[0][1][1] > fitted[0][0][1]
def test_image_entity_becomes_polylines():
result = _vectorize_svg_image(_image_entity(SIGN_SVG))
assert all(entity["type"] == "PolyLine" for entity in result)
assert len(result) == 2
assert all(entity["layerId"] == "title" for entity in result)
assert result[0]["children"], "폴리선에 선분이 없다"
def test_raster_image_is_left_alone():
raster = _image_entity("not-svg", mime="image/png")
assert _vectorize_svg_image(raster) == [raster]
def test_broken_svg_keeps_image():
broken = _image_entity("<svg><path d=")
assert _vectorize_svg_image(broken) == [broken]
@@ -0,0 +1,88 @@
"""사토장이 서면 사토 운반거리가 **측점에서 나온다** (2026-09-09 사용자 확정 ③).
「기존 구조물 측점이나 규칙 측점에만 들어가는 게 맞아」 ⇒ 사토장 자리가 측점이라
거리는 **발생점 → 사토장 측점** 누가거리로 그냥 나온다. 가정할 것이 없다.
⚠ 사토장이 없으면 설계 입력값으로 되돌아간다 — **임의 거리를 넣지 않는다**.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B08_Quantity.B08_Quantity_Router_Earthwork import ( # noqa: E402
_spoil_of,
_spoil_sites,
)
_PLAN = {
"spoil_m3": 300.0,
"natural_spoil_m3": 0.0,
"residuals": [
{"kind": "spoil", "from_m": 0.0, "to_m": 40.0, "volume_m3": 100.0, "natural_m3": 0.0},
{"kind": "spoil", "from_m": 200.0, "to_m": 240.0, "volume_m3": 200.0, "natural_m3": 0.0},
],
}
def _designs(*, extra: float | None = None) -> list[dict]:
rows = []
for chainage in (100.0, 120.0, 140.0):
rows.append(
{
"chainage_m": chainage,
"design": {
"spoil_fill_area_m2": 12.0,
"spoil_fill_structure_id": "sp-1",
"spoil_fill_capacity_m3": 500.0,
"spoil_fill_placed_m3": 480.0,
"spoil_fill_unplaced_m3": 20.0,
"spoil_fill_extra_distance_m": extra,
},
}
)
return rows
def test_사토장_구간을_한_덩어리로_접는다() -> None:
sites = _spoil_sites(_designs())
assert len(sites) == 1
assert sites[0]["from_m"] == 100.0
assert sites[0]["to_m"] == 140.0
assert sites[0]["center_m"] == 120.0
assert sites[0]["placed_m3"] == 480.0
def test_거리는_발생점에서_사토장_측점까지다() -> None:
spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs()))
# 발생점 20m(100㎥) → 100m · 발생점 220m(200㎥) → 100m ⇒ 가중평균 100m
assert spoil["distance_m"] == pytest.approx(100.0)
assert spoil["distance_basis"] == "사토장(측점) 기준"
assert "사토장 측점" in spoil["note"]
def test_추가_운반거리는_항상_더한다() -> None:
spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs(extra=50.0)))
assert spoil["distance_m"] == pytest.approx(150.0)
def test_못_담는_몫을_사유로_드러낸다() -> None:
spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs()))
assert "못 담는" in spoil["note"]
assert spoil["sites"][0]["unplaced_m3"] == 20.0
def test_사토장이_없으면_설계_입력값으로_돌아간다() -> None:
"""임의 거리를 넣지 않는다 — 값이 없으면 막히는 것이 맞다."""
spoil = _spoil_of(_PLAN, {"spoil_site_distance_m": 900.0}, [])
assert spoil["distance_m"] == 900.0
assert spoil["distance_basis"] == "설계 입력값"
blocked = _spoil_of(_PLAN, {}, [])
assert blocked["distance_m"] is None
+137
View File
@@ -0,0 +1,137 @@
"""소단 + 다중 무릎이 **끝나는지** — 화면이 멈추던 자리(2026-09-07 실사고).
무슨 일이 있었나 — 사용자 공용 브라우저에서 **소단을 한 건 놓자 화면이 25분 넘게 멈췄다**
(렌더러가 1.4코어를 계속 태움). 서버(파이썬)로 같은 측점을 돌리면 0.01초에 끝나 서버는 멀쩡했다.
원인 — `cut_profile_points` 의 무릎 가지에서 **제자리 뒤집기**. 암 경계선의 기울기가
**암 경사와 토사 경사 사이**면
· 토사로 바꾸면 다음 걸음이 경계 **아래** → 「다시 암」
· 암으로 바꾸면 다음 걸음이 경계 **위** → 「다시 토사」
가 되고, 이때 보간 비율 `share` 가 0 이라 `knee_dist == dist` — **한 걸음도 안 나간다.**
`while dist < limit` 이 끝나지 않는다. `multi_knee` 는 **소단이 있으면 항상 참**이라
소단이 곧 지뢰였다.
고침 — 무릎은 **앞으로 나아갈 때만** 인정한다(직전 무릎과 같은 자리면 뒤집지 않고 한 걸음 간다).
파이썬·TS 짝 둘 다 같은 모양으로 넣었다.
"""
import json
import subprocess
import sys
import threading
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from common_util.common_util_cross_berm import BermSpec, cut_profile_points # noqa: E402
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
# 경계선 기울기를 두 설계 경사 **사이**에 둔다 — 암 1:0.4 는 수평 1m 에 2.5m 오르고
# 토사 1:1.0 은 1.0m 오른다. 그 사이(1.5m)면 어느 쪽으로 바꿔도 경계를 다시 넘는다.
BOUNDARY_RISE = 1.5
CUT_RATIO, SOIL_CUT_RATIO = 0.4, 1.0
BERM = BermSpec(0.5, 3.0, 0.0)
MAX_REACH_M = 50.0
def _boundary(dist: float) -> float:
return 100.0 + dist * BOUNDARY_RISE
def _run_with_timeout(seconds: float):
"""별도 스레드로 돌려 **끝나는지**를 본다 — 안 끝나면 그 자체가 결함이다."""
box: dict[str, object] = {}
def work() -> None:
box["points"] = cut_profile_points(
0.0, 100.0, CUT_RATIO, SOIL_CUT_RATIO, _boundary, BERM, MAX_REACH_M, True
)
thread = threading.Thread(target=work, daemon=True)
thread.start()
thread.join(seconds)
return None if thread.is_alive() else box.get("points")
def test_경계_기울기가_두_경사_사이여도_끝난다():
points = _run_with_timeout(8.0)
assert points is not None, "8초 안에 안 끝남 — 제자리 무릎이 되풀이되고 있다(화면이 멈춘다)"
assert len(points) > 2
def test_꼭짓점이_뒤로_가지_않는다():
"""무릎을 찍든 소단을 놓든 거리는 **늘 앞으로**만 간다."""
points = _run_with_timeout(8.0)
assert points is not None
for before, after in zip(points, points[1:], strict=False):
assert after[0] >= before[0] - 1e-9, f"거리가 뒤로 감: {before}{after}"
def test_소단이_없으면_종전_그대로():
"""이 고침이 소단 없는 경로(무릎 한 번)를 건드리지 않았는지."""
single = cut_profile_points(
0.0, 100.0, CUT_RATIO, SOIL_CUT_RATIO, _boundary, None, MAX_REACH_M, False
)
assert single[0] == (0.0, 100.0)
assert single[-1][0] > single[0][0]
def test_두_파일이_같은_방식으로_막는다():
"""짝이 갈리면 화면(TS)만 멈추고 서버(파이썬)는 멀쩡한 오늘 같은 일이 또 난다."""
py = (PROJECT_ROOT / "common_util" / "common_util_cross_berm.py").read_text(encoding="utf-8")
ts = (PROJECT_ROOT / "common_util" / "common_util_cross_berm.ts").read_text(encoding="utf-8")
assert "last_knee_dist" in py and "knee_dist > last_knee_dist" in py
assert "lastKneeDist" in ts and "kneeDist > lastKneeDist" in ts
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_TS_짝도_끝난다(tmp_path: Path):
"""실제로 멈춘 것은 **화면(TS)** 이었다 — 그쪽도 끝나는지 직접 돌려 본다."""
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_cross_berm.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
runner = out / "runner.mjs"
runner.write_text(
"""
import { cutProfilePoints } from "./common_util_cross_berm.js";
const boundary = (d) => 100 + d * %s;
const points = cutProfilePoints(0, 100, %s, %s, boundary, { widthM: 0.5, intervalM: 3, slopeDeg: 0 }, %s, true);
console.log(JSON.stringify({ n: points.length, last: points[points.length - 1] }));
"""
% (BOUNDARY_RISE, CUT_RATIO, SOIL_CUT_RATIO, MAX_REACH_M),
encoding="utf-8",
)
done = subprocess.run( # noqa: S603
["node", str(runner)], cwd=str(PROJECT_ROOT), capture_output=True, timeout=20, text=True
)
assert done.returncode == 0, done.stderr[:400]
ts = json.loads(done.stdout.strip().splitlines()[-1])
# 짝 대조 — 같은 기하에서 **꼭짓점 수와 끝점이 같아야** 한다. 한쪽만 고치면 여기서 걸린다.
py_points = _run_with_timeout(8.0)
assert py_points is not None
assert ts["n"] == len(py_points), f"꼭짓점 수가 갈림 — TS {ts['n']} vs 파이썬 {len(py_points)}"
assert abs(ts["last"][0] - py_points[-1][0]) < 1e-6
assert abs(ts["last"][1] - py_points[-1][1]) < 1e-6
@@ -0,0 +1,68 @@
// 수확기 rect 변환(B07_DesignDetail_UI_Cad_Structures.rectPoints) 자체검증 — 로직 복제.
// 돌쌓기 돌·돌망태 칸은 rect + rotate(벽기울기) 로 그린다. 회전을 빠뜨리면 돌이 벽을
// 삐져나가 클립에 전부 잘려 나간다(2026-09-03 실측: rect 49개가 CAD 에 안 실렸다).
// 실행: node tmp/tests/test_cad_rect_harvest.mjs
import assert from "node:assert/strict";
const flip = (x, y) => [x, -y];
function rectPoints(attrs, transform) {
const { x, y, width, height } = attrs;
const corners = [
[x, y],
[x + width, y],
[x + width, y + height],
[x, y + height],
[x, y],
];
const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec(transform ?? "");
if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy));
const angle = (Number(rotate[1]) * Math.PI) / 180;
const [ox, oy] = [Number(rotate[2]), Number(rotate[3])];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return corners.map(([cx, cy]) => {
const dx = cx - ox;
const dy = cy - oy;
return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos);
});
}
const near = (a, b) => Math.abs(a - b) < 1e-9;
// ① 회전이 없으면 네 모서리를 y만 뒤집어 닫힌 점열로 낸다.
{
const points = rectPoints({ x: 10, y: 20, width: 4, height: 6 }, null);
assert.equal(points.length, 5);
assert.deepEqual(points[0], [10, -20]);
assert.deepEqual(points[2], [14, -26]);
assert.deepEqual(points[0], points[4], "닫힌 점열이어야 한다");
}
// ② rotate(90 cx cy) — 중심 기준 90° 회전. 폭·높이가 맞바뀐다.
{
const points = rectPoints({ x: -1, y: -2, width: 2, height: 4 }, "rotate(90 0 0)");
const xs = points.map((p) => p[0]);
const ys = points.map((p) => p[1]);
assert.ok(near(Math.max(...xs) - Math.min(...xs), 4), "가로가 원래 세로가 된다");
assert.ok(near(Math.max(...ys) - Math.min(...ys), 2), "세로가 원래 가로가 된다");
}
// ③ 회전 중심은 제자리에 남는다 — 벽 기울기만큼 기울여도 돌 중심이 안 움직인다.
{
const attrs = { x: 8, y: 12, width: 4, height: 2 };
const center = [attrs.x + attrs.width / 2, attrs.y + attrs.height / 2];
const points = rectPoints(attrs, `rotate(-16.7 ${center[0]} ${center[1]})`);
const xs = points.map((p) => p[0]);
const ys = points.map((p) => p[1]);
assert.ok(near((Math.min(...xs) + Math.max(...xs)) / 2, center[0]));
assert.ok(near((Math.min(...ys) + Math.max(...ys)) / 2, -center[1]));
}
// ④ 소수·쉼표 구분자도 읽는다 — SVG가 `rotate(-16.7,120.5,88.25)` 로 쓰기도 한다.
{
const points = rectPoints({ x: 0, y: 0, width: 2, height: 2 }, "rotate(-16.7,1,1)");
assert.ok(!near(points[0][0], 0), "회전이 실제로 적용돼야 한다");
}
console.log("OK test_cad_rect_harvest.mjs — 4건");
+43
View File
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""등고선·세류선 절취(clip_line_to_box)가 경계에서 정확히 끊기는지 확인한다."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from B07_DesignDetail.B07_DesignDetail_Router_Support import clip_line_to_box
BOX = (0.0, 0.0, 10.0, 10.0)
def test_경계를_넘는_선은_경계_위에서_끊긴다():
runs = clip_line_to_box([(-5.0, 5.0), (15.0, 5.0)], BOX)
assert runs == [[(0.0, 5.0), (10.0, 5.0)]]
def test_상자를_두_번_드나들면_조각이_둘이다():
line = [(-1.0, 5.0), (5.0, 5.0), (5.0, 20.0), (8.0, 20.0), (8.0, 5.0), (20.0, 5.0)]
runs = clip_line_to_box(line, BOX)
assert len(runs) == 2
assert runs[0][0] == (0.0, 5.0)
assert runs[0][-1] == (5.0, 10.0)
assert runs[1][0] == (8.0, 10.0)
assert runs[1][-1] == (10.0, 5.0)
def test_안에_전부_들면_그대로_남는다():
line = [(1.0, 1.0), (2.0, 3.0), (4.0, 2.0)]
assert clip_line_to_box(line, BOX) == [line]
def test_바깥에만_있으면_아무것도_안_남는다():
assert clip_line_to_box([(-5.0, -5.0), (-1.0, -3.0)], BOX) == []
def test_끝점이_경계를_넘지_않는다():
line = [(-3.0, 2.0), (12.0, 12.0), (5.0, -4.0)]
for run in clip_line_to_box(line, BOX):
for x, y in run:
assert -1e-9 <= x <= 10.0 + 1e-9
assert -1e-9 <= y <= 10.0 + 1e-9
+179
View File
@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
"""PRJ 좌표계 판별 검증 — common_util_crs + 수리된 get_epsg_from_prj.
실행: ./venv/Scripts/python.exe -m pytest tmp/tests/test_common_util_crs.py -q
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from pyproj import CRS, Transformer
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_prj_metadata # noqa: E402
from B04_PreProcess.B04_PreProcess_Engine_VWorld import get_epsg_from_prj # noqa: E402
from common_util.common_util_crs import ( # noqa: E402
crs_input_from_prj,
identify_epsg,
)
UPLOADS = Path(r"C:/Users/umsan/.claude/uploads/ab448e1d-3fa5-4f88-8e02-b26428c8dfc7")
LAS_PRJ = UPLOADS / "a450e281-__.prj" # Korean 1985 Modified East Belt → 5176
ROUTE_PRJ = UPLOADS / "105c7734-______.__.2.2.prj" # UTM-K, AUTHORITY 없음 → 5179
EXISTING_COMPD_PRJ = (
ROOT / "storage/1/3/2f940d8a-2065-4cf6-8bf8-dc3f0af84e57/B03_FileInput/input/prj/result.prj"
)
needs_uploads = pytest.mark.skipif(not LAS_PRJ.exists(), reason="업로드 PRJ 없음")
needs_storage = pytest.mark.skipif(not EXISTING_COMPD_PRJ.exists(), reason="저장소 PRJ 없음")
# ── 사다리 라벨 판별 ──────────────────────────────────────────────────────────
@needs_uploads
def test_uploaded_las_prj_labels_5176():
text = LAS_PRJ.read_text(encoding="utf-8")
assert identify_epsg(CRS.from_wkt(text), text) == 5176
@needs_uploads
def test_uploaded_route_prj_labels_5179():
text = ROUTE_PRJ.read_text(encoding="utf-8")
# AUTHORITY 태그가 아예 없다 — 파라미터 지문(③)으로만 잡힌다.
assert 'AUTHORITY["EPSG"' not in text
assert identify_epsg(CRS.from_wkt(text), text) == 5179
def test_standard_codes_round_trip():
for code in (5185, 5186, 5187, 5188, 5179, 5174, 5176, 32652, 4326):
wkt = CRS.from_epsg(code).to_wkt("WKT1_GDAL")
assert identify_epsg(CRS.from_wkt(wkt), wkt) == code, code
@needs_storage
def test_compound_kngeoid_prj_labels_5187():
text = EXISTING_COMPD_PRJ.read_text(encoding="utf-8")
# KNGeoid24 결합(COMPD_CS) — 수평 성분으로 판별한다. 사설 수직 코드는
# B03 _prepare_prj_wkt가 떼므로 여기서는 pyproj가 그대로 읽는지만 대비한다.
from B03_FileInput.B03_FileInput_Engine_Analyze import _prepare_prj_wkt
parse_text, _ = _prepare_prj_wkt(text)
assert identify_epsg(CRS.from_wkt(parse_text), parse_text) == 5187
# ── 회귀: 죽은 문자열 분기 ────────────────────────────────────────────────────
def test_central_belt_no_longer_misread_as_east():
"""예전 코드는 false_easting의 EAST 때문에 모든 PRJ를 5187로 판정했다."""
central = CRS.from_epsg(5186).to_wkt("WKT1_GDAL")
assert get_epsg_from_prj(central) == "EPSG:5186"
west = CRS.from_epsg(5185).to_wkt("WKT1_GDAL")
assert get_epsg_from_prj(west) == "EPSG:5185"
utm = CRS.from_epsg(32652).to_wkt("WKT1_GDAL")
assert get_epsg_from_prj(utm) == "EPSG:32652"
def test_empty_prj_keeps_legacy_default():
assert get_epsg_from_prj("") == "EPSG:5186"
assert get_epsg_from_prj("이것은 WKT가 아니다") == "EPSG:5186"
# ── 변환 정본 = WKT (TOWGS84 보존) ───────────────────────────────────────────
@needs_uploads
def test_las_prj_returns_wkt_and_transforms_into_korea():
text = LAS_PRJ.read_text(encoding="utf-8")
crs_input = get_epsg_from_prj(text)
# 비표준 TOWGS84라 pyproj DB 확정이 안 된다 → 원문 WKT 그대로 반환해야 한다.
assert crs_input.lstrip().startswith("PROJCS"), crs_input[:40]
lon, lat = Transformer.from_crs(crs_input, "EPSG:4326", always_xy=True).transform(
209014.2, 367805.3
)
assert 128.5 < lon < 129.7 and 36.3 < lat < 37.3, (lon, lat) # 울진 일대
@needs_uploads
def test_route_prj_transform_matches_utmk():
text = ROUTE_PRJ.read_text(encoding="utf-8")
crs_input = get_epsg_from_prj(text)
lon, lat = Transformer.from_crs(crs_input, "EPSG:4326", always_xy=True).transform(
1142863.4, 1869357.4
)
# 정식 EPSG:5179와 십cm 수준에서 같아야 한다 (ITRF2000≈GRS80 무보정)
lon2, lat2 = Transformer.from_crs("EPSG:5179", "EPSG:4326", always_xy=True).transform(
1142863.4, 1869357.4
)
assert abs(lon - lon2) < 1e-5 and abs(lat - lat2) < 1e-5
def test_wkt_string_accepted_by_transformer():
"""반환값이 EPSG든 WKT든 Transformer.from_crs 입력으로 동작해야 한다."""
wkt = CRS.from_epsg(5187).to_wkt("WKT1_GDAL")
x, y = Transformer.from_crs(wkt, "EPSG:4326", always_xy=True).transform(208457.1, 467857.4)
assert 128.0 < x < 130.0 and 36.0 < y < 38.0
# ── B03 메타데이터 라벨 보강 ─────────────────────────────────────────────────
@needs_uploads
def test_analyze_prj_metadata_fills_epsg_labels():
las_meta = analyze_prj_metadata(LAS_PRJ)
assert las_meta["is_valid"] and las_meta["epsg"] == 5176, las_meta["epsg"]
route_meta = analyze_prj_metadata(ROUTE_PRJ)
assert route_meta["is_valid"] and route_meta["epsg"] == 5179, route_meta["epsg"]
@needs_storage
def test_analyze_existing_compound_prj_still_5187():
meta = analyze_prj_metadata(EXISTING_COMPD_PRJ)
assert meta["epsg"] == 5187 and meta["crs_status"] in ("identified", "custom_vertical_crs")
# ── 광역 스윕: 한국 전체 + 해외, WKT 3형식, 익명화 악조건 ────────────────────
KOREAN_CODES = (5173, 5174, 5175, 5176, 5177, 5178, 5179, 5185, 5186, 5187, 5188, 32651, 32652, 4326)
FOREIGN_CODES = (3857, 32610, 25832, 2154, 27700, 26910, 2193, 6669, 6677, 3095, 28355, 31370)
WKT_FORMATS = ("WKT1_GDAL", "WKT2_2019", "WKT1_ESRI")
@pytest.mark.parametrize("fmt", WKT_FORMATS)
def test_sweep_korean_and_foreign_codes(fmt):
"""한국 14종 + 해외 12종을 세 가지 WKT 방언으로 직렬화해 전부 재판별한다."""
for code in KOREAN_CODES + FOREIGN_CODES:
wkt = CRS.from_epsg(code).to_wkt(fmt)
assert identify_epsg(CRS.from_wkt(wkt), wkt) == code, (code, fmt)
assert crs_input_from_prj(wkt) == f"EPSG:{code}", (code, fmt)
def _anonymize(wkt: str) -> str:
"""AUTHORITY 태그 제거 + 좌표계 이름 익명화 — 원청 노선 PRJ와 같은 악조건."""
import re
wkt = re.sub(r",?AUTHORITY\[[^\]]*\]", "", wkt)
return re.sub(r'PROJCS\["[^"]+"', 'PROJCS["unknown"', wkt)
def test_sweep_anonymized_wkt_still_identified():
"""AUTHORITY 없고 이름도 지운 ESRI WKT — 파라미터만으로 판별돼야 한다."""
for code in (5185, 5186, 5187, 5188, 5179, 5174, 5176, 32652, 32610, 2154, 26910):
wkt = _anonymize(CRS.from_epsg(code).to_wkt("WKT1_ESRI"))
got = identify_epsg(CRS.from_wkt(wkt), wkt)
assert got == code, (code, got)
def test_anonymized_never_mislabels_as_other_code():
"""익명화 WKT가 엉뚱한 코드로 오판되면 좌표가 통째로 어긋난다 — 정답 아니면 None만 허용."""
for code in FOREIGN_CODES:
wkt = _anonymize(CRS.from_epsg(code).to_wkt("WKT1_ESRI"))
got = identify_epsg(CRS.from_wkt(wkt), wkt)
assert got in (code, None), (code, got)
@needs_storage
def test_existing_compound_prj_keeps_legacy_epsg_string():
"""기존 프로젝트(수평 5187 + KNGeoid24 수직)는 예전처럼 "EPSG:5187"이 나와야 한다.
수직 성분의 bound(지오이드) 2D 변환에 무관하므로 WKT 폴백 사유가 아니다.
"""
text = EXISTING_COMPD_PRJ.read_text(encoding="utf-8")
assert get_epsg_from_prj(text) == "EPSG:5187"
+89
View File
@@ -0,0 +1,89 @@
"""표지 템플릿·엔진 검증 (2026-08-31 신설).
표지는 A1 실치수 고정이라 좌표가 어긋나면 곧바로 종이 밖으로 나간다. 그래서
종이 크기 플레이스홀더 치환 캐시 오염 가지를 박아 둔다.
"""
import json
from pathlib import Path
import pytest
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, FRAME_LAYER_ID
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
TEMPLATE = Path("resources/template_2dDrawing/00_template_cover.json")
# A1 도각 템플릿과 같은 재단 표식 원점 — 두 도면이 같은 종이 좌표계를 써야 한다.
PAPER = (-5.05, -5.04, 834.95, 588.96)
def _bbox(entities):
xs, ys = [], []
for entity in entities:
shape = entity.get("shapeData") or {}
for key in ("basePoint", "point", "startPoint", "endPoint"):
if key in shape:
xs.append(shape[key]["x"])
ys.append(shape[key]["y"])
for p in shape.get("points", []):
xs.append(p["x"])
ys.append(p["y"])
return min(xs), min(ys), max(xs), max(ys)
def test_템플릿이_A1_종이에_맞는다():
document = json.loads(TEMPLATE.read_text(encoding="utf-8"))
bbox = _bbox(document["entities"])
assert [round(v, 2) for v in bbox] == list(PAPER)
assert round(bbox[2] - bbox[0], 1) == 840.0
assert round(bbox[3] - bbox[1], 1) == 594.0
def test_굵은_띠는_실치수_두께를_갖는다():
"""lineWidth는 화면 픽셀이라 두께가 안 나온다 — solid Hatch로 냈는지 확인."""
document = json.loads(TEMPLATE.read_text(encoding="utf-8"))
hatches = [e for e in document["entities"] if e["type"] == "Hatch"]
assert len(hatches) == 3 # 상단선·하단선·제목 강조선
for hatch in hatches:
assert hatch["shapeData"]["options"]["style"] == "solid"
ys = [p["y"] for p in hatch["shapeData"]["points"]]
assert 0.5 <= max(ys) - min(ys) <= 3.0 # mm
def test_도면은_잠금_도각_레이어로_나온다():
"""도각은 잠기고, 덧그릴 비잠금 층이 최소 하나 있어야 한다.
도각만 있으면 CAD에서 선을 놓을 자리가 없다 표지에 비잠금 주기 층을
함께 싣는다(2026-09-01 `64d944eb`). 순서는 규약이 아니므로 id로 찾는다.
"""
drawing = build_cover_drawing("cover")
assert drawing["format"] == DRAWING_FORMAT
assert {e["layerId"] for e in drawing["entities"]} == {FRAME_LAYER_ID}
layers = {layer["id"]: layer for layer in drawing["layers"]}
assert layers[FRAME_LAYER_ID]["isLocked"] is True
assert any(not layer["isLocked"] for layer in drawing["layers"]), (
"덧그릴 비잠금 도면층이 없다"
)
@pytest.mark.parametrize(
("fields", "expected"),
[
({}, ""), # 값이 없으면 빈칸 — 남의 값이 남지 않는다
({"시행청": "울진국유림관리소"}, "울진국유림관리소"),
],
)
def test_플레이스홀더가_치환된다(fields, expected):
drawing = build_cover_drawing("cover", fields)
labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"]
assert expected in labels
def test_치환이_캐시된_템플릿을_더럽히지_않는다():
"""_load_template은 lru_cache다 — 한 번 채운 값이 다음 도면에 새면 안 된다."""
build_cover_drawing("a", {"공사명": "남의 사업", "시행청": "남의 청"})
fresh = build_cover_drawing("b")["entities"]
labels = [e["shapeData"]["label"] for e in fresh if e["type"] == "Text"]
assert "남의 사업 설계도" not in labels
assert "남의 청" not in labels
+70
View File
@@ -0,0 +1,70 @@
// 줌·팬 시 축 눈금이 도형과 맞는지 — Cross_Axes의 역산·배치 로직 복제 검증.
// 실행: node tmp/tests/test_cross_axes_zoom.mjs
import assert from "node:assert/strict";
const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 };
function niceTickStep(span, targetCount, maxCount) {
if (!(span > 0)) return 1;
const exponent = Math.floor(Math.log10(span / targetCount));
let best = Math.pow(10, exponent + 2);
let bestError = Infinity;
for (const power of [exponent - 1, exponent, exponent + 1, exponent + 2]) {
for (const mantissa of [1, 2, 5]) {
const step = mantissa * Math.pow(10, power);
const count = Math.floor(span / step) + 1;
if (count > Math.max(2, maxCount)) continue;
const error = Math.abs(count - targetCount);
if (error < bestError) {
bestError = error;
best = step;
}
}
}
return best;
}
const widthPx = 630;
const heightPx = 250;
const ppm = (widthPx - CROSS_PAD.left - CROSS_PAD.right) / 24; // 반폭 12m
const maxOffset = 12;
const plotLeft = CROSS_PAD.left;
const plotRight = widthPx - CROSS_PAD.right;
const x = (offset) => plotLeft + (maxOffset - offset) * ppm;
function xTicks({ scale, tx }) {
const offsetAt = (px) => maxOffset - ((px - tx) / scale - plotLeft) / ppm;
const high = offsetAt(plotLeft);
const low = offsetAt(plotRight);
const step = niceTickStep(high - low, 7, Math.floor((plotRight - plotLeft) / 48));
const out = [];
for (let o = Math.ceil(low / step) * step; o <= high + 1e-9; o += step) {
out.push({ offset: o, screenX: tx + scale * x(o) });
}
return { out, step, low, high };
}
// 원배율: 눈금이 플롯 안, 라벨 자리 = 도형 자리.
const base = xTicks({ scale: 1, tx: 0 });
assert.ok(base.out.length >= 4, `원배율 눈금 부족 ${base.out.length}`);
for (const t of base.out) {
assert.ok(t.screenX >= plotLeft - 1e-6 && t.screenX <= plotRight + 1e-6, "원배율 눈금 이탈");
assert.ok(Math.abs(t.screenX - x(t.offset)) < 1e-9, "원배율 자리 어긋남");
}
// 4배 확대(중앙 고정): 보이는 범위가 1/4로 좁아지고, 눈금 자리가 변환된 도형 자리와 같아야 한다.
const scale = 4;
const cx = plotLeft + (plotRight - plotLeft) / 2;
const tx = cx - cx * scale;
const zoomed = xTicks({ scale, tx });
assert.ok(
Math.abs(zoomed.high - zoomed.low - 24 / scale) < 1e-6,
`확대 범위 오류 ${zoomed.high - zoomed.low}`,
);
assert.ok(zoomed.step < base.step, `확대인데 눈금 간격이 안 촘촘해짐 ${zoomed.step}`);
assert.ok(zoomed.out.length >= 4, `확대 눈금 부족 ${zoomed.out.length}`);
for (const t of zoomed.out) {
assert.ok(t.screenX >= plotLeft - 1e-6 && t.screenX <= plotRight + 1e-6, "확대 눈금 이탈");
// 도형은 transform으로 옮겨 그려진다 — 같은 offset의 도형 자리와 눈금 자리가 일치해야 한다.
assert.ok(Math.abs(t.screenX - (tx + scale * x(t.offset))) < 1e-9, "확대 자리 어긋남");
}
console.log("ok");
@@ -0,0 +1,57 @@
// 확대 상태의 팬 한계가 캐시 보유 범위까지 늘어나는지 — Cross_View_Zoom.clampPan 로직 복제.
// 실행: node tmp/tests/test_cross_pan_bounds.mjs
import assert from "node:assert/strict";
const plot = { x: 58, y: 10, width: 552, height: 188 };
// 표시 반폭 12m(=플롯 폭)보다 넓은 캐시 20m — 좌우로 각각 플롯 폭의 2/3만큼 더 있다.
const content = { x: plot.x - 368, y: plot.y - 100, width: plot.width + 736, height: plot.height + 200 };
function clampAxis(value, scale, start, size, from, to) {
const lo = start + size - scale * to;
const hi = start - scale * from;
return Math.min(Math.max(value, Math.min(lo, hi)), Math.max(lo, hi));
}
function clampPan(tx, ty, scale, useContent = true) {
const bounds =
useContent && scale > 1 + 1e-6
? {
x: Math.min(content.x, plot.x),
y: Math.min(content.y, plot.y),
right: Math.max(content.x + content.width, plot.x + plot.width),
bottom: Math.max(content.y + content.height, plot.y + plot.height),
}
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
return {
tx: clampAxis(tx, scale, plot.x, plot.width, bounds.x, bounds.right),
ty: clampAxis(ty, scale, plot.y, plot.height, bounds.y, bounds.bottom),
};
}
// 화면 창이 보는 원배율 좌표 구간.
const windowSpan = (tx, scale) => [(plot.x - tx) / scale, (plot.x + plot.width - tx) / scale];
// 원배율: 이동 없음(기존 규칙 유지).
assert.deepEqual(clampPan(200, 200, 1), { tx: 0, ty: 0 });
// 2배 확대: 캐시 왼쪽 끝까지 끌 수 있고, 창이 캐시 범위를 벗어나지 않는다.
const scale = 2;
const far = clampPan(100000, 100000, scale);
const [leftFrom] = windowSpan(far.tx, scale);
assert.ok(Math.abs(leftFrom - content.x) < 1e-6, `왼쪽 한계가 캐시 끝이 아님: ${leftFrom}`);
const farNeg = clampPan(-100000, -100000, scale);
const [, rightTo] = windowSpan(farNeg.tx, scale);
assert.ok(
Math.abs(rightTo - (content.x + content.width)) < 1e-6,
`오른쪽 한계가 캐시 끝이 아님: ${rightTo}`,
);
// 캐시 범위 밖으로는 못 나간다.
for (const raw of [-5000, -800, -100, 0, 100, 800, 5000]) {
const { tx } = clampPan(raw, 0, scale);
const [from, to] = windowSpan(tx, scale);
assert.ok(from >= content.x - 1e-6, `왼쪽 이탈 ${from}`);
assert.ok(to <= content.x + content.width + 1e-6, `오른쪽 이탈 ${to}`);
}
// content가 없으면(옛 규칙) 확대해도 플롯 영역 안으로만 움직인다.
const noContent = clampPan(100000, 0, scale, false);
const [fitFrom] = windowSpan(noContent.tx, scale);
assert.ok(Math.abs(fitFrom - plot.x) < 1e-6, `기본 한계 어긋남 ${fitFrom}`);
console.log("ok");
+78
View File
@@ -0,0 +1,78 @@
// 횡단 Y축 눈금이 플롯 안(상단~X축선)에만 놓이는지 확인 — crossPlotMetrics + Cross_View 로직 복제.
// 실행: node tmp/tests/test_cross_y_ticks.mjs
import assert from "node:assert/strict";
const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 };
const CROSS_HEIGHT = 250;
const AREA_OVERLAY_HEADROOM_PX = 58;
function niceTickStep(span, targetCount, maxCount) {
if (!(span > 0)) return 1;
const exponent = Math.floor(Math.log10(span / targetCount));
let best = Math.pow(10, exponent + 2);
let bestError = Infinity;
for (const power of [exponent - 1, exponent, exponent + 1, exponent + 2]) {
for (const mantissa of [1, 2, 5]) {
const step = mantissa * Math.pow(10, power);
const count = Math.floor(span / step) + 1;
if (count > Math.max(2, maxCount)) continue;
const error = Math.abs(count - targetCount);
if (error < bestError) {
bestError = error;
best = step;
}
}
}
return best;
}
function ticksOf({ rawMin, rawMax, halfWidth, widthPx = 630, exaggeration = 1 }) {
const minOffset = -halfWidth;
const maxOffset = halfWidth;
const elevationMid = (rawMin + rawMax) / 2;
const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5;
const pixelsPerMeter = (widthPx - CROSS_PAD.left - CROSS_PAD.right) / (maxOffset - minOffset);
const rawSpan = Math.max((rawMax - rawMin + padding * 2) * Math.max(exaggeration, 0.1), 1e-6);
const naturalHeight =
rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
const heightPx = Math.max(naturalHeight, CROSS_HEIGHT);
const displaySpan = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6) / pixelsPerMeter;
const displayMax = elevationMid + displaySpan / 2 + AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter / 2;
const y = (elevation) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter;
const rawDisplaySpan = displaySpan / exaggeration;
const rawTop = elevationMid + (displayMax - elevationMid) / exaggeration;
const rawBottom = rawTop - rawDisplaySpan;
const plotHeightPx = heightPx - CROSS_PAD.top - CROSS_PAD.bottom;
// 표고 눈금 최소 1m(2026-08-23) — 화면 로직과 같게 맞춘다.
const step = Math.max(niceTickStep(rawDisplaySpan, 10, Math.floor(plotHeightPx / 14)), 1);
const out = [];
for (let tick = Math.ceil(rawBottom / step) * step; tick <= rawTop + 1e-9; tick += step) {
out.push({ tick, y: y(elevationMid + (tick - elevationMid) * exaggeration) });
}
return { out, step, top: CROSS_PAD.top, bottom: heightPx - CROSS_PAD.bottom, heightPx };
}
const CASES = [
{ rawMin: 536.0, rawMax: 541.0, halfWidth: 12 }, // 스크린샷과 비슷한 성토 단면
{ rawMin: 530.0, rawMax: 530.4, halfWidth: 6 }, // 거의 평탄(1m 눈금 두세 줄만 나온다)
{ rawMin: 500.0, rawMax: 528.0, halfWidth: 20 }, // 급경사 절토
{ rawMin: 536.0, rawMax: 541.0, halfWidth: 12, exaggeration: 2 }, // 과장 2배
];
for (const c of CASES) {
const { out, step, top, bottom } = ticksOf(c);
assert.ok(out.length >= 2, `눈금 부족: ${JSON.stringify(c)}${out.length}`);
assert.ok(step >= 1 - 1e-9, `1m 미만 눈금: ${step}`);
for (const t of out) {
assert.ok(Math.abs(t.tick - Math.round(t.tick)) < 1e-6, `정수 아닌 표고 눈금 ${t.tick}`);
}
for (const t of out) {
assert.ok(
t.y >= top - 1e-6 && t.y <= bottom + 1e-6,
`플롯 밖 눈금 ${t.tick.toFixed(2)} y=${t.y.toFixed(1)} (허용 ${top}~${bottom.toFixed(1)})`,
);
}
const spacing = out.length > 1 ? Math.abs(out[1].y - out[0].y) : Infinity;
assert.ok(spacing >= 14 - 1e-6, `눈금 간격 과밀 ${spacing.toFixed(1)}px (step=${step})`);
}
console.log("ok");
+73
View File
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""PRJ 좌표계 판별(common_util_crs) — 실제 원청 PRJ로 확인한다.
`0d90595a` 노린 실패 사례를 그대로 태운다.
COMPD_CS(수직 지오이드 결합) `CRS.to_epsg()` None을 준다.
EPSG AUTHORITY가 없는 ESRI WKT 파라미터 지문으로만 가릴 있다.
"""
import io
import sys
from pathlib import Path
import pytest
from pyproj import CRS
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from common_util.common_util_crs import crs_input_from_prj, identify_epsg # noqa: E402
COMPOUND_PRJ = next(ROOT.glob("storage/*/*/*/B03_FileInput/input/prj/result.prj"), None)
ESRI_PRJ = next(
ROOT.glob("resources/knowledge/original/실무문서/**/*중심선(EPSG5176).prj"), None
)
def _read(path: Path) -> str:
return io.open(path, encoding="utf-8", errors="replace").read().strip()
@pytest.mark.skipif(COMPOUND_PRJ is None, reason="프로젝트 저장소에 PRJ 표본이 없다")
def test_수직결합_PRJ는_수평성분_EPSG로_판별된다():
"""KGD2002 / East Belt 2010 + KNGeoid24 — to_epsg()는 None이지만 5187로 가려야 한다."""
text = _read(COMPOUND_PRJ)
crs = CRS.from_user_input(text)
assert crs.to_epsg() is None # 판별 사다리가 필요한 이유
assert identify_epsg(crs, text) == 5187
assert crs_input_from_prj(text) == "EPSG:5187"
@pytest.mark.skipif(ESRI_PRJ is None, reason="실무문서 PRJ 표본이 없다")
def test_AUTHORITY_없는_ESRI_WKT도_판별된다():
"""Korean_1985_Modified_Korea_East_Belt — 파일명이 EPSG5176임을 근거로 삼는다."""
text = _read(ESRI_PRJ)
crs = CRS.from_user_input(text)
assert identify_epsg(crs, text) == 5176
assert crs_input_from_prj(text) == "EPSG:5176"
def test_불량_입력은_None을_돌려준다():
"""차단은 WKT 자체가 불량일 때만 — 라벨 실패는 차단 사유가 아니다."""
assert crs_input_from_prj("이건 WKT가 아니다") is None
assert crs_input_from_prj(" ") is None
assert crs_input_from_prj("") is None
def test_TOWGS84_보정이_박힌_WKT는_원문을_돌려준다():
"""EPSG로 갈아타면 파일의 지역 보정이 사라지므로 WKT 원문을 그대로 써야 한다."""
wkt = (
'PROJCS["Korea 2000 / East Belt 2010",'
'GEOGCS["Korea 2000",DATUM["Korea_2000",'
'SPHEROID["GRS 1980",6378137,298.257222101],'
"TOWGS84[-115.8,474.99,674.11,1.16,-2.31,-1.63,6.43]],"
'PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],'
'PROJECTION["Transverse_Mercator"],'
'PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",129],'
'PARAMETER["scale_factor",1],PARAMETER["false_easting",200000],'
'PARAMETER["false_northing",600000],UNIT["metre",1]]'
)
got = crs_input_from_prj(wkt)
assert got is not None
assert not got.startswith("EPSG:") # 라벨로 갈아타지 않는다
assert "TOWGS84" in got # 보정 모수가 살아 있다
+141
View File
@@ -0,0 +1,141 @@
"""터파기 단면 — 파이썬·TS 짝이 같은 값을 내는지 (2026-09-09).
화면(B06 횡단도) 그리고 서버(B08 수량) 세는 값이라 쪽이 갈리면
그림은 이런데 수량은 저렇다 된다.
근거의 급이 다른 갈래를 함께 잠근다
관은 **법정 **(KCS 44 40 10 그림 3.2-1), 벽은 **실무 정본 **(구조도 기슭막이 xls).
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from common_util.common_util_excavation import ( # noqa: E402
PIPE_TRENCH_WIDTH_MM,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
pipe_trench_width_m,
wall_trench_area_m2,
wall_trench_width_m,
)
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import {
PIPE_TRENCH_WIDTH_MM,
pipeTrenchWidthM,
wallTrenchAreaM2,
wallTrenchWidthM,
} from "./common_util_excavation.js";
const out = {
table: PIPE_TRENCH_WIDTH_MM,
pipe: [300, 800, 1000, 1200, 1500, 250, 1600].map((d) => pipeTrenchWidthM(d)),
wallWithFoundation: [1.0, 2.0, 3.0].map((h) => wallTrenchAreaM2(h, 0.7, true)),
wallBlindingOnly: [1.0, 2.0, 3.0].map((h) => wallTrenchAreaM2(h, 0.7, false)),
wallWidth: [0.7, 1.05, 0].map((t) => wallTrenchWidthM(t)),
nulls: [pipeTrenchWidthM(null), wallTrenchAreaM2(null, 0.7, true), wallTrenchAreaM2(1, null, true)],
};
writeFileSync(process.argv[2], JSON.stringify(out));
"""
def _ts_values(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_excavation.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
for emitted in out.glob("*.js"):
text = emitted.read_text(encoding="utf-8")
emitted.write_text(
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None:
ts = _ts_values(tmp_path)
assert {int(k): v for k, v in ts["table"].items()} == PIPE_TRENCH_WIDTH_MM
assert ts["pipe"] == [pipe_trench_width_m(d) for d in (300, 800, 1000, 1200, 1500, 250, 1600)]
assert ts["wallWithFoundation"] == [
wall_trench_area_m2(h, 0.7, has_foundation=True) for h in (1.0, 2.0, 3.0)
]
assert ts["wallBlindingOnly"] == [
wall_trench_area_m2(h, 0.7, has_foundation=False) for h in (1.0, 2.0, 3.0)
]
assert ts["wallWidth"] == [wall_trench_width_m(t) for t in (0.7, 1.05, 0)]
assert ts["nulls"] == [None, None, None]
def test_관은_표를_그대로_쓴다() -> None:
"""⚠ 관경 + 2b 로 계산하지 않는다 — 표가 관 두께·작업여유를 담고 있다."""
assert pipe_trench_width_m(800) == 1.6
assert pipe_trench_width_m(800) != 0.8 + 2 * 0.3
for diameter in (800, 1000, 1200, 1500):
assert pipe_trench_width_m(diameter) is not None, diameter
def test_표에_없는_관경은_지어내지_않는다() -> None:
assert pipe_trench_width_m(250) is None
assert pipe_trench_width_m(1600) is None
def test_실무_정본_수치와_맞는다() -> None:
"""xls 7탭: H=1.0 → 1.03+0.45 = 1.48 · H=2.0 → 2.51 · H=3.0 → 3.54 (평균두께 0.83)."""
thickness = 0.83
for height, expected in ((1.0, 1.48), (2.0, 2.51), (3.0, 3.54)):
area = wall_trench_area_m2(height, thickness, has_foundation=True)
assert area == pytest.approx(expected, abs=0.01), height
def test_기초_유무가_기초분을_가른다() -> None:
with_base = wall_trench_area_m2(2.0, 0.7, has_foundation=True)
without = wall_trench_area_m2(2.0, 0.7, has_foundation=False)
assert with_base - without == pytest.approx(
WALL_FOUNDATION_DEPTH_M * WALL_FOUNDATION_WIDTH_M - 0.1 * 0.7
)
def test_여유폭은_평균두께에_더한다() -> None:
assert wall_trench_width_m(0.7) == pytest.approx(0.7 + WALL_TRENCH_CLEARANCE_M)
@@ -0,0 +1,54 @@
"""세월교 월류 높이 → 노면 하강(계획고·절성토 면적) 확인 (2026-08-30 사용자 지시).
`_ford_set` 월류 높이를 스펙에 싣고, `compute_cross_design(surface_drop_m=)`
계획고를 통째로 내려 ·성토 면적까지 따라가는지 본다.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Culvert import _ford_set # noqa: E402
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
DROP_M = 0.39
def _samples():
"""V자 계류 — 중심이 낮고 바깥으로 갈수록 높아지는 지반."""
return [
{"offset_m": index / 2, "elevation_m": 100.0 + abs(index / 2) * 0.3, "valid": True}
for index in range(-40, 41)
]
def test_ford_set_carries_overflow_depth():
spec = _ford_set({"ford_width_m": 10, "ford_height_m": DROP_M, "pipe_count": 1})
assert spec["overflow_depth_m"] == DROP_M
assert spec["span_m"] == 10
# 값이 없으면 내리지 않는다.
assert _ford_set({"ford_width_m": 10})["overflow_depth_m"] == 0.0
def test_surface_drop_lowers_plan_and_moves_areas():
samples = _samples()
common = dict(ground_type="soil", section_mode="left_cut")
base = compute_cross_design(samples, 100.0, **common)
drop = compute_cross_design(samples, 100.0, surface_drop_m=DROP_M, **common)
# 계획고·노견이 정확히 월류 높이만큼 내려간다(전폭 평행 하강).
assert round(base["design_elevation_m"] - drop["design_elevation_m"], 4) == DROP_M
for side in ("left", "right"):
gap = base["road_edges"][side]["elevation_m"] - drop["road_edges"][side]["elevation_m"]
assert round(gap, 4) == DROP_M
# 노면이 내려가면 절토가 늘고 성토가 준다.
assert drop["cut_area_m2"] > base["cut_area_m2"]
assert drop["fill_area_m2"] <= base["fill_area_m2"]
# 프론트가 점선을 되그릴 수 있게 하강량을 echo한다. 하강이 없으면 키 자체가 없다.
assert drop["surface_drop_m"] == DROP_M
assert "surface_drop_m" not in base
@@ -0,0 +1,95 @@
// 세월교 측벽 수직화(B06_Section_UI_Cross_Culvert_Basin `leanRatio`) 자체검증 — 로직 복제.
// 2026-08-30 사용자 확정: 세월교 측벽은 수직(교본 그림 3-18). 1:0.3 기움은 ㄴ형
// 집수정 부재를 재사용하며 딸려 온 값이라 세월교에서만 끈다(집수정은 그대로).
// 실행: node tmp/tests/test_ford_wall_vertical.mjs
import assert from "node:assert/strict";
const REVET_LEAN_RATIO = 0.3;
/* ── 복제: buildBasin의 벽·바닥판 좌표 산식 ─────────────────────────── */
function basinParts({ anchorOffset, anchorElevation, outward, wallHeight, memberT, floorT,
innerWidthM, leanRatio }) {
const lean = leanRatio ?? REVET_LEAN_RATIO;
const defaultBottom = anchorElevation - floorT;
const wallBase = anchorOffset + outward * (lean * wallHeight);
const wallOf = (baseOffset, dir, bottomElevation = defaultBottom) => {
const topShift = -dir * lean * wallHeight;
const spanZ = wallHeight + floorT;
const xAt = (elevation) => baseOffset + topShift * ((elevation - defaultBottom) / spanZ);
return [
{ offset: xAt(bottomElevation), elevation: bottomElevation },
{ offset: baseOffset + topShift, elevation: anchorElevation + wallHeight },
{ offset: baseOffset + topShift + dir * memberT, elevation: anchorElevation + wallHeight },
{ offset: xAt(bottomElevation) + dir * memberT, elevation: bottomElevation },
];
};
const wallOuterAt = (elevation) => {
const bottom = anchorElevation - floorT;
const t = (elevation - bottom) / (wallHeight + floorT);
return wallBase + outward * memberT - outward * lean * wallHeight * t;
};
const floorTopInner = wallOuterAt(anchorElevation);
const floorOuter = floorTopInner + outward * (innerWidthM + memberT);
const floorTaper = lean * floorT;
return {
wall: wallOf(wallBase, outward),
floor: [
{ offset: floorTopInner, elevation: anchorElevation },
{ offset: floorOuter, elevation: anchorElevation },
{ offset: floorOuter - outward * floorTaper, elevation: anchorElevation - floorT },
{ offset: wallOuterAt(anchorElevation - floorT), elevation: anchorElevation - floorT },
],
trimOffset: wallBase - outward * lean * wallHeight,
floorLengthM: Math.abs(floorOuter - floorTopInner),
};
}
const base = {
anchorOffset: 3.0, // 노견 끝
anchorElevation: 100.0, // 바닥판 상면
outward: 1, // 좌측
wallHeight: 1.5,
memberT: 0.3, // 벽 두께
floorT: 0.3, // 물받이 두께(교본 최소 0.3m)
innerWidthM: 1.2, // 날개벽 투영 연장
};
/* ── 세월교(leanRatio 0) — 벽이 수직이어야 한다 ─────────────────────── */
const ford = basinParts({ ...base, leanRatio: 0 });
const [b0, t0, t1, b1] = ford.wall;
assert.ok(Math.abs(t0.offset - b0.offset) < 1e-9, `벽 도로측 면이 안 섰다: ${t0.offset} vs ${b0.offset}`);
assert.ok(Math.abs(t1.offset - b1.offset) < 1e-9, `벽 계류측 면이 안 섰다: ${t1.offset} vs ${b1.offset}`);
// 벽은 노견 끝에서 그대로 내려간다 — 상단 자리(설계선 트림 경계)는 그대로.
assert.ok(Math.abs(t0.offset - base.anchorOffset) < 1e-9);
assert.ok(Math.abs(ford.trimOffset - base.anchorOffset) < 1e-9);
// 두께는 유지.
assert.ok(Math.abs(Math.abs(t1.offset - t0.offset) - base.memberT) < 1e-9);
// 바닥판 taper 0 = 직사각형(위·아래 바깥 모서리가 같은 offset).
assert.ok(Math.abs(ford.floor[1].offset - ford.floor[2].offset) < 1e-9, "바닥판 taper가 남았다");
assert.ok(Math.abs(ford.floor[0].offset - ford.floor[3].offset) < 1e-9, "바닥판 안쪽 변이 기울었다");
// 바닥 길이 = 날개벽 투영 연장 + 벽 두께(사용자 유지 지시).
assert.ok(
Math.abs(ford.floorLengthM - (base.innerWidthM + base.memberT)) < 1e-9,
`바닥 길이 어긋남: ${ford.floorLengthM}`,
);
/* ── 집수정(기본값) — 종전 1:0.3 기움 그대로여야 한다 ───────────────── */
const basin = basinParts(base);
const lean = REVET_LEAN_RATIO * base.wallHeight;
assert.ok(
Math.abs(basin.wall[1].offset - basin.wall[0].offset - -lean) < 1e-9,
"집수정 기움이 바뀌었다 — 집수정은 유지가 원칙",
);
assert.ok(
Math.abs(basin.floor[1].offset - basin.floor[2].offset) > 1e-9,
"집수정 바닥판 taper가 사라졌다",
);
// 집수정도 상단 도로측 꼭짓점은 노견 끝(트림 경계)에 있다 — 이 규칙은 공통.
assert.ok(Math.abs(basin.trimOffset - base.anchorOffset) < 1e-9);
/* ── 우측(outward -1)도 같은 규칙 ──────────────────────────────────── */
const right = basinParts({ ...base, outward: -1, leanRatio: 0 });
assert.ok(Math.abs(right.wall[1].offset - right.wall[0].offset) < 1e-9, "우측 벽이 안 섰다");
assert.ok(Math.abs(right.floorLengthM - (base.innerWidthM + base.memberT)) < 1e-9);
console.log("test_ford_wall_vertical.mjs OK");
+128
View File
@@ -0,0 +1,128 @@
"""운반 물량의 **상태** — 내역서 수량은 자연상태다 (2026-09-09).
운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
수량은 자연상태로 한다(설계실무 요령 `config_system_design` 5-4-3 인용문).
시험이 잠그는 것은 **방향**이다. 곱하면 토사가 0.9배가 되어 뒤집힌다.
`L`(1.3·1.35·1.625) 쓰지 않는다는 것도 함께 잠근다 품셈이 `f = 1/L` 스스로 곱한다.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
SummaryInput,
build_table as build_summary,
)
from B08_Quantity.B08_Quantity_Engine_HaulSummary import ( # noqa: E402
build_table,
natural_m3,
summary_input_rows,
)
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
_PLAN = {
"blocks": [
{
"bands": [
{
"equipment": "dozer",
"haul_distance_m": 40.0,
"haul_from_m": 0.0,
"haul_to_m": 40.0,
"ea_m3": 90.0,
"rr_m3": 115.0,
"br_m3": 0.0,
},
{
"equipment": "dump_truck",
"haul_distance_m": 300.0,
"haul_from_m": 0.0,
"haul_to_m": 300.0,
"ea_m3": 0.0,
"rr_m3": 0.0,
"br_m3": 130.0,
},
]
}
],
"transfers": [],
}
def _row(table: dict, equipment: str, ground: str) -> dict:
return next(
row for row in table["rows"] if row["equipment"] == equipment and row["ground"] == ground
)
def test_나누기다_곱하기가_아니다() -> None:
for ground, kind in (("토사", "soil"), ("리핑암", "ripping_rock"), ("발파암", "blasting_rock")):
factor = EARTHWORK_CONVERSION_FACTORS[kind]["compacted"]
assert natural_m3(100.0, ground) == pytest.approx(100.0 / factor)
# 방향이 뒤집히면 이 줄이 잡는다.
assert natural_m3(100.0, ground) != pytest.approx(100.0 * factor)
# 토사는 늘고(÷0.9) 암은 준다(÷1.15·÷1.30) — 부호가 갈래마다 다르다.
assert natural_m3(100.0, "토사") > 100.0
assert natural_m3(100.0, "리핑암") < 100.0
assert natural_m3(100.0, "발파암") < 100.0
def test_L_은_쓰지_않는다() -> None:
"""품셈 10-11·10-12 가 `f = 1/L` 을 스스로 곱하므로 우리가 또 들면 두 번 환산이다."""
for ground, loose in (("토사", 1.3), ("리핑암", 1.35), ("발파암", 1.625)):
assert natural_m3(100.0, ground) != pytest.approx(100.0 / loose)
def test_갈래를_모르면_환산하지_않는다() -> None:
assert natural_m3(100.0, "지반모름") is None
assert natural_m3(100.0, "") is None
def test_네_줄이_두_상태를_함께_낸다() -> None:
table = build_table(_PLAN)
dozer_soil = _row(table, "dozer", "토사")
assert dozer_soil["volume_m3"] == pytest.approx(90.0)
assert dozer_soil["volume_basis"] == "compacted"
assert dozer_soil["natural_m3"] == pytest.approx(100.0) # 90 ÷ 0.90
assert dozer_soil["conversion_c"] == pytest.approx(0.90)
assert _row(table, "dozer", "리핑암")["natural_m3"] == pytest.approx(100.0) # 115 ÷ 1.15
assert _row(table, "dump_truck", "발파암")["natural_m3"] == pytest.approx(100.0) # 130 ÷ 1.30
# 거리는 다짐 기준 그대로 — 환산이 거리를 건드리면 안 된다.
assert dozer_soil["average_distance_m"] == pytest.approx(40.0)
def test_집계표는_자연상태로_싣는다() -> None:
haul = build_table(_PLAN)
summary = build_summary(
SummaryInput(
earthwork_totals={},
slope_totals={},
haul_rows=summary_input_rows(haul),
rock_classes=[],
rock_ratios_pct={},
application_ratios={},
)
)
rows = [row for row in summary["rows"] if row["group"] in ("도자운반", "덤프운반")]
assert rows, "운반 줄이 서야 한다"
for row in rows:
assert row["amount"] == pytest.approx(100.0), row
assert "자연상태 환산" in row["note"]
def test_검산은_다짐상태끼리_한다() -> None:
"""환산값으로 검산하면 늘 어긋난다 — 계획이 다짐이기 때문이다."""
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan
plan = dict(_PLAN, hauled_m3=335.0, transferred_m3=0.0)
check = check_against_plan(build_table(plan), plan)
assert check.difference_m3 == pytest.approx(0.0)
+216
View File
@@ -0,0 +1,216 @@
"""초기값 스냅샷 — 촬영·복원이 작업본을 초기 상태로 되돌리는지 확인한다.
DB는 aiomysql 대신 최소 가짜 커넥션으로 대신한다. 확인하려는 것은 스냅샷 모듈의
규약(무엇을 뜨고, 무엇을 되돌리고, 무엇을 건드리지 않는가)이지 드라이버가 아니다.
"""
import asyncio
import json
from pathlib import Path
import pytest
from common_util.common_util_initial_snapshot import (
has_initial_snapshot,
restore_initial_snapshot,
restore_snapshot_files,
save_initial_snapshot,
snapshot_dir,
)
ROUTE_ROW = {"id": 7, "project_id": "p1", "status": "CONFIRMED", "total_length_m": 123.4}
class _Cursor:
"""SELECT는 준비된 행을, INSERT는 새 id를 돌려주는 최소 커서."""
def __init__(self, state):
self._state = state
self.lastrowid = 0
self._rows = []
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql, params=None):
head = sql.strip().split()[0].upper()
if head == "INSERT":
self._state["inserted"].append((sql, params))
self._state["next_id"] += 1
self.lastrowid = self._state["next_id"]
return
if "FROM routes" in sql:
self._rows = [dict(ROUTE_ROW)]
else:
table = sql.split("FROM ")[1].split()[0]
self._rows = list(self._state["children"].get(table, []))
async def fetchone(self):
return self._rows[0] if self._rows else None
async def fetchall(self):
return self._rows
class _Connection:
def __init__(self, state):
self._state = state
def cursor(self, *_args, **_kwargs):
return _Cursor(self._state)
@pytest.fixture
def project(tmp_path: Path):
"""정본 파일이 든 프로젝트 루트를 만든다."""
for tree, name, text in (
("B05_Profile/route", "structures.json", '{"v": "initial"}'),
("B06_Section/longitudinal", "long.json", "initial"),
("B06_Section/cross_sections", "cross.json", "initial"),
("B04_PreProcess/drainage/edits", "pipe_points.json", '{"pipes": []}'),
):
folder = tmp_path / tree
folder.mkdir(parents=True, exist_ok=True)
(folder / name).write_text(text, encoding="utf-8")
return tmp_path
@pytest.fixture
def state():
return {
"inserted": [],
"next_id": 100,
"children": {
"route_points": [{"id": 1, "route_id": 7, "chainage_m": 0.0}],
"route_statistics": [{"id": 2, "route_id": 7, "min_slope": 1.0}],
"longitudinal_sections": [{"id": 3, "route_id": 7, "project_id": "p1"}],
"cross_sections": [{"id": 4, "route_id": 7, "project_id": "p1"}],
},
}
def test_snapshot_captures_files_and_rows(project, state):
assert not has_initial_snapshot(project)
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
assert has_initial_snapshot(project)
dump = json.loads((snapshot_dir(project) / "db.json").read_text(encoding="utf-8"))
assert dump["routes"][0]["total_length_m"] == 123.4
assert len(dump["cross_sections"]) == 1
# 파일 트리 4개가 통째로 떠 있어야 한다.
# 배수유역은 `edits/` 만이 아니라 **폴더 통째**로 뜬다(2026-09-04 사용자 확정).
saved = snapshot_dir(project) / "B04_PreProcess__drainage" / "edits" / "pipe_points.json"
assert saved.read_text(encoding="utf-8") == '{"pipes": []}'
def test_snapshot_is_taken_once(project, state):
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
# 촬영 뒤 사용자가 정본을 고쳐도 스냅샷은 그대로여야 한다(읽기 전용 층).
(project / "B05_Profile/route/structures.json").write_text('{"v": "edited"}', encoding="utf-8")
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
kept = snapshot_dir(project) / "B05_Profile__route" / "structures.json"
assert json.loads(kept.read_text(encoding="utf-8"))["v"] == "initial"
def test_restore_overwrites_edited_masters(project, state):
"""편집분이 남아 초기값이 오염되던 것이 이 복원으로 사라진다."""
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
edited = project / "B04_PreProcess/drainage/edits/pipe_points.json"
edited.write_text('{"pipes": [{"chainage_m": 30}]}', encoding="utf-8")
(project / "B05_Profile/route/extra.json").write_text("사용자가 만든 것", encoding="utf-8")
restore_snapshot_files(project)
assert edited.read_text(encoding="utf-8") == '{"pipes": []}'
# 스냅샷에 없던 파일은 남지 않는다(트리 통째 교체).
assert not (project / "B05_Profile/route/extra.json").exists()
def test_restore_reinserts_rows_with_new_route_id(project, state):
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
connection = _Connection(state)
new_id = asyncio.run(restore_initial_snapshot(connection, project, "p1"))
assert new_id == 101 # routes 1행이 먼저 들어간다
tables = [sql.split("INSERT INTO ")[1].split()[0] for sql, _ in state["inserted"]]
assert tables == [
"routes",
"route_points",
"route_statistics",
"longitudinal_sections",
"cross_sections",
]
# 자식 행은 옛 id가 아니라 새 route_id를 달고 들어가야 한다.
child_sql, child_params = state["inserted"][1]
columns = child_sql.split("(")[1].split(")")[0].replace("`", "").split(", ")
assert child_params[columns.index("route_id")] == new_id
assert "id" not in columns # AUTO_INCREMENT 자리는 비워 둔다
def test_restore_without_snapshot_returns_none(tmp_path, state):
assert asyncio.run(restore_initial_snapshot(_Connection(state), tmp_path, "p1")) is None
# ── 계산 중 진입 차단 마커 · 스냅샷 무효화 · 편집 정본 제거 (2026-08-29) ──
def test_designing_marker_lifecycle(project):
from common_util.common_util_initial_snapshot import (
clear_designing,
designing_lock_path,
is_designing,
mark_designing,
)
assert not is_designing(project)
mark_designing(project)
assert is_designing(project)
# 마커는 스냅샷 대상 트리 밖(프로젝트 루트)에 있어야 복원에 딸려 들어가지 않는다.
assert designing_lock_path(project).parent == project
clear_designing(project)
assert not is_designing(project)
clear_designing(project) # 두 번 내려도 터지지 않는다
def test_marker_not_captured_by_snapshot(project, state):
from common_util.common_util_initial_snapshot import mark_designing
mark_designing(project)
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
captured = [p.name for p in snapshot_dir(project).rglob("*") if p.is_file()]
assert "initial_design.lock" not in captured
def test_discard_initial_snapshot(project, state):
from common_util.common_util_initial_snapshot import (
discard_initial_snapshot,
has_initial_snapshot,
)
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
assert has_initial_snapshot(project)
assert discard_initial_snapshot(project) is True
assert not has_initial_snapshot(project)
# 없는 것을 지우면 False — 호출부가 로그를 남길지 판단한다.
assert discard_initial_snapshot(project) is False
def test_wipe_edited_masters(project):
"""폴백 재계산이 진짜 초기값을 만들려면 이 둘이 먼저 사라져야 한다."""
from common_util.common_util_initial_snapshot import wipe_edited_masters
removed = wipe_edited_masters(project)
assert sorted(removed) == [
"B04_PreProcess/drainage/edits",
"B05_Profile/route/structures.json",
]
assert not (project / "B05_Profile/route/structures.json").exists()
assert not (project / "B04_PreProcess/drainage/edits").exists()
# 다른 정본은 건드리지 않는다.
assert (project / "B06_Section/longitudinal/long.json").exists()
assert wipe_edited_masters(project) == [] # 두 번 불러도 조용히 끝난다
@@ -0,0 +1,63 @@
"""B05·B06 유토곡선 일원화 소스 검사 (2026-09-03 사용자 지시).
같은 데이터를 화면에 보여 주는 기능이므로 보는 노선 횡단 재계산 창구
낡음 판정 규칙이 벌이어야 한다. 실측으로 드러난 갈림:
· 노선 B05 route 126(DRAFT) / B06 route 125(CONFIRMED), 20m 성토 4.82 85.9
· 재계산 B06만 표준 단면값· 경계 오프셋을 실어 보냄, 절토 228.52 270.12
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
REPO = _read("B06_Section/B06_Section_Repository.py")
B06_ROUTER = _read("B06_Section/B06_Section_Router.py")
REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts")
B05_PREVIEW = _read("B05_Profile/B05_Profile_UI_Profile_Preview.ts")
B06_PAGE = _read("B06_Section/B06_Section_UI_Page.ts")
def test_workflow_route_context_has_no_confirmed_filter():
"""화면이 보는 경로는 최신 경로 — 확정 여부로 거르지 않는다."""
assert "async def get_workflow_route_context" in REPO
assert "confirmed_only=False" in REPO
# 확정본만 보는 창구(B07 납품도면)는 그대로 남아 있어야 한다.
assert "async def get_confirmed_route_context" in REPO
assert "confirmed_only=True" in REPO
def test_b06_context_uses_workflow_route():
"""B06 화면 context는 B05와 같은 최신 경로를 돌려준다."""
# 2026-09-06 부터 이 읽기는 `asyncio.gather` 로 묶여 커넥션 인자가 사라졌다
# (`run_with_connection` 이 자기 커넥션을 넘긴다). 확인할 것은 **어느 창구를 쓰는가**다.
assert "get_workflow_route_context" in B06_ROUTER
assert "get_confirmed_route_context" not in B06_ROUTER
def test_cross_refresh_is_single_entry():
"""횡단 재계산은 한 창구뿐 — 두 화면이 같은 인자를 실어 보낸다."""
assert "export async function refreshCrossDesigns" in REFRESH
assert "readStandardCrossSession(projectId)" in REFRESH
assert "readRockBoundarySession(projectId, routeId)" in REFRESH
for source in (B05_PREVIEW, B06_PAGE):
assert "refreshCrossDesigns(" in source
assert "previewCrossDesigns(" not in source
def test_cross_refresh_preserves_user_fields():
"""서버 설계로 갈아 끼워도 화면 조작으로만 생기는 값은 살아남는다."""
for field in (
"inlet_structure",
"basin_adjust",
"revet_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
):
assert f'"{field}"' in REFRESH # preserveUserFields 의 보존 필드 목록
@@ -0,0 +1,423 @@
"""채집석 공제 — 사토에서 한 번만, 실어 내는 몫부터 뺀다 (2026-09-09 사용자 확정).
채집석 공제는 사토에서 번만 뺀다.
B08 소요량(collected_stone_deduction_m3, 양수) 내기만 하고 공제하지 않으며,
빼는 자리는 유토곡선의 사토뿐이다
실어 내는 (spoil_m3 natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
TS 코드를 **실제로 돌려** 확인한다 파이썬 짝이 없는 자리라(브라우저·서버 모두 TS
그대로 실행) 소스 문자열을 훑는 것으로는 셈이 맞는지 없다.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { computeHaulPlan } from "./common_util_mass_haul_balance.js";
const [inputPath, outputPath] = process.argv.slice(2);
const input = JSON.parse(readFileSync(inputPath, "utf8"));
const out = [];
for (const item of input.cases) {
const plan = computeHaulPlan(input.result, input.limits, {
collected_stone_deduction_m3: item.deduction ?? null,
collected_stone_by_ground_m3: item.stoneByGround ?? null,
collected_stone_ground_unknown_m3: item.stoneUnknown ?? null,
structure_spoil_m3: item.spoil ?? null,
structure_spoil_points: item.points ?? null,
conversion: item.conversion ?? null,
});
out.push(
plan === null
? null
: {
spoil_m3: plan.spoil_m3,
natural_spoil_m3: plan.natural_spoil_m3,
borrow_m3: plan.borrow_m3,
deduction: plan.collected_stone_deduction_m3,
deducted: plan.collected_stone_deducted_m3,
spoilIn: plan.structure_spoil_m3,
spoilAdded: plan.structure_spoil_added_m3,
residuals: plan.residuals.map((r) => ({
kind: r.kind,
volume_m3: r.volume_m3,
natural_m3: r.natural_m3,
ea_m3: r.ea_m3,
rr_m3: r.rr_m3,
br_m3: r.br_m3,
})),
},
);
}
writeFileSync(outputPath, JSON.stringify(out));
"""
def _mass_haul_result() -> dict:
"""절토가 앞, 성토가 뒤인 짧은 노선 — 다 못 쓴 흙이 사토로 남는다."""
points = []
cumulative = 0.0
for index in range(11):
cut = 50.0 if index and index <= 5 else 0.0
fill = 20.0 if index > 5 else 0.0
net = cut - fill
cumulative += net
points.append(
{
"station_id": f"S{index:03d}",
"chainage_m": index * 20.0,
"net_volume_m3": net,
"cumulative_volume_m3": cumulative,
"cut_soil_m3": cut,
"cut_rock_m3": 0.0,
"cut_rr_m3": 0.0,
"cut_br_m3": 0.0,
"cut_compacted_m3": cut,
"fill_m3": fill,
"natural_spoil": False,
"net_area_m2": net / 20.0,
}
)
return {
"points": points,
"cut_natural_m3": {"ea": 250.0, "rr": 0.0, "br": 0.0},
"cut_compacted_m3": 250.0,
"fill_compacted_m3": 100.0,
"final_cumulative_m3": cumulative,
"surplus_m3": max(cumulative, 0.0),
"shortage_m3": 0.0,
"min_cumulative_m3": 0.0,
"max_cumulative_m3": 250.0,
"conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0},
}
def _run(tmp_path: Path, cases: list[dict]) -> list[dict | None]:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_mass_haul_balance.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
# tsc 가 낸 상대 import 에는 확장자가 없어 node ESM 이 못 찾는다 — `.js` 를 붙여 준다.
for emitted in out.glob("*.js"):
text = emitted.read_text(encoding="utf-8")
emitted.write_text(
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(
json.dumps({"result": _mass_haul_result(), "limits": None, "cases": cases}),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_공제가_사토를_줄이고_안_온_값과_0을_가른다(tmp_path: Path) -> None:
none_plan, zero_plan, some_plan = _run(tmp_path, [{}, {"deduction": 0.0}, {"deduction": 30.0}])
assert none_plan is not None and zero_plan is not None and some_plan is not None
# ① 「아직 안 옴(None)」과 「공제 없음(0)」은 결과가 같되 **표시가 다르다**.
assert none_plan["deduction"] is None
assert zero_plan["deduction"] == 0
assert none_plan["spoil_m3"] == zero_plan["spoil_m3"]
# ② 공제한 만큼 사토가 줄어든다.
assert some_plan["deducted"] == pytest.approx(30.0)
assert some_plan["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] - 30.0)
# ③ 잔량 자체가 줄어야 운반 물량이 따라간다 — 총량만 줄이면 안 된다.
before = sum(r["volume_m3"] for r in zero_plan["residuals"] if r["kind"] == "spoil")
after = sum(r["volume_m3"] for r in some_plan["residuals"] if r["kind"] == "spoil")
assert after == pytest.approx(before - 30.0)
# ④ 토취는 건드리지 않는다.
assert some_plan["borrow_m3"] == zero_plan["borrow_m3"]
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_사토보다_큰_공제는_사토까지만_뺀다(tmp_path: Path) -> None:
(zero_plan,) = _run(tmp_path, [{"deduction": 0.0}])
huge = zero_plan["spoil_m3"] * 10
(plan,) = _run(tmp_path, [{"deduction": huge}])
assert plan["spoil_m3"] == pytest.approx(0.0, abs=1e-6)
assert plan["deducted"] == pytest.approx(zero_plan["spoil_m3"])
assert plan["deducted"] < huge # 받은 값보다 작게 뺐다는 것이 드러난다
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_자연방토는_나중에_깎인다(tmp_path: Path) -> None:
"""실어 내는 몫이 남아 있는 동안에는 자연방토가 줄지 않는다."""
(zero_plan,) = _run(tmp_path, [{"deduction": 0.0}])
haul_out = zero_plan["spoil_m3"] - zero_plan["natural_spoil_m3"]
if haul_out <= 0:
pytest.skip("이 표본은 사토가 전부 자연방토라 순서를 못 본다")
(plan,) = _run(tmp_path, [{"deduction": haul_out / 2.0}])
assert plan["natural_spoil_m3"] == pytest.approx(zero_plan["natural_spoil_m3"])
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_구조물_잔토는_사토에_더해지고_잔량도_함께_는다(tmp_path: Path) -> None:
"""⚠ 공제는 빼고 이것은 **더한다**. 총량만 늘리면 운반이 안 는다."""
zero_plan, spoil_plan = _run(tmp_path, [{}, {"spoil": 40.0}])
assert zero_plan is not None and spoil_plan is not None
assert zero_plan["spoilIn"] is None # 「아직 안 옴」
assert spoil_plan["spoilIn"] == pytest.approx(40.0)
assert spoil_plan["spoilAdded"] == pytest.approx(40.0)
assert spoil_plan["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] + 40.0)
before = sum(r["volume_m3"] for r in zero_plan["residuals"] if r["kind"] == "spoil")
after = sum(r["volume_m3"] for r in spoil_plan["residuals"] if r["kind"] == "spoil")
assert after == pytest.approx(before + 40.0)
# 자연방토는 안 늘린다 — 구조물 잔토는 실어 내는 흙이다.
assert spoil_plan["natural_spoil_m3"] == pytest.approx(zero_plan["natural_spoil_m3"])
# 토취는 건드리지 않는다.
assert spoil_plan["borrow_m3"] == pytest.approx(zero_plan["borrow_m3"])
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_공제와_잔토가_함께_와도_한_번씩만_먹는다(tmp_path: Path) -> None:
zero_plan, both = _run(tmp_path, [{}, {"deduction": 10.0, "spoil": 40.0}])
assert both["deducted"] == pytest.approx(10.0)
assert both["spoilAdded"] == pytest.approx(40.0)
assert both["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] - 10.0 + 40.0)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_담을_사토가_없으면_사토를_새로_세운다(tmp_path: Path) -> None:
"""⚠ 물량이 사라지면 안 된다 — 파낸 흙은 어디로든 간다(2026-09-09 확정 ㉰).
토취를 줄이는 길은 잔토를 성토재로 있다 근거가 있어야 하므로
지금은 **내보내는 (안전측)**으로 둔다.
"""
(plan,) = _run(
tmp_path,
[{"spoil": 25.0, "points": [{"chainage_m": 500.0, "spoil_m3": 25.0}]}],
)
assert plan is not None
assert plan["spoilAdded"] == pytest.approx(25.0)
spoils = [r for r in plan["residuals"] if r["kind"] == "spoil"]
assert sum(r["volume_m3"] for r in spoils) >= 25.0 - 1e-6
# 새로 선 사토는 **실어 내는 몫**이다 — 자연방토로 눅이지 않는다.
assert all(r["natural_m3"] == 0 for r in spoils if r["volume_m3"] == pytest.approx(25.0))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_잔토를_더한_뒤에_공제를_뺀다(tmp_path: Path) -> None:
"""⚠ 순서가 뜻을 가른다 — 사토가 0 인 노선에서 반대 순서면 공제가 영영 안 걸린다.
실측( ): 사토 0 노선에서 잔토 126.63 얹혔는데 공제는 0 이었다.
더하고 빼기 두면 126.63 64.75 = 61.88 된다.
"""
base, plan = _run(
tmp_path,
[
{},
{
"spoil": 100.0,
"points": [{"chainage_m": 60.0, "spoil_m3": 100.0}],
"deduction": 40.0,
},
],
)
assert base is not None and plan is not None
assert plan["spoilAdded"] == pytest.approx(100.0)
assert plan["deducted"] == pytest.approx(40.0) # 잔토 덕에 뺄 대상이 생겼다
# 더하고 뺀 결과가 그대로 남는다.
assert plan["spoil_m3"] == pytest.approx(base["spoil_m3"] + 100.0 - 40.0)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_터파기_토질이_오면_그_갈래로_담는다(tmp_path: Path) -> None:
"""B08 이 `design.ground_type` 으로 이미 판정한 값을 **이어받는다** — 새 근거가 아니다.
고른 구조물은 토질이 실려 오고, 몫은 지반 모름으로 남는다
(`ground_unknown_m3` 드러난다).
"""
base, plan = _run(
tmp_path,
[
{},
{
"spoil": 60.0,
"points": [
# B08 이 보내는 이름(`ground_type`)과 옛 이름(`ground`) 둘 다 받는다.
{"chainage_m": 500.0, "spoil_m3": 20.0, "ground_type": "ripping_rock"},
{"chainage_m": 520.0, "spoil_m3": 25.0, "ground": "토사"},
{"chainage_m": 540.0, "spoil_m3": 15.0}, # 못 고른 것
],
},
],
)
assert base is not None and plan is not None
def bucket(rows: list[dict], key: str) -> float:
return sum(r[key] for r in rows if r["kind"] == "spoil")
before, after = base["residuals"], plan["residuals"]
assert plan["spoilAdded"] == pytest.approx(60.0)
# 갈래가 온 몫은 **그 갈래로** 늘어난다.
assert bucket(after, "rr_m3") - bucket(before, "rr_m3") == pytest.approx(20.0)
assert bucket(after, "ea_m3") - bucket(before, "ea_m3") == pytest.approx(25.0)
assert bucket(after, "br_m3") == pytest.approx(bucket(before, "br_m3"))
# 못 고른 15 는 어느 갈래에도 안 들어간다 — 「모름」으로 남는다.
unknown_before = bucket(before, "volume_m3") - (
bucket(before, "ea_m3") + bucket(before, "rr_m3") + bucket(before, "br_m3")
)
unknown_after = bucket(after, "volume_m3") - (
bucket(after, "ea_m3") + bucket(after, "rr_m3") + bucket(after, "br_m3")
)
assert unknown_after - unknown_before == pytest.approx(15.0, abs=1e-6)
# ── 구조물 잔토의 **상태** — 자연상태로 와서 다짐상태 곡선에 얹힌다 (2026-09-09) ──────
# B08 이 보내는 잔토는 터파기 제자리 기하 부피라 **자연상태**(`volume_basis: "natural"`)이고
# 유토곡선은 **다짐상태**다. 담기 전에 ×C 하지 않으면 상태가 다른 두 부피를 섞는 것이 된다.
_CONVERSION = {
"soil": {"loose": 1.25, "compacted": 0.90},
"ripping_rock": {"loose": 1.35, "compacted": 1.15},
"blasting_rock": {"loose": 1.60, "compacted": 1.30},
}
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_잔토는_다짐으로_바꿔_담는다(tmp_path: Path) -> None:
points = [{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": "soil"}]
plain, converted = _run(
tmp_path,
[
{"points": points},
{"points": points, "conversion": _CONVERSION},
],
)
# 환산 없이 담으면 자연상태 100 이 그대로 들어간다.
assert plain["spoilAdded"] == pytest.approx(100.0)
# 계수가 오면 ×C — 토사 0.90 이라 다짐 90 으로 담긴다.
assert converted["spoilAdded"] == pytest.approx(90.0)
# ⚠ 왕복이 맞아야 한다 — B08 이 내보낼 때 ÷C 하면 원래 자연상태 100 이 돌아온다.
assert converted["spoilAdded"] / _CONVERSION["soil"]["compacted"] == pytest.approx(100.0)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_갈래를_모르면_환산하지_않는다(tmp_path: Path) -> None:
"""계수가 없는 몫을 토사로 눅이면 근거 없이 금액이 움직인다."""
base, plan = _run(
tmp_path,
[
{"conversion": _CONVERSION},
{
"points": [{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": None}],
"conversion": _CONVERSION,
},
],
)
assert plan["spoilAdded"] == pytest.approx(100.0)
def buckets(item: dict) -> float:
return sum(
r["ea_m3"] + r["rr_m3"] + r["br_m3"] for r in item["residuals"] if r["kind"] == "spoil"
)
# 갈래 칸은 그대로이고 총량만 는다 ⇒ 그 차이가 「지반 모름」으로 드러난다.
assert buckets(plan) == pytest.approx(buckets(base))
assert plan["spoil_m3"] == pytest.approx(base["spoil_m3"] + 100.0)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_갈래마다_계수가_다르다(tmp_path: Path) -> None:
(plan,) = _run(
tmp_path,
[
{
"points": [
{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": "ripping_rock"},
{"chainage_m": 80.0, "spoil_m3": 100.0, "ground_type": "blasting_rock"},
],
"conversion": _CONVERSION,
}
],
)
assert plan["spoilAdded"] == pytest.approx(115.0 + 130.0)
# ── 채집석의 **축** — 벽 입적(자연)을 다짐 곡선에서 빼려면 ×C (2026-09-09 세 창 확정) ──
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_채집석은_갈래마다_C를_곱해_뺀다(tmp_path: Path) -> None:
base, plain, converted = _run(
tmp_path,
[
{"deduction": 0.0},
{"deduction": 100.0, "stoneByGround": {"ripping_rock": 100.0}},
{
"deduction": 100.0,
"stoneByGround": {"ripping_rock": 100.0},
"conversion": _CONVERSION,
},
],
)
# 계수가 안 오면 종전처럼 그대로 뺀다.
assert plain["spoil_m3"] == pytest.approx(base["spoil_m3"] - 100.0)
# 리핑암 C=1.15 ⇒ 다짐 축에서는 115 을 빼야 같은 양이다.
assert converted["deducted"] == pytest.approx(115.0)
assert converted["spoil_m3"] == pytest.approx(base["spoil_m3"] - 115.0)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_갈래_모르는_채집석은_환산하지_않는다(tmp_path: Path) -> None:
(plan,) = _run(
tmp_path,
[
{
"deduction": 100.0,
"stoneByGround": {"ripping_rock": 40.0},
"stoneUnknown": 60.0,
"conversion": _CONVERSION,
}
],
)
# 40×1.15 = 46 (환산) + 60 (그대로) = 106
assert plan["deducted"] == pytest.approx(106.0)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_갈래가_안_오면_총량으로_되돌아간다(tmp_path: Path) -> None:
"""값이 안 오는 것과 0 은 다르다 — 갈래가 없으면 종전 동작."""
(plan,) = _run(tmp_path, [{"deduction": 30.0, "conversion": _CONVERSION}])
assert plan["deducted"] == pytest.approx(30.0)
+45
View File
@@ -0,0 +1,45 @@
// niceTickStep 자체검증 — B06_Section_UI_Section_Common.ts와 같은 로직(의존 없이 복제).
// 실행: node tmp/tests/test_nice_tick_step.mjs
import assert from "node:assert/strict";
function niceTickStep(span, targetCount, maxCount) {
if (!(span > 0)) return 1;
const exponent = Math.floor(Math.log10(span / targetCount));
let best = Math.pow(10, exponent + 2);
let bestError = Infinity;
for (const power of [exponent - 1, exponent, exponent + 1, exponent + 2]) {
for (const mantissa of [1, 2, 5]) {
const step = mantissa * Math.pow(10, power);
const count = Math.floor(span / step) + 1;
if (count > Math.max(2, maxCount)) continue;
const error = Math.abs(count - targetCount);
if (error < bestError) {
bestError = error;
best = step;
}
}
}
return best;
}
const counts = (span, step) => Math.floor(span / step) + 1;
// 1·2·5 계열만 나온다 + 개수는 상한 이하, 목표 10에 근접.
for (const span of [0.4, 1, 3.7, 8, 21, 27, 63, 140, 900, 4200]) {
const step = niceTickStep(span, 10, 20);
const mantissa = step / Math.pow(10, Math.round(Math.log10(step / 5)) - 0 || 0);
assert.ok(step > 0, `step>0 (span=${span})`);
const norm = step / Math.pow(10, Math.floor(Math.log10(step) + 1e-9));
assert.ok([1, 2, 5].some((m) => Math.abs(norm - m) < 1e-9), `1·2·5 계열 아님: ${step}`);
assert.ok(counts(span, step) <= 20, `상한 초과: span=${span} step=${step}`);
assert.ok(counts(span, step) >= 3, `너무 성김: span=${span} step=${step}`);
void mantissa;
}
// 표고 27m 범위, 화면 여유 충분 → 2m 간격 10줄.
assert.equal(niceTickStep(27, 10, 20), 2);
// 화면이 낮아 6줄까지만 → 5m 간격.
assert.equal(niceTickStep(27, 10, 6), 5);
// 좁은 횡단(2m) → 0.2m 간격.
assert.equal(niceTickStep(2, 10, 20), 0.2);
// 범위 0 방어.
assert.equal(niceTickStep(0, 10, 10), 1);
console.log("ok");

Some files were not shown because too many files have changed in this diff Show More