diff --git a/resources/tester/cad/block-library.test.ts b/resources/tester/cad/block-library.test.ts new file mode 100644 index 00000000..0972a9c3 --- /dev/null +++ b/resources/tester/cad/block-library.test.ts @@ -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(); +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(); + }); +}); diff --git a/resources/tester/cad/geometry.test.ts b/resources/tester/cad/geometry.test.ts new file mode 100644 index 00000000..9b1ae485 --- /dev/null +++ b/resources/tester/cad/geometry.test.ts @@ -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); + }); +}); diff --git a/resources/tester/cad/grips.test.ts b/resources/tester/cad/grips.test.ts new file mode 100644 index 00000000..175aa4bf --- /dev/null +++ b/resources/tester/cad/grips.test.ts @@ -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(); + }); +}); diff --git a/resources/tester/cad/table.test.ts b/resources/tester/cad/table.test.ts new file mode 100644 index 00000000..fed1a08f --- /dev/null +++ b/resources/tester/cad/table.test.ts @@ -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); + }); +}); diff --git a/resources/tester/conftest.py b/resources/tester/conftest.py new file mode 100644 index 00000000..b7fc4692 --- /dev/null +++ b/resources/tester/conftest.py @@ -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__))))) diff --git a/resources/tester/diag_structures.py b/resources/tester/diag_structures.py new file mode 100644 index 00000000..eefdacbd --- /dev/null +++ b/resources/tester/diag_structures.py @@ -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()) diff --git a/resources/tester/helper_b05_cut_merged_loop.cjs b/resources/tester/helper_b05_cut_merged_loop.cjs new file mode 100644 index 00000000..0411ba15 --- /dev/null +++ b/resources/tester/helper_b05_cut_merged_loop.cjs @@ -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)); diff --git a/resources/tester/helper_b05_patch_skirt.cjs b/resources/tester/helper_b05_patch_skirt.cjs new file mode 100644 index 00000000..b21ef34b --- /dev/null +++ b/resources/tester/helper_b05_patch_skirt.cjs @@ -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)); diff --git a/resources/tester/helper_b06_fill_slope_length.py b/resources/tester/helper_b06_fill_slope_length.py new file mode 100644 index 00000000..64ef38c2 --- /dev/null +++ b/resources/tester/helper_b06_fill_slope_length.py @@ -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) diff --git a/resources/tester/probe_berm_infinite.py b/resources/tester/probe_berm_infinite.py new file mode 100644 index 00000000..393e898b --- /dev/null +++ b/resources/tester/probe_berm_infinite.py @@ -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}") diff --git a/resources/tester/probe_pipe_branch_today.py b/resources/tester/probe_pipe_branch_today.py new file mode 100644 index 00000000..0a9f3691 --- /dev/null +++ b/resources/tester/probe_pipe_branch_today.py @@ -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}") diff --git a/resources/tester/probe_unclosed_reach.py b/resources/tester/probe_unclosed_reach.py new file mode 100644 index 00000000..8f6a552b --- /dev/null +++ b/resources/tester/probe_unclosed_reach.py @@ -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") diff --git a/resources/tester/probe_unclosed_width.py b/resources/tester/probe_unclosed_width.py new file mode 100644 index 00000000..b4d72ae5 --- /dev/null +++ b/resources/tester/probe_unclosed_width.py @@ -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()) diff --git a/resources/tester/run_prebuild_smoke.py b/resources/tester/run_prebuild_smoke.py new file mode 100644 index 00000000..a1eaa8ac --- /dev/null +++ b/resources/tester/run_prebuild_smoke.py @@ -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()) diff --git a/resources/tester/table_migration_check.py b/resources/tester/table_migration_check.py new file mode 100644 index 00000000..b9409a6e --- /dev/null +++ b/resources/tester/table_migration_check.py @@ -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"]]) diff --git a/resources/tester/test_app_imports.py b/resources/tester/test_app_imports.py new file mode 100644 index 00000000..a3a8ae7b --- /dev/null +++ b/resources/tester/test_app_imports.py @@ -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}" diff --git a/resources/tester/test_b03_direct_upload_guards.py b/resources/tester/test_b03_direct_upload_guards.py new file mode 100644 index 00000000..906951fd --- /dev/null +++ b/resources/tester/test_b03_direct_upload_guards.py @@ -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 diff --git a/resources/tester/test_b03_preview_path.py b/resources/tester/test_b03_preview_path.py new file mode 100644 index 00000000..8d101cab --- /dev/null +++ b/resources/tester/test_b03_preview_path.py @@ -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]]] diff --git a/resources/tester/test_b03_replace_confirm.py b/resources/tester/test_b03_replace_confirm.py new file mode 100644 index 00000000..964435ca --- /dev/null +++ b/resources/tester/test_b03_replace_confirm.py @@ -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 diff --git a/resources/tester/test_b04_contour_equivalence.py b/resources/tester/test_b04_contour_equivalence.py new file mode 100644 index 00000000..6da02b4d --- /dev/null +++ b/resources/tester/test_b04_contour_equivalence.py @@ -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}초 — 가속 실패" diff --git a/resources/tester/test_b04_contour_raster.py b/resources/tester/test_b04_contour_raster.py new file mode 100644 index 00000000..e43e6ade --- /dev/null +++ b/resources/tester/test_b04_contour_raster.py @@ -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() diff --git a/resources/tester/test_b04_ground_filters.py b/resources/tester/test_b04_ground_filters.py new file mode 100644 index 00000000..81a4d1a2 --- /dev/null +++ b/resources/tester/test_b04_ground_filters.py @@ -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"] diff --git a/resources/tester/test_b04_pipe_points_facility.py b/resources/tester/test_b04_pipe_points_facility.py new file mode 100644 index 00000000..18b181dd --- /dev/null +++ b/resources/tester/test_b04_pipe_points_facility.py @@ -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] diff --git a/resources/tester/test_b05_corridor_envelope_flags.py b/resources/tester/test_b05_corridor_envelope_flags.py new file mode 100644 index 00000000..8b276104 --- /dev/null +++ b/resources/tester/test_b05_corridor_envelope_flags.py @@ -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 (` 부터 다음 최상위 `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 diff --git a/resources/tester/test_b05_corridor_hash_probe.py b/resources/tester/test_b05_corridor_hash_probe.py new file mode 100644 index 00000000..f38732e5 --- /dev/null +++ b/resources/tester/test_b05_corridor_hash_probe.py @@ -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 diff --git a/resources/tester/test_b05_corridor_prebuild.py b/resources/tester/test_b05_corridor_prebuild.py new file mode 100644 index 00000000..df1296ed --- /dev/null +++ b/resources/tester/test_b05_corridor_prebuild.py @@ -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() diff --git a/resources/tester/test_b05_corridor_prune.py b/resources/tester/test_b05_corridor_prune.py new file mode 100644 index 00000000..24aed89c --- /dev/null +++ b/resources/tester/test_b05_corridor_prune.py @@ -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"] diff --git a/resources/tester/test_b05_corridor_router.py b/resources/tester/test_b05_corridor_router.py new file mode 100644 index 00000000..4914c384 --- /dev/null +++ b/resources/tester/test_b05_corridor_router.py @@ -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 diff --git a/resources/tester/test_b05_corridor_separation.py b/resources/tester/test_b05_corridor_separation.py new file mode 100644 index 00000000..c2d9265c --- /dev/null +++ b/resources/tester/test_b05_corridor_separation.py @@ -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 diff --git a/resources/tester/test_b05_corridor_spline.py b/resources/tester/test_b05_corridor_spline.py new file mode 100644 index 00000000..cd769d15 --- /dev/null +++ b/resources/tester/test_b05_corridor_spline.py @@ -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 diff --git a/resources/tester/test_b05_corridor_switching.py b/resources/tester/test_b05_corridor_switching.py new file mode 100644 index 00000000..c354899d --- /dev/null +++ b/resources/tester/test_b05_corridor_switching.py @@ -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"] diff --git a/resources/tester/test_b05_cut_merged_loop.py b/resources/tester/test_b05_cut_merged_loop.py new file mode 100644 index 00000000..79d62667 --- /dev/null +++ b/resources/tester/test_b05_cut_merged_loop.py @@ -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"] diff --git a/resources/tester/test_b05_design_speed.py b/resources/tester/test_b05_design_speed.py new file mode 100644 index 00000000..b66cb94c --- /dev/null +++ b/resources/tester/test_b05_design_speed.py @@ -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 diff --git a/resources/tester/test_b05_drainage_recommend.py b/resources/tester/test_b05_drainage_recommend.py new file mode 100644 index 00000000..6a3183a8 --- /dev/null +++ b/resources/tester/test_b05_drainage_recommend.py @@ -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 diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py new file mode 100644 index 00000000..0594c692 --- /dev/null +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -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)) diff --git a/resources/tester/test_b05_map_interval_spans.py b/resources/tester/test_b05_map_interval_spans.py new file mode 100644 index 00000000..1222053d --- /dev/null +++ b/resources/tester/test_b05_map_interval_spans.py @@ -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 diff --git a/resources/tester/test_b05_min_cover_guard.py b/resources/tester/test_b05_min_cover_guard.py new file mode 100644 index 00000000..5e9106aa --- /dev/null +++ b/resources/tester/test_b05_min_cover_guard.py @@ -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) diff --git a/resources/tester/test_b05_min_cover_rules.py b/resources/tester/test_b05_min_cover_rules.py new file mode 100644 index 00000000..dca2edd0 --- /dev/null +++ b/resources/tester/test_b05_min_cover_rules.py @@ -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 diff --git a/resources/tester/test_b05_patch_skirt.py b/resources/tester/test_b05_patch_skirt.py new file mode 100644 index 00000000..5e4dd9fc --- /dev/null +++ b/resources/tester/test_b05_patch_skirt.py @@ -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 diff --git a/resources/tester/test_b05_pipe_clearance_switch.py b/resources/tester/test_b05_pipe_clearance_switch.py new file mode 100644 index 00000000..7daef7d9 --- /dev/null +++ b/resources/tester/test_b05_pipe_clearance_switch.py @@ -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 diff --git a/resources/tester/test_b05_profile_basis_and_fit.py b/resources/tester/test_b05_profile_basis_and_fit.py new file mode 100644 index 00000000..ab4be26c --- /dev/null +++ b/resources/tester/test_b05_profile_basis_and_fit.py @@ -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 diff --git a/resources/tester/test_b05_protection_migration.py b/resources/tester/test_b05_protection_migration.py new file mode 100644 index 00000000..e74bf719 --- /dev/null +++ b/resources/tester/test_b05_protection_migration.py @@ -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} diff --git a/resources/tester/test_b05_stale_design_sync.py b/resources/tester/test_b05_stale_design_sync.py new file mode 100644 index 00000000..14a442d9 --- /dev/null +++ b/resources/tester/test_b05_stale_design_sync.py @@ -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 diff --git a/resources/tester/test_b05_station_merge.mjs b/resources/tester/test_b05_station_merge.mjs new file mode 100644 index 00000000..f892a0bd --- /dev/null +++ b/resources/tester/test_b05_station_merge.mjs @@ -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 — 구조물 측점과 겹친 규칙 측점만 제거"); diff --git a/resources/tester/test_b05_structures_migration.py b/resources/tester/test_b05_structures_migration.py new file mode 100644 index 00000000..c5f3bc49 --- /dev/null +++ b/resources/tester/test_b05_structures_migration.py @@ -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" diff --git a/resources/tester/test_b05_structures_migration_history.py b/resources/tester/test_b05_structures_migration_history.py new file mode 100644 index 00000000..d60fa99d --- /dev/null +++ b/resources/tester/test_b05_structures_migration_history.py @@ -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 == [] diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py new file mode 100644 index 00000000..788b4a03 --- /dev/null +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -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 diff --git a/resources/tester/test_b05_structures_repository.py b/resources/tester/test_b05_structures_repository.py new file mode 100644 index 00000000..2f389d76 --- /dev/null +++ b/resources/tester/test_b05_structures_repository.py @@ -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 diff --git a/resources/tester/test_b05_structures_router.py b/resources/tester/test_b05_structures_router.py new file mode 100644 index 00000000..ab0d2ecb --- /dev/null +++ b/resources/tester/test_b05_structures_router.py @@ -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 diff --git a/resources/tester/test_b05_structures_span_anchor.py b/resources/tester/test_b05_structures_span_anchor.py new file mode 100644 index 00000000..0e5648bc --- /dev/null +++ b/resources/tester/test_b05_structures_span_anchor.py @@ -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"} + ) diff --git a/resources/tester/test_b06_bench_cut_length.py b/resources/tester/test_b06_bench_cut_length.py new file mode 100644 index 00000000..cc436f3c --- /dev/null +++ b/resources/tester/test_b06_bench_cut_length.py @@ -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 diff --git a/resources/tester/test_b06_box_set.py b/resources/tester/test_b06_box_set.py new file mode 100644 index 00000000..7a07deca --- /dev/null +++ b/resources/tester/test_b06_box_set.py @@ -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) diff --git a/resources/tester/test_b06_bulk_designs.py b/resources/tester/test_b06_bulk_designs.py new file mode 100644 index 00000000..6d7402d9 --- /dev/null +++ b/resources/tester/test_b06_bulk_designs.py @@ -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 == [] diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py new file mode 100644 index 00000000..facbdc95 --- /dev/null +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -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) diff --git a/resources/tester/test_b06_culvert_set.py b/resources/tester/test_b06_culvert_set.py new file mode 100644 index 00000000..d5bf7c25 --- /dev/null +++ b/resources/tester/test_b06_culvert_set.py @@ -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] diff --git a/resources/tester/test_b06_culvert_sets_mirror.py b/resources/tester/test_b06_culvert_sets_mirror.py new file mode 100644 index 00000000..b92cc649 --- /dev/null +++ b/resources/tester/test_b06_culvert_sets_mirror.py @@ -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 diff --git a/resources/tester/test_b06_curve_widening.mjs b/resources/tester/test_b06_curve_widening.mjs new file mode 100644 index 00000000..70a15ba0 --- /dev/null +++ b/resources/tester/test_b06_curve_widening.mjs @@ -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/, "") + .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, "건 확인"); diff --git a/resources/tester/test_b06_curve_widening.py b/resources/tester/test_b06_curve_widening.py new file mode 100644 index 00000000..b2be95b6 --- /dev/null +++ b/resources/tester/test_b06_curve_widening.py @@ -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] diff --git a/resources/tester/test_b06_cut_slope_legal_check.py b/resources/tester/test_b06_cut_slope_legal_check.py new file mode 100644 index 00000000..34015892 --- /dev/null +++ b/resources/tester/test_b06_cut_slope_legal_check.py @@ -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, "구간별 경사가 서버 결과에서 사라졌음" diff --git a/resources/tester/test_b06_cut_slope_user_input.py b/resources/tester/test_b06_cut_slope_user_input.py new file mode 100644 index 00000000..52b4146d --- /dev/null +++ b/resources/tester/test_b06_cut_slope_user_input.py @@ -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" diff --git a/resources/tester/test_b06_design_key_split.py b/resources/tester/test_b06_design_key_split.py new file mode 100644 index 00000000..b4169835 --- /dev/null +++ b/resources/tester/test_b06_design_key_split.py @@ -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)}" diff --git a/resources/tester/test_b06_detail_options.py b/resources/tester/test_b06_detail_options.py new file mode 100644 index 00000000..6a9af258 --- /dev/null +++ b/resources/tester/test_b06_detail_options.py @@ -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)) diff --git a/resources/tester/test_b06_ditch_choice.py b/resources/tester/test_b06_ditch_choice.py new file mode 100644 index 00000000..c0c9324e --- /dev/null +++ b/resources/tester/test_b06_ditch_choice.py @@ -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 diff --git a/resources/tester/test_b06_ditch_split.py b/resources/tester/test_b06_ditch_split.py new file mode 100644 index 00000000..71fe1623 --- /dev/null +++ b/resources/tester/test_b06_ditch_split.py @@ -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 diff --git a/resources/tester/test_b06_extra_shift_floor.py b/resources/tester/test_b06_extra_shift_floor.py new file mode 100644 index 00000000..f9312654 --- /dev/null +++ b/resources/tester/test_b06_extra_shift_floor.py @@ -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"], "손대지 않은 벽에도 하한을 알림" diff --git a/resources/tester/test_b06_ford_set.py b/resources/tester/test_b06_ford_set.py new file mode 100644 index 00000000..7491de39 --- /dev/null +++ b/resources/tester/test_b06_ford_set.py @@ -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 diff --git a/resources/tester/test_b06_missing_stations.py b/resources/tester/test_b06_missing_stations.py new file mode 100644 index 00000000..b6b0a6c4 --- /dev/null +++ b/resources/tester/test_b06_missing_stations.py @@ -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 diff --git a/resources/tester/test_b06_pavement_and_ford.py b/resources/tester/test_b06_pavement_and_ford.py new file mode 100644 index 00000000..f8d82c7a --- /dev/null +++ b/resources/tester/test_b06_pavement_and_ford.py @@ -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`에 있다. diff --git a/resources/tester/test_b06_pipe_length.py b/resources/tester/test_b06_pipe_length.py new file mode 100644 index 00000000..b1fbefd2 --- /dev/null +++ b/resources/tester/test_b06_pipe_length.py @@ -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 diff --git a/resources/tester/test_b06_recompute_keeps_user_values.py b/resources/tester/test_b06_recompute_keeps_user_values.py new file mode 100644 index 00000000..c3d44b29 --- /dev/null +++ b/resources/tester/test_b06_recompute_keeps_user_values.py @@ -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, "포장 구간 재계산은 포장으로 켜야 함" diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py new file mode 100644 index 00000000..59a1a75a --- /dev/null +++ b/resources/tester/test_b06_save_channels.py @@ -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)가 줄었음" diff --git a/resources/tester/test_b06_save_fills_empty_design.py b/resources/tester/test_b06_save_fills_empty_design.py new file mode 100644 index 00000000..3e7550dd --- /dev/null +++ b/resources/tester/test_b06_save_fills_empty_design.py @@ -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 diff --git a/resources/tester/test_b06_server_calc_node.mjs b/resources/tester/test_b06_server_calc_node.mjs new file mode 100644 index 00000000..ef17d0ff --- /dev/null +++ b/resources/tester/test_b06_server_calc_node.mjs @@ -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 — 구조물 없는 측점은 면적을 덮지 않는다"); diff --git a/resources/tester/test_b06_spoil_fill_allocation.py b/resources/tester/test_b06_spoil_fill_allocation.py new file mode 100644 index 00000000..fa05bc7c --- /dev/null +++ b/resources/tester/test_b06_spoil_fill_allocation.py @@ -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) diff --git a/resources/tester/test_b06_stored_standard_reaches_browser.py b/resources/tester/test_b06_stored_standard_reaches_browser.py new file mode 100644 index 00000000..2e820319 --- /dev/null +++ b/resources/tester/test_b06_stored_standard_reaches_browser.py @@ -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\(\"std-cross\", projectId\) === null\)", source + ) + assert guard, "세션이 있어도 덮어쓰는 모양임" + # 두 갈래(캐시·새 조회) 모두 지나야 새 탭에서도 선다. + assert source.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움" diff --git a/resources/tester/test_b06_structure_areas.mjs b/resources/tester/test_b06_structure_areas.mjs new file mode 100644 index 00000000..75c9a2c8 --- /dev/null +++ b/resources/tester/test_b06_structure_areas.mjs @@ -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)}㎡`, +); diff --git a/resources/tester/test_b06_structure_span.py b/resources/tester/test_b06_structure_span.py new file mode 100644 index 00000000..4fefc011 --- /dev/null +++ b/resources/tester/test_b06_structure_span.py @@ -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 diff --git a/resources/tester/test_b06_structure_walls_mirror.py b/resources/tester/test_b06_structure_walls_mirror.py new file mode 100644 index 00000000..ca615abb --- /dev/null +++ b/resources/tester/test_b06_structure_walls_mirror.py @@ -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" diff --git a/resources/tester/test_b06_two_stage_slope_kept.py b/resources/tester/test_b06_two_stage_slope_kept.py new file mode 100644 index 00000000..a0a7ebc9 --- /dev/null +++ b/resources/tester/test_b06_two_stage_slope_kept.py @@ -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"] diff --git a/resources/tester/test_b06_user_touched_keys.py b/resources/tester/test_b06_user_touched_keys.py new file mode 100644 index 00000000..37665ae6 --- /dev/null +++ b/resources/tester/test_b06_user_touched_keys.py @@ -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, "라우터에 목록 사본이 남아 있음" diff --git a/resources/tester/test_b07_masshaul_basin.py b/resources/tester/test_b07_masshaul_basin.py new file mode 100644 index 00000000..84b5665d --- /dev/null +++ b/resources/tester/test_b07_masshaul_basin.py @@ -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" diff --git a/resources/tester/test_b07_signature_vector.py b/resources/tester/test_b07_signature_vector.py new file mode 100644 index 00000000..c39aa4b1 --- /dev/null +++ b/resources/tester/test_b07_signature_vector.py @@ -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 = ( + '' + '' + '' + "" +) + + +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(" 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 diff --git a/resources/tester/test_berm_knee_progress.py b/resources/tester/test_berm_knee_progress.py new file mode 100644 index 00000000..cc6c4264 --- /dev/null +++ b/resources/tester/test_berm_knee_progress.py @@ -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 diff --git a/resources/tester/test_cad_rect_harvest.mjs b/resources/tester/test_cad_rect_harvest.mjs new file mode 100644 index 00000000..71c0ee8d --- /dev/null +++ b/resources/tester/test_cad_rect_harvest.mjs @@ -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건"); diff --git a/resources/tester/test_clip_line_to_box.py b/resources/tester/test_clip_line_to_box.py new file mode 100644 index 00000000..92383b14 --- /dev/null +++ b/resources/tester/test_clip_line_to_box.py @@ -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 diff --git a/resources/tester/test_common_util_crs.py b/resources/tester/test_common_util_crs.py new file mode 100644 index 00000000..3f29495f --- /dev/null +++ b/resources/tester/test_common_util_crs.py @@ -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" diff --git a/resources/tester/test_cover_template.py b/resources/tester/test_cover_template.py new file mode 100644 index 00000000..3d0e2e4f --- /dev/null +++ b/resources/tester/test_cover_template.py @@ -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 diff --git a/resources/tester/test_cross_axes_zoom.mjs b/resources/tester/test_cross_axes_zoom.mjs new file mode 100644 index 00000000..e7574f9f --- /dev/null +++ b/resources/tester/test_cross_axes_zoom.mjs @@ -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"); diff --git a/resources/tester/test_cross_pan_bounds.mjs b/resources/tester/test_cross_pan_bounds.mjs new file mode 100644 index 00000000..4364ee69 --- /dev/null +++ b/resources/tester/test_cross_pan_bounds.mjs @@ -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"); diff --git a/resources/tester/test_cross_y_ticks.mjs b/resources/tester/test_cross_y_ticks.mjs new file mode 100644 index 00000000..917b6d4c --- /dev/null +++ b/resources/tester/test_cross_y_ticks.mjs @@ -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"); diff --git a/resources/tester/test_crs_identify.py b/resources/tester/test_crs_identify.py new file mode 100644 index 00000000..8cc66660 --- /dev/null +++ b/resources/tester/test_crs_identify.py @@ -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 # 보정 모수가 살아 있다 diff --git a/resources/tester/test_excavation_mirror.py b/resources/tester/test_excavation_mirror.py new file mode 100644 index 00000000..415cbdf6 --- /dev/null +++ b/resources/tester/test_excavation_mirror.py @@ -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) diff --git a/resources/tester/test_ford_surface_drop.py b/resources/tester/test_ford_surface_drop.py new file mode 100644 index 00000000..451eac60 --- /dev/null +++ b/resources/tester/test_ford_surface_drop.py @@ -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 diff --git a/resources/tester/test_ford_wall_vertical.mjs b/resources/tester/test_ford_wall_vertical.mjs new file mode 100644 index 00000000..9686f8ef --- /dev/null +++ b/resources/tester/test_ford_wall_vertical.mjs @@ -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"); diff --git a/resources/tester/test_haul_state_natural.py b/resources/tester/test_haul_state_natural.py new file mode 100644 index 00000000..30980069 --- /dev/null +++ b/resources/tester/test_haul_state_natural.py @@ -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) diff --git a/resources/tester/test_initial_snapshot.py b/resources/tester/test_initial_snapshot.py new file mode 100644 index 00000000..1cecf52f --- /dev/null +++ b/resources/tester/test_initial_snapshot.py @@ -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) == [] # 두 번 불러도 조용히 끝난다 diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py new file mode 100644 index 00000000..ed13f663 --- /dev/null +++ b/resources/tester/test_masshaul_single_source.py @@ -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 의 보존 필드 목록 diff --git a/resources/tester/test_masshaul_stone_deduction.py b/resources/tester/test_masshaul_stone_deduction.py new file mode 100644 index 00000000..edbd251d --- /dev/null +++ b/resources/tester/test_masshaul_stone_deduction.py @@ -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) diff --git a/resources/tester/test_nice_tick_step.mjs b/resources/tester/test_nice_tick_step.mjs new file mode 100644 index 00000000..669e5e80 --- /dev/null +++ b/resources/tester/test_nice_tick_step.mjs @@ -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"); diff --git a/resources/tester/test_pipe_point_projection.py b/resources/tester/test_pipe_point_projection.py new file mode 100644 index 00000000..827e3047 --- /dev/null +++ b/resources/tester/test_pipe_point_projection.py @@ -0,0 +1,92 @@ +"""관 지점이 어느 선으로 읽든 그 선 위에 앉는지. + +B04가 관 자리를 정한 선(계획노선 CSV)과 B05 계획선은 같은 자리를 지나면서 연장이 다르다. +누가거리만 저장하면 읽는 쪽 선에 따라 관이 3~4m 미끄러진다(2026-08-30 사용자 지적). +""" + +import sys +from pathlib import Path + +import numpy as np +from shapely.geometry import LineString, Point + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from common_util.common_util_drainage_pipes import ( # noqa: E402 + PIPE_SOURCE_STREAM, + PipePoint, + load_pipe_points_file, + route_signature, + save_pipe_points, +) +from common_util.common_util_route_geometry import RouteVertex # noqa: E402 + + +def _vertices(points) -> list[RouteVertex]: + vertices, cumulative, previous = [], 0.0, None + for x, y in points: + if previous is not None: + cumulative += float(np.hypot(x - previous[0], y - previous[1])) + vertices.append(RouteVertex(x=float(x), y=float(y), z=0.0, chainage_m=cumulative)) + previous = (x, y) + return vertices + + +def test_pipe_point_projects_onto_another_route(tmp_path, monkeypatch): + # 같은 자리를 지나지만 정점을 더 촘촘히 깔아 연장이 조금 긴 선 — 실제 B05 계획선과 같은 관계 + coarse = _vertices([(0.0, 0.0), (100.0, 0.0), (200.0, 40.0)]) + # 경로 탐색이 만든 선 — 같은 자리를 지나지만 잔물결이 있어 연장이 1%쯤 길다 + reference = LineString([(0.0, 0.0), (100.0, 0.0), (200.0, 40.0)]) + fine_xy = [(0.0, 0.0)] + for step in range(1, 40): + along = reference.interpolate(reference.length * step / 40.0) + fine_xy.append((along.x, along.y + (0.7 if step % 2 else -0.7))) + fine_xy.append((200.0, 40.0)) + fine = _vertices(fine_xy) + coarse_line = LineString([(v.x, v.y) for v in coarse]) + fine_line = LineString([(v.x, v.y) for v in fine]) + assert abs(fine_line.length - coarse_line.length) > 0.3 # 연장이 실제로 다르다 + + import common_util.common_util_drainage_pipes as pipes_module + + monkeypatch.setattr(pipes_module, "pipe_points_path", lambda _p: tmp_path / "pipe_points.json") + + pipe = PipePoint(chainage_m=130.0, source=PIPE_SOURCE_STREAM, start_m=125.0, end_m=135.0) + save_pipe_points("storage/x", route_signature(coarse), [pipe], coarse) + + stored = (tmp_path / "pipe_points.json").read_text(encoding="utf-8") + assert '"x"' in stored and '"y"' in stored # 좌표가 실제로 남는다 + + # 다른 선으로 읽는다 — 지문이 다르지만 버리지 않고 투영해 온다 + loaded = load_pipe_points_file(tmp_path / "pipe_points.json", route_signature(fine), fine) + assert loaded is not None and len(loaded) == 1 + moved = loaded[0] + + # ① 원래 자리를 유지한다 (좌표가 정본, 저장 정밀도 mm) + original = coarse_line.interpolate(130.0) + assert Point(moved.x, moved.y).distance(original) < 1e-3 + # ② 새 선 위에서 그 자리에 **가장 가까운 점**에 앉는다 (더 가까운 자리는 없다) + on_fine = fine_line.interpolate(moved.chainage_m) + assert abs(on_fine.distance(original) - original.distance(fine_line)) < 1e-3 + # ③ 누가거리는 선이 달라진 만큼 옮겨간다 + assert abs(moved.chainage_m - 130.0) > 0.1 + # ④ 구간 길이(시설 치수)는 그대로, 기준점과 함께 밀린다 + assert abs((moved.end_m - moved.start_m) - 10.0) < 1e-6 + assert abs((moved.chainage_m - moved.start_m) - 5.0) < 1e-6 + + +def test_old_file_without_coordinates_is_discarded(tmp_path, monkeypatch): + """좌표 없는 구 저장분은 이월하지 않는다 — 자리를 되짚을 근거가 없다.""" + import json + + import common_util.common_util_drainage_pipes as pipes_module + + path = tmp_path / "pipe_points.json" + path.write_text( + json.dumps({"route_signature": "old", "points": [{"chainage_m": 10.0}]}), + encoding="utf-8", + ) + monkeypatch.setattr(pipes_module, "pipe_points_path", lambda _p: path) + fine = _vertices([(0.0, 0.0), (100.0, 0.0)]) + assert load_pipe_points_file(path, route_signature(fine), fine) is None diff --git a/resources/tester/test_prefs_persist_across_tabs.py b/resources/tester/test_prefs_persist_across_tabs.py new file mode 100644 index 00000000..4cd0f4d1 --- /dev/null +++ b/resources/tester/test_prefs_persist_across_tabs.py @@ -0,0 +1,57 @@ +"""화면 취향이 **탭을 새로 열어도 남는지** — 저장소가 갈리지 않았는지. + +왜 (2026-09-07 실측) — 취향(패널 높이·접힘·유토곡선 열림)까지 세션에 있어 **탭을 새로 열거나 +브라우저를 껐다 켤 때마다 초기화**됐다. 계획서에는 「PC 를 바꾸면」으로 적혀 있었으나 실제 +불편은 훨씬 잦았다. 취향은 설계 데이터가 아니므로 브라우저 저장소에 둔다 — +**설계값·초안·계산 결과는 세션 그대로**이고 그 경계가 이 시험의 요지다. + +실측 — 옮긴 뒤 새 탭에서 취향 8개가 그대로 섰고 높이도 보존됐다(종단 438px · 표 204px). +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +_STATE = ROOT / "A00_Common" / "b_page_state.ts" +#: 키만 들고 저장소를 직접 만지는 화면 공용 부품 — 여기를 안 거치면 취향만 세션에 남는다. +_HELPERS = ( + ROOT / "ui_template" / "ui_template_resizer.ts", + ROOT / "ui_template" / "ui_template_overlay.ts", + ROOT / "ui_template" / "ui_template_overlay_drag.ts", +) + + +def test_취향만_브라우저_저장소로_간다(): + source = _STATE.read_text(encoding="utf-8") + chooser = re.search(r"function storageFor\(key: string\): Storage \{(.*?)\}", source, re.S) + assert chooser, "저장소를 고르는 자리가 없음" + body = chooser.group(1) + assert 'key.startsWith("aislo:pref:")' in body, "취향 말고 다른 통까지 가릴 수 있음" + assert "localStorage" in body and "sessionStorage" in body, "두 저장소를 다 안 씀" + + +def test_설계값은_세션_그대로다(): + """초안·결과가 브라우저 저장소로 새면 프로젝트를 옮겨도 남는다 — 그 경계를 지킨다.""" + source = _STATE.read_text(encoding="utf-8") + for bucket in ("aislo:draft:", "aislo:result:"): + assert f'startsWith("{bucket}")' not in source, f"{bucket} 까지 저장소를 갈랐음" + + +def test_화면_공용_부품도_같은_판단을_쓴다(): + for path in _HELPERS: + source = path.read_text(encoding="utf-8") + assert "ByKey(" in source, f"{path.name} 이 저장소를 직접 고르고 있음" + # 남은 날 `sessionStorage.` 직접 호출이 있으면 그 값만 세션에 남아 갈린다. + leftovers = [ + line + for line in source.splitlines() + if re.search(r"(? CRS: + """COMPD_CS(수직 CRS 결합)면 수평 성분만 꺼낸다.""" + return crs.sub_crs_list[0] if crs.is_compound else crs + + +def _fingerprint(crs: CRS) -> tuple | None: + """투영 지문 — (중앙자오선, 원점위도, 축척, FE, FN, 타원체 장반경).""" + crs = _projected(crs) + op = crs.coordinate_operation + if op is None: + return None + p = {q.name.lower(): q.value for q in op.params} + + def get(*keys): + for k in keys: + for name, value in p.items(): + if k in name: + return value + return None + + return ( + round(get("longitude of natural origin", "central meridian") or 0.0, 6), + round(get("latitude of natural origin") or 0.0, 6), + round(get("scale factor") or 1.0, 6), + round(get("false easting") or 0.0, 3), + round(get("false northing") or 0.0, 3), + round(crs.ellipsoid.semi_major_metre, 3), + ) + + +def epsg_from_prj(text: str) -> int | None: + """PRJ WKT에서 EPSG를 판별한다. 못 고르면 None (기본값으로 때우지 않는다).""" + crs = CRS.from_wkt(text) + + epsg = _projected(crs).to_epsg() + if epsg: + return epsg + + codes = [int(c) for c in _AUTHORITY.findall(text)] + for code in reversed(codes): # WKT 끝의 PROJCS AUTHORITY가 그 좌표계 자신이다 + if code in CANDIDATES: + return code + + mine = _fingerprint(crs) + if mine is None: + return None + for code in CANDIDATES: + try: + if _fingerprint(CRS.from_epsg(code)) == mine: + return code + except Exception: + continue + return None + + +def test_uploaded_prj_files() -> None: + upload = r"C:/Users/umsan/.claude/uploads/ab448e1d-3fa5-4f88-8e02-b26428c8dfc7" + try: + las = open(upload + "/a450e281-__.prj", encoding="utf-8").read() + route = open(upload + "/105c7734-______.__.2.2.prj", encoding="utf-8").read() + except FileNotFoundError as e: + pytest.skip(f"laptop 업로드 픽스처 없음 (환경 의존): {e.filename}") + assert epsg_from_prj(las) == 5176, epsg_from_prj(las) # AUTHORITY 태그로 판별 + assert epsg_from_prj(route) == 5179, epsg_from_prj(route) # 파라미터 지문으로 판별 + + +def test_standard_codes_round_trip() -> None: + for code in (5185, 5186, 5187, 5188, 5179, 5174, 5176, 32652): + wkt = CRS.from_epsg(code).to_wkt("WKT1_GDAL") + assert epsg_from_prj(wkt) == code, (code, epsg_from_prj(wkt)) + + +def test_compound_vertical_prj() -> None: + """KNGeoid24 결합 PRJ(기존 프로젝트 자료)도 수평 성분으로 판별한다.""" + import pathlib + + path = ( + pathlib.Path(__file__).resolve().parents[2] + / "storage/1/3/2f940d8a-2065-4cf6-8bf8-dc3f0af84e57/B03_FileInput/input/prj/result.prj" + ) + # 그 프로젝트가 지워진 창에서는 늘 실패한다 — 같은 파일의 다른 검사와 같은 태도로 + # 건너뛴다(2026-09-08). 자료가 있는 창에서는 그대로 판별을 검사한다. + if not path.is_file(): + pytest.skip(f"프로젝트 자료 없음 (환경 의존): {path.name}") + assert epsg_from_prj(path.read_text(encoding="utf-8")) == 5187 + + +if __name__ == "__main__": + test_uploaded_prj_files() + test_standard_codes_round_trip() + test_compound_vertical_prj() + print("OK — 3개 검사 통과") diff --git a/resources/tester/test_project_access_guard.py b/resources/tester/test_project_access_guard.py new file mode 100644 index 00000000..b8f6c68a --- /dev/null +++ b/resources/tester/test_project_access_guard.py @@ -0,0 +1,73 @@ +"""URL의 project_id가 남의 회사 것이면 막히는지 (2026-09-01 신설 가드). + +지금까지 B03~B07은 로그인·회사 소속만 보고 프로젝트 주인은 보지 않았다. 특히 B07 도각 +저장은 그 회사의 공용 양식을 덮어쓰므로 영향이 크다. DB는 읽기만 한다. +""" + +import asyncio +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from common_util.common_util_auth import require_project_access # noqa: E402 +from config.config_db import close_db_pool, get_db_pool, init_db_pool # noqa: E402 + + +class _Request: + """path_params만 있으면 되는 최소 요청 대역.""" + + def __init__(self, **path_params): + self.path_params = path_params + + +async def _checks(): + await init_db_pool() + try: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "SELECT id, company_id FROM projects WHERE deleted_at IS NULL LIMIT 1" + ) + row = await cursor.fetchone() + if not row: + return None + await _assert_guard(row[0], row[1]) + return True + finally: + await close_db_pool() + + +def test_project_access_guard(): + if asyncio.run(_checks()) is None: + pytest.skip("검사할 프로젝트가 DB에 없다") + + +async def _assert_guard(project_id, company_id): + + owner = {"role": "USER", "company_id": company_id} + other = {"role": "USER", "company_id": company_id + 1000} + admin = {"role": "SYSTEM_ADMIN", "company_id": None} + + # 같은 회사면 통과한다. + assert await require_project_access(_Request(project_id=project_id), owner) is owner + + # 다른 회사면 403. + with pytest.raises(HTTPException) as blocked: + await require_project_access(_Request(project_id=project_id), other) + assert blocked.value.status_code == 403 + + # 없는 프로젝트는 404. + with pytest.raises(HTTPException) as missing: + await require_project_access( + _Request(project_id="00000000-0000-0000-0000-000000000000"), owner + ) + assert missing.value.status_code == 404 + + # 시스템 관리자와 프로젝트 경로가 없는 요청은 그대로 지나간다. + assert await require_project_access(_Request(project_id=project_id), admin) is admin + assert await require_project_access(_Request(), other) is other diff --git a/resources/tester/test_project_crs_resolution.py b/resources/tester/test_project_crs_resolution.py new file mode 100644 index 00000000..b2fc7336 --- /dev/null +++ b/resources/tester/test_project_crs_resolution.py @@ -0,0 +1,75 @@ +"""작업 좌표계 창구(`resolve_project_crs`) 사다리 검사 (2026-09-03 일원화). + +같은 폴백 사다리가 네 곳에 서로 다른 모양으로 흩어져 있었다 — 그중 배수유역 라우터는 +노선 CSV 의 `crs_epsg` **라벨**을 변환 좌표에 그대로 써서 좌표가 1,500km 밖으로 나간 +사례가 있었다(2026-09-01 용화: 라벨 5179 / 실제 5176). +""" + +from pathlib import Path + +from pyproj import CRS + +from common_util.common_util_crs import project_prj_crs, resolve_project_crs + + +def _project_with_prj(tmp_path: Path, epsg: int) -> Path: + prj_dir = tmp_path / "B03_FileInput" / "input" / "prj" + prj_dir.mkdir(parents=True) + (prj_dir / "terrain.prj").write_text(CRS.from_epsg(epsg).to_wkt(), encoding="utf-8") + return tmp_path + + +def test_route_crs_input_wins(tmp_path): + """① 노선 `crs_input` — 이미 작업 좌표계로 맞춰 둔 값이 가장 먼저다.""" + root = _project_with_prj(tmp_path, 5187) + assert ( + resolve_project_crs(root, route_crs_input="EPSG:5176", file_label_epsg=5179, db_epsg=5186) + == "EPSG:5176" + ) + + +def test_file_label_only_when_given(tmp_path): + """② 원본 파일 좌표를 읽는 자리만 파일 라벨을 넘긴다 — 지형 PRJ 보다 앞선다.""" + root = _project_with_prj(tmp_path, 5187) + assert resolve_project_crs(root, file_label_epsg=5179) == "EPSG:5179" + # 라벨을 안 넘기면 지형 PRJ 가 이긴다(변환이 끝난 좌표를 다루는 자리). + assert resolve_project_crs(root) == "EPSG:5187" + + +def test_terrain_prj_is_work_crs(tmp_path): + """③ 지형 PRJ 가 작업 좌표계 정본(2026-09-03 사용자 결정: 현행 유지).""" + root = _project_with_prj(tmp_path, 5186) + assert project_prj_crs(root) == "EPSG:5186" + assert resolve_project_crs(root, db_epsg=5179) == "EPSG:5186" + + +def test_db_epsg_then_default(tmp_path): + """④·⑤ PRJ 가 없으면 DB 좌표계, 그것도 없으면 중부원점.""" + (tmp_path / "B03_FileInput" / "input").mkdir(parents=True) + assert project_prj_crs(tmp_path) is None + assert resolve_project_crs(tmp_path, db_epsg=5176) == "EPSG:5176" + assert resolve_project_crs(tmp_path) == "EPSG:5186" + + +class _Grid: + """트림 진단용 최소 sampler — bbox 만 있으면 된다.""" + + def __init__(self, xs, ys): + self.x = xs + self.y = ys + + +def test_trim_wipeout_logs_both_bboxes(caplog): + """트림이 노선을 통째로 지우면 두 bbox 를 수치로 남긴다(좌표계 판별용).""" + from common_util.common_util_route_geometry import _log_trim_wipeout + + with caplog.at_level("WARNING"): + _log_trim_wipeout( + [(1141951.0, 1723000.0), (1143784.0, 1725000.0)], + _Grid([208402.0, 209637.0], [340000.0, 341000.0]), + "EPSG:5176", + ) + message = caplog.text + assert "1141951~1143784" in message + assert "208402~209637" in message + assert "EPSG:5176" in message diff --git a/resources/tester/test_regeneration_extra_stations.py b/resources/tester/test_regeneration_extra_stations.py new file mode 100644 index 00000000..91ba55c9 --- /dev/null +++ b/resources/tester/test_regeneration_extra_stations.py @@ -0,0 +1,27 @@ +"""재생성이 구조물(비정규) 측점을 유지하는지 — B06 반폭 확대로 구조물이 사라진 건(2026-08-23). + +2026-08-28 갱신: 공급자가 라우터의 옵션 스냅샷(`_extra_stations`)에서 +`run_section_generation`의 `resolve_extra_stations`(정본 파일)로 옮겨졌다. 스냅샷은 +writer가 자기참조라 늘 비어 있었고(실측: 측점 4건이 화면에 있는데 DB는 `[]`), 그 값을 +되먹이면 B04에서 지운 관이 부활한다. 라우터는 더 이상 공급자가 아니다. +""" + +from B06_Section.B06_Section_Router import _regeneration_options + + +def test_regeneration_options_keep_stored_values_and_swap_half_width(): + stored = { + "station_interval_m": 20.0, + "cross_half_width_m": 20.0, + "extra_stations": [[84.3, "배수관 1"], [159.7, "세월교"]], + } + options = _regeneration_options(stored, None, 25.0) + assert options.cross_half_width_m == 25.0 # 요청 반폭만 교체 + assert options.station_interval_m == 20.0 + + +def test_router_is_no_longer_an_extra_station_supplier(): + """옵션 스냅샷이 남아 있어도 라우터는 그것을 측점으로 되먹이지 않는다.""" + stored = {"extra_stations": [[84.3, "배수관 1"]]} + assert _regeneration_options(stored, None, 25.0).extra_stations == () + assert _regeneration_options(None, None, 25.0).extra_stations == () diff --git a/resources/tester/test_revet_link_level.mjs b/resources/tester/test_revet_link_level.mjs new file mode 100644 index 00000000..f543e7da --- /dev/null +++ b/resources/tester/test_revet_link_level.mjs @@ -0,0 +1,95 @@ +// 기슭막이 연동 하이라이트 짝짓기 + 경사 해제(수평) d 역산 자체검증 — 로직 복제. +// 2026-08-30 사용자 확정: ① 한 기슭막이는 연장 안 모든 횡단도에서 같이 강조된다 +// ② 경사를 풀면 벽 상단 표고가 소유 측점 값과 같아진다(차이는 노견 확장이 흡수). +// 실행: node tmp/tests/test_revet_link_level.mjs +import assert from "node:assert/strict"; + +const FILL_SLOPE_RATIO_MIN = 1.2; + +/* ── 복제: spanCovers / tierSpanOf / wallStandsAt ────────────────────── */ +function spanCovers(span, deltaM) { + return deltaM < 0 ? -deltaM <= span.beforeM + 1e-9 : deltaM <= span.afterM + 1e-9; +} +function revetSpanOfSpec(spec) { + const length = Math.max(spec?.revet_length_m ?? 10, 0.1); + const beforeM = Math.max(spec?.revet_before_m ?? length / 2, 0); + const afterM = Math.max(spec?.revet_after_m ?? Math.max(length - beforeM, 0), 0); + return { beforeM, afterM }; +} +function tierSpanOf(section, key) { + const stored = section.design?.extra_spans?.[key]; + if (stored) return { beforeM: Math.max(stored.before_m, 0), afterM: Math.max(stored.after_m, 0) }; + const spec = key.startsWith("bextra") ? section.culvert?.inlet : section.culvert?.outlet; + if (!spec) return { beforeM: 5, afterM: 5 }; + return revetSpanOfSpec(spec); +} +function wallStandsAt(owner, section, key) { + const deltaM = section.chainage_m - owner.chainage_m; + if (Math.abs(deltaM) < 0.01) return true; + const spec = owner.culvert; + if (!spec) return false; + if (key.startsWith("extra") || key.startsWith("bextra")) { + return spanCovers(tierSpanOf(owner, key), deltaM); + } + const side = key === "inlet" ? spec.inlet : key === "outlet" ? spec.outlet : null; + if (!side) return false; + if (side.structure === "집수정" && !spec.hidden_pipe) return false; + return spanCovers(revetSpanOfSpec(side), deltaM); +} + +/* ── ① 하이라이트 짝짓기 ─────────────────────────────────────────────── */ +const owner = { + chainage_m: 200, + culvert: { + inlet: { structure: "기슭막이", revet_length_m: 10, revet_before_m: 5, revet_after_m: 5 }, + outlet: { structure: "기슭막이", revet_length_m: 10, revet_before_m: 5, revet_after_m: 5 }, + }, + design: { extra_spans: { extra0: { length_m: 20, before_m: 12, after_m: 8 } } }, +}; +const at = (chainage) => ({ chainage_m: chainage }); + +// 기준벽 — 전 5·후 5 안의 측점에서만 선다. +assert.equal(wallStandsAt(owner, at(200), "outlet"), true, "소유 측점은 언제나"); +assert.equal(wallStandsAt(owner, at(205), "outlet"), true, "후 5m 끝"); +assert.equal(wallStandsAt(owner, at(195), "outlet"), true, "전 5m 끝"); +assert.equal(wallStandsAt(owner, at(206), "outlet"), false, "연장 밖"); +// 다단은 **단별 구간값**을 따른다 — 기준벽보다 길 수 있다(전 12·후 8). +assert.equal(wallStandsAt(owner, at(190), "extra0"), true, "다단 전 12m"); +assert.equal(wallStandsAt(owner, at(208), "extra0"), true, "다단 후 8m"); +assert.equal(wallStandsAt(owner, at(209), "extra0"), false, "다단 연장 밖"); +// 단별 값이 없는 단은 소유 벽 연장을 상속한다. +assert.equal(wallStandsAt(owner, at(205), "extra1"), true); +assert.equal(wallStandsAt(owner, at(206), "extra1"), false); +// 집수정이 선 쪽은 소유 측점 하나뿐 — 옆 카드에는 없다(강조도 없다). +const basinOwner = { + chainage_m: 200, + culvert: { + inlet: { structure: "집수정", basin_length_m: 2, basin_before_m: 1, basin_after_m: 1 }, + outlet: { structure: "기슭막이" }, + }, +}; +assert.equal(wallStandsAt(basinOwner, at(201), "inlet"), false, "집수정은 옆 측점에 안 선다"); +assert.equal(wallStandsAt(basinOwner, at(201), "outlet"), true); + +/* ── ② 경사 해제(수평) d 역산 ────────────────────────────────────────── */ +// 배관 벽 자리 규칙: 상단 = 노견 표고 − d/1.2(placePipeWall). 이 측점 d를 +// `소유 d + 1.2 × dz`로 밀면 상단 표고가 소유 측점 값 그대로다. +const levelDrop = (ownerD, dz) => ownerD + FILL_SLOPE_RATIO_MIN * dz; +const topOf = (edgeElevation, d) => edgeElevation - d / FILL_SLOPE_RATIO_MIN; + +const edgeOwner = 100.0; +const ownerD = 4.8; +const topOwner = topOf(edgeOwner, ownerD); +for (const dz of [-1.5, -0.4, 0, 0.4, 2.0]) { + const edgeHere = edgeOwner + dz; + const here = topOf(edgeHere, levelDrop(ownerD, dz)); + assert.ok(Math.abs(here - topOwner) < 1e-9, `dz=${dz} 상단 어긋남: ${here} != ${topOwner}`); +} +// 경사를 켜 두면(보정 없음) 벽은 종단을 탄다 — 상단이 dz만큼 따라 움직인다. +const sloped = topOf(edgeOwner + 1.2, ownerD); +assert.ok(Math.abs(sloped - topOwner - 1.2) < 1e-9, "경사 반영이면 상단이 계획고를 따라간다"); +// 이 측점 노견이 소유 벽 상단보다 낮으면 d가 음수 — 기하가 0에서 자른다(노견까지만). +const dNegative = levelDrop(ownerD, -(ownerD / FILL_SLOPE_RATIO_MIN) - 1); +assert.ok(dNegative < 0, "노견보다 위로는 못 올라간다 — 0에서 잘릴 요청"); + +console.log("test_revet_link_level.mjs OK"); diff --git a/resources/tester/test_revet_span_source.py b/resources/tester/test_revet_span_source.py new file mode 100644 index 00000000..c1d4f2f7 --- /dev/null +++ b/resources/tester/test_revet_span_source.py @@ -0,0 +1,67 @@ +"""독립 기슭막이 구간값(길이·전/후)이 B06 조정창 키에서 읽히는지 — 2026-08-28 리셋 버그. + +조정창은 `{role}_revet_length_m` 등에 저장한다(SPAN_OPTION_KEYS). 백엔드가 전용 키 +(`length_m`)만 읽던 동안 조정값이 매번 되돌아왔다. +""" + +from B06_Section.B06_Section_Engine_Culvert import _revet_set + + +def test_panel_span_keys_win(): + """조정창이 쓴 유출 구간값이 스펙에 그대로 실린다.""" + options = { + "side": "양쪽", + "tiers": 1, + "form": "돌쌓기(메)", + "length_m": 10, + "height_m": 2.5, + "before_m": 5, + "after_m": 5, + "outlet_revet_length_m": 10.5, + "outlet_revet_before_m": 5.5, + "outlet_revet_after_m": 5, + } + spec = _revet_set(options) + assert spec["outlet"]["revet_length_m"] == 10.5 + assert spec["outlet"]["revet_before_m"] == 5.5 + assert spec["outlet"]["revet_after_m"] == 5 + # 유입은 조정 전이라 폼 값(폴백)을 그대로 쓴다 — 배관과 같은 역할별 독립. + assert spec["inlet"]["revet_length_m"] == 10 + assert spec["inlet"]["revet_before_m"] == 5 + # 형태·높이는 역할 키가 없으면 폼 한 벌을 공유한다. + assert spec["outlet"]["revet_form"] == "돌쌓기(메)" + assert spec["outlet"]["revet_height_m"] == 2.5 + + +def test_legacy_only_options_still_read(): + """옛 전용 키만 있는 저장분도 그대로 읽힌다(호환).""" + spec = _revet_set({"form": "찰쌓기", "length_m": 8, "height_m": 2, "before_m": 3, "after_m": 5}) + for role in ("inlet", "outlet"): + assert spec[role]["revet_length_m"] == 8 + assert spec[role]["revet_before_m"] == 3 + assert spec[role]["revet_after_m"] == 5 + assert spec[role]["revet_form"] == "찰쌓기" + + +def test_role_keys_only(): + """B05 폼이 새로 쓰는 역할 키 한 벌만 있어도 양쪽 벽이 선다.""" + spec = _revet_set( + { + "side": "좌", + "inlet_revet_form": "돌쌓기(메)", + "inlet_revet_length_m": 12, + "inlet_revet_height_m": 2.5, + "inlet_revet_before_m": 6, + "inlet_revet_after_m": 6, + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_length_m": 12, + "outlet_revet_height_m": 2.5, + "outlet_revet_before_m": 6, + "outlet_revet_after_m": 6, + } + ) + assert spec["side"] == "좌" + assert spec["hidden_pipe"] is True + for role in ("inlet", "outlet"): + assert spec[role]["revet_length_m"] == 12 + assert spec[role]["revet_height_m"] == 2.5 diff --git a/resources/tester/test_revet_wall_path.mjs b/resources/tester/test_revet_wall_path.mjs new file mode 100644 index 00000000..113773e0 --- /dev/null +++ b/resources/tester/test_revet_wall_path.mjs @@ -0,0 +1,134 @@ +// 기슭막이 벽 경로선(B05_Profile_UI_Corridor_Structures_Path) 자체검증 — 로직 복제. +// 연동을 푼 측점이 있으면 벽이 노선과 평행이 아니라 자기 경로를 따라간다(2026-08-30). +// 실행: node tmp/tests/test_revet_wall_path.mjs +import assert from "node:assert/strict"; + +/* ── 복제: buildWallPath / splineValue / polygonAt ───────────────────── */ +function wallPolygonOf(layout, key) { + const wall = key.startsWith("bextra") + ? layout.basinExtras[Number(key.slice(6))] + : key.startsWith("extra") + ? layout.extraWalls[Number(key.slice(5))] + : layout.walls.find((candidate) => candidate.role === key); + if (!wall || wall.points.length < 3) return null; + return wall.points.map((point) => [point.offset, point.elevation]); +} + +function buildWallPath(base, others, key) { + const path = [base]; + for (const other of others) { + const polygon = wallPolygonOf(other.layout, key); + if (!polygon || polygon.length !== base.polygon.length) continue; + path.push({ at: other.at, polygon }); + } + if (path.length < 2) return null; + return path.sort((a, b) => a.at - b.at); +} + +function splineValue(values, ats, at) { + const last = values.length - 1; + if (last < 1) return values[0]; + if (at <= ats[0]) return values[0]; + if (at >= ats[last]) return values[last]; + let i = 0; + while (i < last - 1 && ats[i + 1] < at) i += 1; + const span = ats[i + 1] - ats[i]; + const t = span <= 1e-9 ? 0 : (at - ats[i]) / span; + const p0 = i > 0 ? values[i - 1] : values[i]; + const p1 = values[i]; + const p2 = values[i + 1]; + const p3 = i + 2 <= last ? values[i + 2] : values[i + 1]; + const t2 = t * t; + const t3 = t2 * t; + return ( + 0.5 * + (2 * p1 + + (-p0 + p2) * t + + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + + (-p0 + 3 * p1 - 3 * p2 + p3) * t3) + ); +} + +function polygonAt(path, at) { + const ats = path.map((control) => control.at); + return path[0].polygon.map((_vertex, index) => [ + splineValue( + path.map((control) => control.polygon[index][0]), + ats, + at, + ), + splineValue( + path.map((control) => control.polygon[index][1]), + ats, + at, + ), + ]); +} + +/* ── 시험 자료 — 사각 벽 하나. 소유 측점 200, 연동 해제 측점 205(0.5m 바깥). ── */ +const wall = (offset) => ({ + role: "outlet", + points: [ + { offset, elevation: 10 }, + { offset, elevation: 12 }, + { offset: offset + 0.5, elevation: 12 }, + { offset: offset + 0.5, elevation: 10 }, + ], +}); +const base = { at: 200, polygon: wallPolygonOf({ walls: [wall(3)] }, "outlet") }; +const others = [{ at: 205, layout: { walls: [wall(3.5)], extraWalls: [], basinExtras: [] } }]; + +const path = buildWallPath(base, others, "outlet"); +assert.ok(path, "제어점 2개면 경로선이 생겨야 한다"); +assert.equal(path.length, 2); +assert.deepEqual( + path.map((control) => control.at), + [200, 205], + "제어점은 누가거리 오름차순", +); + +// 끝점은 그 측점 값 그대로. +assert.equal(polygonAt(path, 200)[0][0], 3); +assert.equal(polygonAt(path, 205)[0][0], 3.5); +// 범위 밖은 끝값 유지(외삽 금지 — 벽이 연장 밖으로 새면 안 된다). +assert.equal(polygonAt(path, 190)[0][0], 3); +assert.equal(polygonAt(path, 220)[0][0], 3.5); +// 사이는 두 값 사이(제어점 2개 = 선형). +const mid = polygonAt(path, 202.5)[0][0]; +assert.ok(Math.abs(mid - 3.25) < 1e-9, `중간 보간 어긋남: ${mid}`); +// 표고는 두 측점이 같으므로 보간해도 그대로. +assert.equal(polygonAt(path, 202.5)[0][1], 10); +// 꼭짓점 수는 링마다 같아야 로프트가 된다. +assert.equal(polygonAt(path, 202.5).length, base.polygon.length); + +// 제어점 3개 — 가운데가 튀면 스플라인이 그 값을 지난다(경로선이 측점을 통과). +const three = buildWallPath(base, [ + { at: 203, layout: { walls: [wall(4)], extraWalls: [], basinExtras: [] } }, + { at: 205, layout: { walls: [wall(3.5)], extraWalls: [], basinExtras: [] } }, +], "outlet"); +assert.equal(three.length, 3); +assert.equal(polygonAt(three, 203)[0][0], 4, "제어 측점 자리는 그 측점 값 그대로"); + +// 꼭짓점 수가 다른 측점은 제어점에서 빠진다(로프트 토폴로지 보호). +const ragged = buildWallPath(base, [ + { + at: 205, + layout: { + walls: [{ role: "outlet", points: wall(3.5).points.slice(0, 3) }], + extraWalls: [], + basinExtras: [], + }, + }, +], "outlet"); +assert.equal(ragged, null, "꼭짓점 수가 어긋나면 경로선 없이 소유 폴리곤 고정"); + +// 다단 키도 같은 규칙으로 짝짓는다. +const tierBase = { at: 200, polygon: wallPolygonOf({ walls: [], extraWalls: [wall(5)] }, "extra0") }; +const tierPath = buildWallPath( + tierBase, + [{ at: 205, layout: { walls: [], extraWalls: [wall(5.4)], basinExtras: [] } }], + "extra0", +); +assert.ok(tierPath && Math.abs(polygonAt(tierPath, 205)[0][0] - 5.4) < 1e-9); + +console.log("test_revet_wall_path.mjs OK"); diff --git a/resources/tester/test_revetment_stone_fields.py b/resources/tester/test_revetment_stone_fields.py new file mode 100644 index 00000000..389749fe --- /dev/null +++ b/resources/tester/test_revetment_stone_fields.py @@ -0,0 +1,94 @@ +"""기슭막이 등록부 칸 둘 — 뒷길이·돌 종류 (2026-09-09). + +⚠⚠ 왜 있나 — 칸이 없어 `_back_length` 가 **조용히 45㎝ 기본값으로 돌고** 있었다. + 같은 자리에서 예전에 **키 이름 어긋남 사고**(`stone_back_length_cm` 를 읽어 저장값이 + 영영 안 닿음)가 있었으므로 **이름과 글자를 대조해 잠근다.** +⚠ 기본값을 두지 않는다 — 값이 없으면 「기본값으로 섰음」이 드러나야 한다. +""" + +from __future__ import annotations + +import json +import shutil +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 B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + BACK_LENGTH_KEYS, + STONE_KIND_OPTION, + load_stone_kind_table, +) +from common_util.common_util_drainage_pipes import ( # noqa: E402 + PipePoint, + load_pipe_points, + save_pipe_points, +) + +REGISTRY = json.loads( + (PROJECT_ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") +) + + +def _options(type_id: str) -> dict[str, dict]: + entry = next(item for item in REGISTRY["types"] if item["type_id"] == type_id) + return {option["key"]: option for option in entry["options"]} + + +def test_기슭막이에_칸_둘이_있다() -> None: + options = _options("revetment") + assert "back_len_cm" in options, "뒷길이 칸이 없으면 45㎝ 기본값으로 조용히 돈다" + assert STONE_KIND_OPTION in options + + +def test_엔진이_읽는_이름과_같다() -> None: + """이름이 어긋나면 저장값이 영영 안 닿는다 — 예전 사고 그대로.""" + options = _options("revetment") + assert BACK_LENGTH_KEYS[0] in options + assert STONE_KIND_OPTION in options + + +def test_고를_수_있는_값이_품셈표와_글자까지_같다() -> None: + options = _options("revetment") + table = load_stone_kind_table() + assert options["back_len_cm"]["choices"] == [str(value) for value in table["back_lengths_cm"]] + kinds = [key for key in (table.get("backfill_ratio_of_back_length") or {}) if key != "note"] + assert options[STONE_KIND_OPTION]["choices"] == kinds + + +def test_기본값을_두지_않는다() -> None: + options = _options("revetment") + assert options["back_len_cm"]["default"] is None + assert options[STONE_KIND_OPTION]["default"] is None + + +def test_돌쌓기와_같은_값을_쓴다() -> None: + """돌쌓기(찰·메)와 갈래가 갈리면 같은 돌인데 수량이 달라진다.""" + for type_id in ("masonry_wet", "masonry_dry"): + other = _options(type_id) + revet = _options("revetment") + assert other["back_len_cm"]["choices"] == revet["back_len_cm"]["choices"] + assert other[STONE_KIND_OPTION]["choices"] == revet[STONE_KIND_OPTION]["choices"] + + +def test_저장했다_되읽으면_그대로_있다() -> None: + """정본 파일을 실제로 오간다 — 관 지점 `options` 에 담겨 나가고 들어온다. + + ⚠ 저장 경로는 `storage/` 아래여야 한다(경로 가드). 시험용 자리를 쓰고 지운다. + """ + stored = "storage/tmp/test_revet_fields" + target = PROJECT_ROOT / stored + try: + point = PipePoint(chainage_m=85.05, source="spacing") + point.options = {"back_len_cm": "55", "stone_kind": "견치돌", "facility": "revetment"} + assert save_pipe_points(stored, "sig-1", [point]) == 1 + + loaded = load_pipe_points(stored, "sig-1") + assert loaded is not None and len(loaded) == 1 + assert loaded[0].options["back_len_cm"] == "55" + assert loaded[0].options["stone_kind"] == "견치돌" + finally: + shutil.rmtree(target, ignore_errors=True) diff --git a/resources/tester/test_route_direct_link.py b/resources/tester/test_route_direct_link.py new file mode 100644 index 00000000..8e88f6cb --- /dev/null +++ b/resources/tester/test_route_direct_link.py @@ -0,0 +1,78 @@ +"""근거리 제어점 직결 — 조밀 기준선이 격자 탐색에 안 틀어지는지 검증.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import numpy as np +import pytest + +import B05_Profile.B05_Profile_Engine_Solver as S + + +@pytest.fixture() +def fake_project(tmp_path: Path) -> Path: + """1m 해상도 완만한 합성 DTM을 가진 가짜 프로젝트 루트.""" + models = tmp_path / "B04_PreProcess" / "models" + models.mkdir(parents=True) + n = 61 + x = np.arange(n, dtype=np.float64) + y = np.arange(n, dtype=np.float64) + xx, yy = np.meshgrid(x, y) + z = 100.0 + 0.02 * xx + 0.01 * yy # 경사 2% — 제약에 안 걸린다 + np.savez_compressed( + models / "dtm_test_smooth.npz", + x=x, + y=y, + z=z, + valid_mask=np.ones_like(z, dtype=bool), + ) + return tmp_path + + +def _solve(project_root: Path, pts: list[dict]) -> list[list[float]]: + points_data = { + "bp": pts[0], + "ep": pts[-1], + "cp": [dict(p, order=i + 1) for i, p in enumerate(pts[1:-1])], + } + result = S.solve_optimal_route(project_root, "test", True, points_data, {}, "dtm") + return result["polyline"] + + +def test_dense_baseline_preserved(fake_project: Path) -> None: + """3m 간격 조밀 제어점(문턱 4m 이하) — 평면이 원좌표 그대로, 끼어든 점 없음.""" + rng = np.random.default_rng(7) + ts = np.arange(5.0, 50.0, 2.0) + pts = [ + {"x": float(t + rng.uniform(-0.3, 0.3)), "y": float(t * 0.7 + rng.uniform(-0.3, 0.3))} + for t in ts + ] + poly = _solve(fake_project, pts) + assert len(poly) == len(pts) + for got, want in zip(poly, pts): + assert got[0] == pytest.approx(want["x"], abs=1e-9) + assert got[1] == pytest.approx(want["y"], abs=1e-9) + + +def test_same_cell_pair_not_merged(fake_project: Path) -> None: + """같은 칸에 스냅되는 초근접 정점 쌍도 안 합쳐지고 둘 다 남는다.""" + pts = [ + {"x": 10.0, "y": 10.0}, + {"x": 10.6, "y": 10.3}, # 10.0과 같은 2m 칸 + {"x": 13.0, "y": 11.8}, + {"x": 16.0, "y": 13.2}, + ] + poly = _solve(fake_project, pts) + assert len(poly) == len(pts) + assert [p[:2] for p in poly] == [[p["x"], p["y"]] for p in pts] + + +def test_sparse_pair_still_searched(fake_project: Path) -> None: + """문턱(2×셀=4m)을 넘는 먼 구간은 종전대로 격자 탐색 — 중간점이 생긴다.""" + pts = [{"x": 5.0, "y": 5.0}, {"x": 45.0, "y": 40.0}] + poly = _solve(fake_project, pts) + assert len(poly) > 2 # 탐색이 격자 중간점을 채웠다 + assert poly[0][:2] == [5.0, 5.0] + assert poly[-1][:2] == [45.0, 40.0] diff --git a/resources/tester/test_route_polyline.py b/resources/tester/test_route_polyline.py new file mode 100644 index 00000000..3b8c9017 --- /dev/null +++ b/resources/tester/test_route_polyline.py @@ -0,0 +1,128 @@ +"""계획노선 폴리라인화 — 원호가 접선과 실제로 맞물리는지 수치로 본다. + +왜 (2026-09-06 사용자 지시) — 예상노선은 폴리라인이 아니라 점 묶음이라, 계획노선은 그것을 +복사해 **폴리라인으로 바꾼 것**이어야 한다. 곡선 기준은 지식DB(별표2 Ⅰ.2.다) 값을 쓴다. +""" + +import math + +from common_util.common_util_route_polyline import ( + build_planned_polyline, + dedupe_points, +) + + +def _dist(a, b): + return math.hypot(b[0] - a[0], b[1] - a[1]) + + +def test_붙어_있는_점은_하나로(): + points = [(0, 0), (0.1, 0), (0.2, 0), (10, 0)] + assert dedupe_points(points) == [(0, 0), (10, 0)] + + +def test_펴진_자리에도_곡선을_둔다(): + """내각 175° — **곡선 생략을 안 한다**(2026-09-07 사용자 지시). + + 별표2 는 「내각 155° 이상이면 곡선을 두지 않을 수 있다」고 하지만, 그러면 조금 폈다는 + 이유로 곡선이 사라져 **사용자가 평면 노선을 고칠 때** 잡을 손잡이가 없어진다. + 그래서 꺾이는 자리에는 늘 곡선을 둔다. 아주 완만하므로 원본과 거의 겹친다. + """ + at = (100.0, 100.0 * math.tan(math.radians(5))) + result = build_planned_polyline([(0.0, 0.0), at, (200.0, 0.0)], min_radius_m=15.0) + assert result.curve_count == 1 + # 꺾임점은 선 위에 없다 — 아주 완만한 원호가 그 자리를 대신한다. + assert all(_dist(v, at) > 1e-9 for v in result.vertices) + # 그래도 원본에서 크게 벗어나지 않는다. + assert max(abs(v[1] - at[1] * min(v[0], 200 - v[0]) / 100.0) for v in result.vertices) < 3.0 + + +def test_직각_꺾임에_원호가_들어간다(): + """내각 90° — 곡선 대상. R 15m 이면 접선 15m, 양쪽 구간 100m 라 자리는 넉넉하다.""" + result = build_planned_polyline([(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], min_radius_m=15.0) + node = result.nodes[1] + assert result.curve_count == 1 + assert node.radius_m == 15.0 + assert math.isclose(node.tangent_m, 15.0, rel_tol=1e-6) # R·tan(45°) = R + assert not node.violations + + # 접선점이 꺾임점에서 tangent 만큼 떨어져 있어야 한다. + start = (100.0 - 15.0, 0.0) + end = (100.0, 15.0) + assert any(_dist(v, start) < 1e-6 for v in result.vertices) + assert any(_dist(v, end) < 1e-6 for v in result.vertices) + # 꺾임점 자체는 선 위에 없다 — 원호가 대신 지나간다. + assert all(_dist(v, (100.0, 100.0 - 100.0)) > 1e-6 for v in result.vertices[:1]) + assert all(_dist(v, (100.0, 0.0)) > 1e-9 for v in result.vertices) + + # 원호 위 점은 전부 중심에서 R 만큼 떨어져 있어야 한다(중심 = (85, 15)). + center = (85.0, 15.0) + arc = [v for v in result.vertices if 1e-6 < _dist(v, start) and 1e-6 < _dist(v, end)] + arc = [v for v in arc if abs(_dist(v, center) - 15.0) < 1.0] + assert arc, "원호 점이 없다" + for point in arc: + assert math.isclose(_dist(point, center), 15.0, abs_tol=1e-6) + + +def test_자리가_모자라면_반지름을_줄이고_위반으로_표시(): + """구간이 10m 뿐이라 R 15m 접선(15m)이 안 들어간다 — 줄이되 막지 않는다.""" + result = build_planned_polyline([(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], min_radius_m=15.0) + node = result.nodes[1] + assert node.radius_m is not None + assert node.radius_m < 15.0 + assert math.isclose(node.tangent_m, 5.0, rel_tol=1e-6) # 구간 10m 의 절반 + assert any("최소곡선반지름 미달" in text for text in node.violations) + assert result.violation_count == 1 + + +def test_배향곡선_하한도_표시된다(): + result = build_planned_polyline([(0.0, 0.0), (6.0, 0.0), (6.0, 6.0)], min_radius_m=15.0) + node = result.nodes[1] + assert any("배향곡선 하한 미달" in text for text in node.violations) + + +def test_노드는_원본_점을_그대로_들고_있다(): + """폴리라인화는 **모양 정리**일 뿐 — 제어점(IP)은 원본 자리에 그대로 있어야 한다.""" + points = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (200.0, 100.0)] + result = build_planned_polyline(points, min_radius_m=15.0) + assert [(node.x, node.y) for node in result.nodes] == points + + +def test_점이_둘_이하면_그대로_돌려준다(): + assert build_planned_polyline([(0.0, 0.0), (10.0, 0.0)], min_radius_m=15.0).vertices == [ + (0.0, 0.0), + (10.0, 0.0), + ] + assert build_planned_polyline([], min_radius_m=15.0).vertices == [] + + +def test_완만한_긴_굴곡이_삼켜지지_않는다(): + """허용오차보다 얕은 S 자 굴곡 — 노드 간격 상한이 없으면 통째로 지워진다. + + 2026-09-06 실측 재현: 4.5km S자 노선이 노드 20개·곡선 2곳으로 뭉개졌다. + 편차 3m(허용오차 4m 아래)짜리 완만한 굴곡을 400m 에 걸쳐 만든다. + """ + from common_util.common_util_route_polyline import simplify_to_nodes + + points = [(float(x), 3.0 * math.sin(math.pi * x / 200.0)) for x in range(0, 801, 4)] + # 상한이 없으면(되뽑기 끔) 굴곡이 지워져 양 끝만 남는다. + flat = simplify_to_nodes(points, 4.0, max_spacing_m=0.0) + assert len(flat) <= 3, flat + + # 상한 100m 를 두면 그 구간을 다시 뽑아 굴곡이 살아난다. + kept = simplify_to_nodes(points, 4.0, max_spacing_m=100.0) + assert len(kept) > len(flat) + spans = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(kept, kept[1:])] + # 상한은 **되뽑기를 거는 방아쇠**이지 딱 잘라 지키는 값이 아니다 — 겹 수(MAX_REFINE_DEPTH) + # 안에서 허용오차를 반씩 낮춰 뽑으므로 조금 넘길 수 있다. 확인할 것은 「지워지지 않았다」. + assert max(spans) <= 100.0 * 1.2, spans + assert len(kept) >= 8, kept + + +def test_되뽑기는_원본_점만_남긴다(): + """되뽑아도 새 점을 만들지 않는다 — 노드는 언제나 원본 자리다.""" + from common_util.common_util_route_polyline import simplify_to_nodes + + points = [(float(x), 3.0 * math.sin(math.pi * x / 200.0)) for x in range(0, 801, 4)] + kept = simplify_to_nodes(points, 4.0, max_spacing_m=100.0) + assert all(point in points for point in kept) diff --git a/resources/tester/test_route_polyline_apex_roundtrip.py b/resources/tester/test_route_polyline_apex_roundtrip.py new file mode 100644 index 00000000..a1431009 --- /dev/null +++ b/resources/tester/test_route_polyline_apex_roundtrip.py @@ -0,0 +1,81 @@ +"""묶인 곡선을 **교각점 하나로 갈아 끼워도 같은 선**이 나오는지. + +왜 (2026-09-07 실측) — 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶고, 그 곡선의 +교각점은 앞뒤 직선을 늘려 만나는 자리라 원본 꺾임점 중 어느 것도 아니다. 그런데 편집은 +「꺾임점 하나 = 곡선 하나」로 표현되므로, 묶인 채로 두면 한 번만 손대도 묶음이 흩어지고 +**맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(R 12~199m → 전부 12m). + +그래서 화면이 모달을 열 때 묶인 구간을 그 **교각점 하나**로 갈아 끼운다. 이 시험은 그렇게 +갈아 끼운 목록을 서버에 되넣었을 때 **원래와 같은 선**이 나오는지를 지킨다. +""" + +import math + +from common_util.common_util_route_polyline import build_planned_polyline + +MIN_RADIUS = 12.0 + + +def _arc_route(radius_m: float) -> list[tuple[float, float]]: + points = [(float(x), 0.0) for x in range(-150, 0, 3)] + for degree in range(0, 91, 3): + angle = math.radians(degree) + points.append((radius_m * math.sin(angle), radius_m - radius_m * math.cos(angle))) + points += [(radius_m, radius_m + y) for y in range(3, 150, 3)] + return points + + +def _flatten(result): + """화면이 하는 것과 같은 갈아 끼우기 — 묶인 구간을 교각점 하나로.""" + replaced = {curve.node_first: curve for curve in result.curves} + dropped = { + index + for curve in result.curves + for index in range(curve.node_first + 1, curve.node_last + 1) + } + nodes: list[tuple[float, float]] = [] + radii: list[float | None] = [] + for index, node in enumerate(result.nodes): + if index in dropped: + continue + curve = replaced.get(index) + nodes.append(curve.apex if curve else (node.x, node.y)) + radii.append(curve.radius_m if curve else None) + return nodes, radii + + +def test_교각점으로_갈아_끼워도_같은_선(): + for radius in (25.0, 40.0, 80.0): + first = build_planned_polyline(_arc_route(radius), min_radius_m=MIN_RADIUS) + nodes, radii = _flatten(first) + again = build_planned_polyline( + nodes, + min_radius_m=MIN_RADIUS, + simplify=False, + curve_flags=[True] * len(nodes), + radii=radii, + ) + assert again.curve_count == first.curve_count, f"R {radius}m 곡선 수가 달라짐" + fitted = [c.radius_m for c in first.curves] + kept = [c.radius_m for c in again.curves] + for one, other in zip(fitted, kept, strict=True): + assert math.isclose(one, other, rel_tol=1e-6), f"R {one:.2f} → {other:.2f}" + # 선 자체도 거의 같아야 한다 — 정점 수와 양 끝이 맞고 최대 어긋남이 1mm 안. + assert len(again.vertices) == len(first.vertices) + worst = max(math.dist(a, b) for a, b in zip(first.vertices, again.vertices, strict=True)) + assert worst < 1e-3, f"선이 {worst:.4f}m 어긋남" + + +def test_갈아_끼우지_않으면_반지름이_눌린다(): + """왜 갈아 끼워야 하는지 못박는다 — 원본 노드를 그대로 되넣으면 하한으로 눌린다.""" + first = build_planned_polyline(_arc_route(40.0), min_radius_m=MIN_RADIUS) + raw = [(node.x, node.y) for node in first.nodes] + again = build_planned_polyline( + raw, + min_radius_m=MIN_RADIUS, + simplify=False, + curve_flags=[True] * len(raw), + radii=[None] * len(raw), + ) + assert max(c.radius_m for c in first.curves) > 30.0 + assert max(c.radius_m for c in again.curves) <= MIN_RADIUS + 1e-6 diff --git a/resources/tester/test_route_polyline_curve_fit.py b/resources/tester/test_route_polyline_curve_fit.py new file mode 100644 index 00000000..22fc24c0 --- /dev/null +++ b/resources/tester/test_route_polyline_curve_fit.py @@ -0,0 +1,90 @@ +"""계획노선이 「직선 > 곡선 > 직선」으로 서고, 반지름이 예정노선에 맞는지. + +사용자 확정(2026-09-07) — 「계획노선은 직선>곡선>직선 형태의 폴리라인임. 반지름은 법정 +최소 값을 지키되 **기존 예정노선에 가까운 폴리라인을 찾는 게 키**임.」 + +옛 방식은 꺾임점마다 법정 하한(R 12m) 원호를 하나씩 끼웠다. 그러면 완만한 긴 곡선이 +작은 원호 여러 개로 쪼개져 원본과 벌어졌다. 이제 이어진 꺾임을 한 곡선으로 묶고 그 안에서 +반지름을 골라 맞춘다. +""" + +import math + +from common_util.common_util_route_polyline import ( + _point_to_segment_m, + build_planned_polyline, +) + +MIN_RADIUS = 12.0 + + +def _arc_route(radius_m: float, *, straight_m: int = 150, step_deg: int = 3) -> list: + """직선 → 반지름 `radius_m` 원호(90°) → 직선 을 3m 간격 점으로 흉내 낸다.""" + points = [(float(x), 0.0) for x in range(-straight_m, 0, 3)] + for degree in range(0, 91, step_deg): + angle = math.radians(degree) + points.append((radius_m * math.sin(angle), radius_m - radius_m * math.cos(angle))) + points += [(radius_m, radius_m + y) for y in range(3, straight_m, 3)] + return points + + +def _deviation(points: list, line: list) -> tuple[float, float]: + """예정노선 점들이 폴리라인에서 벗어난 (최대, 평균) 거리(m).""" + gaps = [ + min(_point_to_segment_m(point, a, b) for a, b in zip(line, line[1:])) for point in points + ] + return max(gaps), sum(gaps) / len(gaps) + + +def test_매끄러운_곡선은_한_곡선으로_서고_반지름을_되찾는다(): + for radius in (25.0, 40.0, 80.0): + points = _arc_route(radius) + result = build_planned_polyline(points, min_radius_m=MIN_RADIUS) + fitted = [node.radius_m for node in result.nodes if node.radius_m] + assert result.curve_count == 1, f"R {radius}m 이 곡선 {result.curve_count}개로 쪼개짐" + # 원본 반지름을 10% 안으로 되찾아야 한다 — 하한(12m)에 눌러앉으면 실패. + assert abs(fitted[0] - radius) / radius < 0.10, f"R {radius}m → {fitted[0]:.1f}m" + worst, _mean = _deviation(points, result.vertices) + assert worst < 1.0, f"R {radius}m 에서 최대 {worst:.2f}m 벗어남" + + +def test_법정_하한_아래로는_고르지_않는다(): + """원본이 하한보다 급하게 돌면 하한을 지키고, 자리가 모자랄 때만 줄여 위반으로 남긴다.""" + points = _arc_route(6.0, straight_m=60) + result = build_planned_polyline(points, min_radius_m=MIN_RADIUS) + for node in result.nodes: + if node.radius_m is None: + continue + if node.radius_m < MIN_RADIUS: + assert node.violations, "하한 미달인데 표시가 없음" + + +def test_급한_꺾임은_하한을_그대로_쓴다(): + """직각으로 꺾이면 원본에 가장 가까운 값이 곧 하한이다 — 키우면 원본에서 멀어진다.""" + points = [(float(x), 0.0) for x in range(-120, 1, 3)] + points += [(0.0, float(y)) for y in range(3, 121, 3)] + result = build_planned_polyline(points, min_radius_m=MIN_RADIUS) + fitted = [node.radius_m for node in result.nodes if node.radius_m] + assert fitted, "직각 꺾임에 곡선이 없음" + assert abs(fitted[0] - MIN_RADIUS) < 0.5, f"직각인데 R {fitted[0]:.1f}m" + + +def test_묶는_것이_손해면_안_묶는다(): + """성격이 다른 굴곡이 이어 붙으면 한 곡선으로 펴지 않는다 — 크게 벌어지기 때문.""" + # 오른쪽으로 완만히 돌다가 같은 방향으로 급히 꺾이는 자리 + points = [(float(x), 0.0) for x in range(-90, 0, 3)] + for degree in range(0, 31, 3): + angle = math.radians(degree) + points.append((60 * math.sin(angle), 60 - 60 * math.cos(angle))) + last = points[-1] + for step in range(1, 30): + points.append( + ( + last[0] + step * 3 * math.cos(math.radians(75)), + last[1] + step * 3 * math.sin(math.radians(75)), + ) + ) + result = build_planned_polyline(points, min_radius_m=MIN_RADIUS) + worst, _mean = _deviation(points, result.vertices) + # 묶어서 크게 벌어지느니 쪼개는 편을 골라야 한다. + assert worst < 8.0, f"최대 {worst:.2f}m 벌어짐 — 묶지 말았어야 함" diff --git a/resources/tester/test_route_polyline_edit.py b/resources/tester/test_route_polyline_edit.py new file mode 100644 index 00000000..ced325ca --- /dev/null +++ b/resources/tester/test_route_polyline_edit.py @@ -0,0 +1,82 @@ +"""곡선·직선을 지우고 더하고 반지름을 바꾸는 편집이 그대로 서는지. + +사용자 지시(2026-09-07) — 「r과 직선 삭제나 추가가 있어야 하지 않을까?」 +편집은 **꺾임점 목록 + 자리마다 곡선 켬끔 + 반지름**으로 표현한다: + · 직선 삭제·추가 = 목록에서 점을 빼거나 더하기 + · 곡선 삭제·추가 = `curve_flags` 를 끄고 켜기 + · 반지름 변경 = `radii` 로 못박기 +""" + +import math + +from common_util.common_util_route_polyline import build_planned_polyline + +MIN_RADIUS = 12.0 + +# 두 번 꺾이는 노선 — 꺾임점 두 개(색인 1·2). +NODES = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (200.0, 100.0)] + + +def _build(**kwargs): + return build_planned_polyline(NODES, min_radius_m=MIN_RADIUS, simplify=False, **kwargs) + + +def test_기본은_꺾임점마다_곡선(): + result = _build() + assert result.curve_count == 2 + assert [c.node_first for c in result.curves] == [1, 2] + + +def test_곡선_삭제(): + """가운데 곡선을 끄면 그 자리는 직선이 그대로 꺾인다.""" + result = _build(curve_flags=[True, False, True, True]) + assert result.curve_count == 1 + assert [c.node_first for c in result.curves] == [2] + # 곡선을 지운 자리의 꺾임점은 선 위에 **그대로 남는다**. + assert any(abs(v[0] - 100.0) < 1e-9 and abs(v[1]) < 1e-9 for v in result.vertices) + + +def test_곡선_추가(): + """껐다 켜면 곡선이 돌아온다 — 껐을 때와 켰을 때가 서로 다르다.""" + off = _build(curve_flags=[True, False, True, True]) + on = _build(curve_flags=[True, True, True, True]) + assert on.curve_count == off.curve_count + 1 + assert on.vertices != off.vertices + + +def test_반지름_변경은_못박힌다(): + result = _build(radii=[None, 30.0, None, None]) + first = next(c for c in result.curves if c.node_first == 1) + assert math.isclose(first.radius_m, 30.0, rel_tol=1e-9) + # 접선 길이도 그 반지름을 따른다 — 직각이라 T = R. + assert math.isclose(first.tangent_m, 30.0, rel_tol=1e-6) + + +def test_반지름을_바꿔도_교각점은_안_움직인다(): + """「곡선 반지름 변경 시 주변 직선 각도 구속」 — 직선이 고정이라 접선점만 미끄러진다.""" + small = _build(radii=[None, 15.0, None, None]) + large = _build(radii=[None, 35.0, None, None]) + one = next(c for c in small.curves if c.node_first == 1) + other = next(c for c in large.curves if c.node_first == 1) + assert one.apex == other.apex + assert one.start != other.start # 접선점은 움직인다 + + +def test_직선_삭제는_점을_빼는_것(): + """가운데 꺾임점을 빼면 앞뒤 직선이 하나로 합쳐진다.""" + fewer = build_planned_polyline( + [NODES[0], NODES[2], NODES[3]], min_radius_m=MIN_RADIUS, simplify=False + ) + assert fewer.curve_count == 1 + assert len(fewer.nodes) == 3 + + +def test_직선_추가는_점을_더하는_것(): + """직선 위에 점을 더하면 그 자리에 곡선이 생긴다(그 점을 옮기기 전에는 거의 직선).""" + more = build_planned_polyline( + [NODES[0], (50.0, 0.0), *NODES[1:]], min_radius_m=MIN_RADIUS, simplify=False + ) + assert len(more.nodes) == len(NODES) + 1 + # 새 점은 직선 위라 그 자리 곡선은 거의 펴져 있다 — 내각이 180°에 가깝다. + added = [c for c in more.curves if c.node_first == 1] + assert not added or added[0].inner_angle_deg > 179.0 diff --git a/resources/tester/test_route_polyline_handle_drag.py b/resources/tester/test_route_polyline_handle_drag.py new file mode 100644 index 00000000..9bb90187 --- /dev/null +++ b/resources/tester/test_route_polyline_handle_drag.py @@ -0,0 +1,153 @@ +"""곡선 손잡이 끌기 — 화면의 **역셈**과 서버의 **정셈**이 짝이 맞는지. + +사용자 확정(2026-09-07) — 「직선과 곡선 교차점 이동 시에는 직선의 각도와 반지름 값 변경이 되」. +화면(`B05_Profile_UI_RouteEdit.ts` 의 `dragHandleTo`)은 **끈 접선점 → 새 교각점·새 R** 을 구하고, +서버(`common_util_route_polyline`)는 **교각점·R → 접선점**을 낸다. 둘은 두 벌이 아니라 짝이며, +왕복이 제자리인지를 여기서 지킨다 — 어긋나면 화면에서 끈 자리와 저장된 곡선이 달라진다. + +TS 를 실제로 컴파일해 Node 로 돌린다(배수관 세트 거울 테스트와 같은 방식). +""" + +import json +import math +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_route_polyline import ( # noqa: E402 + _arc_geometry, + _inner_angle_deg, +) + +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +# 꺾임점 셋짜리 노선 — 가운데가 곡선이다. +BEFORE = (0.0, 0.0) +APEX = (100.0, 0.0) +AFTER = (100.0, 100.0) + +# TS 의 `dragHandleTo` 와 **같은 식**을 떼어 낸 것 — 실제 파일에서 읽어 쓴다면 모달 전체를 +# 컴파일해야 해서(캔버스·DOM 의존) 여기서는 같은 식을 Node 로 돌려 값만 맞대 본다. +_TS_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); + +function intersect(a1, a2, b1, b2) { + const dx1 = a2[0] - a1[0], dy1 = a2[1] - a1[1]; + const dx2 = b2[0] - b1[0], dy2 = b2[1] - b1[1]; + const den = dx1 * dy2 - dy1 * dx2; + if (Math.abs(den) <= 1e-12) return null; + const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / den; + return [a1[0] + dx1 * t, a1[1] + dy1 * t]; +} +function innerAngleDeg(before, at, after) { + const ax = before[0] - at[0], ay = before[1] - at[1]; + const bx = after[0] - at[0], by = after[1] - at[1]; + const la = Math.hypot(ax, ay), lb = Math.hypot(bx, by); + if (la <= 0 || lb <= 0) return 180; + const c = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); + return (Math.acos(c) * 180) / Math.PI; +} +const out = input.cases.map((item) => { + const { before, apex, after, to, which } = item; + const next = + which === "start" ? intersect(before, to, apex, after) : intersect(before, apex, to, after); + if (!next) return null; + const inner = innerAngleDeg(before, next, after); + const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + if (!(halfTan > 1e-9)) return null; + const tangent = Math.hypot(next[0] - to[0], next[1] - to[1]); + return { apex: next, radius: tangent / halfTan, inner }; +}); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +def _cases() -> list[dict]: + """시작점을 앞뒤로 밀어 본다 — 접선 길이 10~40m.""" + cases = [] + for tangent in (10.0, 20.0, 30.0, 40.0): + cases.append( + { + "before": list(BEFORE), + "apex": list(APEX), + "after": list(AFTER), + "to": [APEX[0] - tangent, APEX[1]], # 들어오는 직선 위 — 각도는 그대로 + "which": "start", + } + ) + # 직선에서 벗어난 자리로도 끌어 본다 — 그때는 직선 각도가 바뀐다. + cases.append( + { + "before": list(BEFORE), + "apex": list(APEX), + "after": list(AFTER), + "to": [70.0, 12.0], + "which": "start", + } + ) + return cases + + +def _ts_results(tmp_path: Path) -> list[dict]: + out = tmp_path / "js" + out.mkdir(parents=True, exist_ok=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({"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_끈_자리가_그대로_곡선_시작점이_된다(tmp_path: Path) -> None: + """화면이 낸 (교각점, R) 을 서버 셈에 넣으면 **끈 자리**가 다시 나와야 한다.""" + results = _ts_results(tmp_path) + assert len(results) == len(_cases()) + for case, produced in zip(_cases(), results, strict=True): + assert produced is not None, f"{case['to']} 에서 값이 안 나옴" + apex = (produced["apex"][0], produced["apex"][1]) + radius = produced["radius"] + inner = _inner_angle_deg(BEFORE, apex, AFTER) + half_tan = math.tan(math.radians(180.0 - inner) / 2) + geometry = _arc_geometry(BEFORE, apex, AFTER, inner, radius, half_tan) + assert geometry is not None + start, _end, _center = geometry + dragged = case["to"] + gap = math.hypot(start[0] - dragged[0], start[1] - dragged[1]) + assert gap < 1e-6, f"끈 자리 {dragged} vs 되돌아온 시작점 {start} — {gap:.4f}m 어긋남" + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_직선_위로_끌면_각도는_그대로고_반지름만_바뀐다(tmp_path: Path) -> None: + """접선점을 **들어오는 직선 위**에서 밀면 교각점이 안 움직이고 R 만 바뀐다.""" + results = _ts_results(tmp_path) + on_line = results[:4] # 직선 위 네 경우 + for produced in on_line: + assert math.isclose(produced["apex"][0], APEX[0], abs_tol=1e-9) + assert math.isclose(produced["apex"][1], APEX[1], abs_tol=1e-9) + radii = [item["radius"] for item in on_line] + assert radii == sorted(radii), "접선점을 멀리 밀수록 반지름이 커져야 함" + + +def test_화면_코드가_같은_식을_쓰고_있다() -> None: + """이 시험이 흉내 낸 식이 실제 화면 코드와 갈리지 않았는지 — 자리만 확인한다.""" + source = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Curve.ts").read_text( + encoding="utf-8" + ) + assert "export function dragHandleTo(" in source + assert re.search(r"radius\s*=\s*tangent\s*/\s*halfTan", source), "반지름 식이 바뀜" + assert 'which === "start"' in source diff --git a/resources/tester/test_route_polyline_idempotent.py b/resources/tester/test_route_polyline_idempotent.py new file mode 100644 index 00000000..0d012838 --- /dev/null +++ b/resources/tester/test_route_polyline_idempotent.py @@ -0,0 +1,53 @@ +"""[확인]을 여러 번 눌러도 선이 깎이지 않는지 — 노드 왕복이 제자리인가. + +왜 (2026-09-07 보조 창 실측) — 같은 노선으로 [확인]을 되풀이했더니 계획노선 정점이 +136 → 134 → 119, 노드가 22 → 21 로 계속 줄었다. 원인은 **이미 폴리라인인 것을 다시 +단순화**한 것. 원호 위 점이 섞인 선을 단순화하면 꺾임점이 조금씩 지워지고, 그 결과로 만든 +선을 또 단순화하기 때문이다. 단순화는 **원본 점군에서 한 번만** 돌아야 한다. +""" + +import math + +from common_util.common_util_route_polyline import build_planned_polyline + +RADIUS = 15.0 + + +def _zigzag() -> list[tuple[float, float]]: + """꺾임이 뚜렷한 원본 점군 — 3m 간격으로 조밀하게 찍은 지그재그.""" + corners = [(0.0, 0.0), (120.0, 0.0), (120.0, 120.0), (260.0, 120.0), (260.0, 240.0)] + points: list[tuple[float, float]] = [] + for start, end in zip(corners, corners[1:]): + length = math.dist(start, end) + steps = max(2, int(length // 3)) + for step in range(steps): + ratio = step / steps + points.append( + (start[0] + (end[0] - start[0]) * ratio, start[1] + (end[1] - start[1]) * ratio) + ) + points.append(corners[-1]) + return points + + +def test_노드를_다시_넣으면_같은_선이_나온다(): + """사용자가 아무것도 안 옮기고 [확인]만 눌렀을 때 = 노드 그대로 왕복.""" + first = build_planned_polyline(_zigzag(), min_radius_m=RADIUS) + nodes = [(node.x, node.y) for node in first.nodes] + + second = build_planned_polyline(nodes, min_radius_m=RADIUS, simplify=False) + assert [(n.x, n.y) for n in second.nodes] == nodes + assert second.vertices == first.vertices + assert second.curve_count == first.curve_count + + third = build_planned_polyline( + [(node.x, node.y) for node in second.nodes], min_radius_m=RADIUS, simplify=False + ) + assert third.vertices == first.vertices, "세 번째 왕복에서 선이 달라짐" + + +def test_옛_방식은_실제로_깎인다(): + """왜 갈래가 필요한지 못박는다 — 폴리라인을 다시 단순화하면 노드가 준다.""" + first = build_planned_polyline(_zigzag(), min_radius_m=RADIUS) + # 옛 방식: 폴리라인 정점(원호 포함)을 통째로 다시 단순화 + again = build_planned_polyline(first.vertices, min_radius_m=RADIUS) + assert len(again.nodes) != len(first.nodes) or again.vertices != first.vertices diff --git a/resources/tester/test_route_shapefile_input.py b/resources/tester/test_route_shapefile_input.py new file mode 100644 index 00000000..0e20e8ea --- /dev/null +++ b/resources/tester/test_route_shapefile_input.py @@ -0,0 +1,214 @@ +"""계획노선 shapefile 입력 + PRJ 2개 분리 검증 (2026-08-31). + +원청 실물 자료(일월.용화.2.2 — UTM-K 노선 shapefile, 동부원점 지형 PRJ/TFW)를 +그대로 써서 판독·좌표계 선택·필수 세트 판정을 확인한다. +""" + +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 B03_FileInput.B03_FileInput_Engine import reconcile_shapefile_members # noqa: E402 +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata # noqa: E402 +from B03_FileInput.B03_FileInput_Engine_Shapefile import ( # noqa: E402 + read_shapefile_attributes, + read_shapefile_parts, + shapefile_crs_input, + shapefile_encoding, + shapefile_epsg_label, +) +from B03_FileInput.B03_FileInput_Repository_Temp import ( # noqa: E402 + is_batch_required_complete, +) +from B03_FileInput.B03_FileInput_Router import _missing_required_file_types # noqa: E402 +from common_util.common_util_crs import find_project_prj # noqa: E402 +from common_util.common_util_route_geometry import ( # noqa: E402 + find_planned_route_file, + read_planned_route, +) + +# 업로드 원본을 그대로 둔 시험용 세트 (basename만 route/terrain으로 맞췄다). +FIXTURE_DIR = Path( + r"C:\Users\umsan\AppData\Local\Temp\claude" + r"\d--02-Software-Prog---------------------" + r"\72ca32d1-3b09-48f8-90f8-b4c851bf490e\scratchpad" +) +ROUTE_SHP = FIXTURE_DIR / "shpset" / "route.shp" +TERRAIN_PRJ = FIXTURE_DIR / "terrain.prj" + +pytestmark = pytest.mark.skipif( + not ROUTE_SHP.exists(), reason="원청 실물 shapefile 세트가 없는 환경" +) + + +def test_shapefile_geometry_matches_source() -> None: + """실물 노선: PolyLine 1파트·121정점, 2D(z=0).""" + parts = read_shapefile_parts(ROUTE_SHP) + assert len(parts) == 1 + assert len(parts[0]) == 121 + assert all(point[2] == 0.0 for point in parts[0]) + + +def test_cpg_949_decodes_korean_attributes() -> None: + """`.cpg` 내용이 `949` 한 줄이어도 CP949로 읽어 한글 속성이 살아야 한다.""" + assert shapefile_encoding(ROUTE_SHP) == "cp949" + attributes = read_shapefile_attributes(ROUTE_SHP) + assert attributes["대상지"] == "일월.용화.2.2" + assert attributes["길이"] == "2.200" + + +def test_crs_input_is_wkt_not_epsg_code() -> None: + """노선 PRJ는 `to_epsg()`가 None이다 — 변환 입력은 원문 WKT로 나와야 한다.""" + crs_input = shapefile_crs_input(ROUTE_SHP) + assert crs_input is not None + assert crs_input.startswith("PROJCS[") + assert "PCS_ITRF2000_UTM_K" in crs_input + # 라벨은 별도로 붙는다(표시·로그용). + assert shapefile_epsg_label(ROUTE_SHP) == 5179 + + +def test_route_reprojects_into_terrain_raster_extent() -> None: + """노선(UTM-K)을 지형 계로 옮기면 TFW 원점 안쪽에 앉아야 한다.""" + from pyproj import Transformer + + from common_util.common_util_crs import crs_input_from_prj + + planned = read_planned_route(ROUTE_SHP) + assert planned is not None + target = crs_input_from_prj(TERRAIN_PRJ.read_text(encoding="utf-8")) + transformer = Transformer.from_crs(planned.crs_input, target, always_xy=True) + moved = [transformer.transform(vertex.x, vertex.y) for vertex in planned.vertices] + + xs = [point[0] for point in moved] + ys = [point[1] for point in moved] + # TFW: 화소 0.2m, 좌상단 원점 (208288.8376, 368092.2820), y는 아래로 진행. + origin_x, origin_y = 208288.8376409571, 368092.2819918637 + assert min(xs) > origin_x + assert max(ys) < origin_y + # 노선 연장은 도형 기준 약 2.1km (속성 길이 2.2km와 같은 자릿수). + assert 2000.0 < planned.vertices[-1].chainage_m < 2300.0 + + +def test_planned_route_reader_prefers_shapefile(tmp_path: Path) -> None: + """노선 파일이 CSV·shapefile 둘 다 있으면 shapefile을 고른다.""" + input_dir = tmp_path / "input" + (input_dir / "shp").mkdir(parents=True) + (input_dir / "csv").mkdir(parents=True) + for extension in (".shp", ".shx", ".dbf", ".cpg", ".prj"): + target = input_dir / "shp" / f"route{extension}" + target.write_bytes(ROUTE_SHP.with_suffix(extension).read_bytes()) + (input_dir / "csv" / "old.csv").write_text( + "x,y,z,crs_epsg\n1,2,3,5186\n4,5,6,5186\n", encoding="utf-8" + ) + + found = find_planned_route_file(input_dir) + assert found is not None and found.suffix == ".shp" + planned = read_planned_route(found) + assert planned is not None + assert len(planned.vertices) == 121 + assert planned.name == "일월.용화.2.2" + + +def test_terrain_prj_wins_over_route_prj(tmp_path: Path) -> None: + """PRJ가 둘이면 지형(input/prj/)을 프로젝트 좌표계로 고른다.""" + stage_input = tmp_path / "B03_FileInput" / "input" + (stage_input / "shp").mkdir(parents=True) + (stage_input / "prj").mkdir(parents=True) + # 노선 PRJ 파일명이 정렬상 앞서도(a < z) 지형이 이겨야 한다. + (stage_input / "shp" / "a_route.prj").write_text( + ROUTE_SHP.with_suffix(".prj").read_text(encoding="utf-8"), encoding="utf-8" + ) + (stage_input / "prj" / "z_terrain.prj").write_text( + TERRAIN_PRJ.read_text(encoding="utf-8"), encoding="utf-8" + ) + + chosen = find_project_prj(tmp_path) + assert chosen is not None + assert chosen.name == "z_terrain.prj" + assert "Modified East Belt" in chosen.read_text(encoding="utf-8") + + +def test_terrain_prj_pairs_with_terrain_data(tmp_path: Path) -> None: + """`input/prj/`에 옛 PRJ가 남아 있어도 지금 지형 자료와 짝인 것을 고른다. + + 실측 사고(2026-08-31): 옛 `result.prj`(East Belt 2010, FN 600,000)가 이름 정렬에서 + 새 `용화.prj`(Modified East Belt, FN 500,000)보다 앞서 뽑혀 노선이 Y로 100,000m + 어긋났고 B05 경로 계산이 400으로 실패했다. + """ + stage_input = tmp_path / "B03_FileInput" / "input" + for name in ("prj", "las", "tfw"): + (stage_input / name).mkdir(parents=True) + # 옛 잔재 — 이름이 앞서고, 짝이 되는 지형 자료는 이제 없다. + (stage_input / "prj" / "result.prj").write_text("stale", encoding="utf-8") + # 이번 지형 — LAS/TFW와 basename이 같다. + (stage_input / "prj" / "용화.prj").write_text( + TERRAIN_PRJ.read_text(encoding="utf-8"), encoding="utf-8" + ) + (stage_input / "las" / "용화.las").write_bytes(b"LASF") + (stage_input / "tfw" / "용화.tfw").write_text("0.2 0 0 -0.2 1 2", encoding="utf-8") + + chosen = find_project_prj(tmp_path) + assert chosen is not None + assert chosen.name == "용화.prj" + assert "Modified East Belt" in chosen.read_text(encoding="utf-8") + + +def test_reconcile_pulls_prj_that_arrived_first(tmp_path: Path) -> None: + """PRJ가 SHP보다 먼저 도착해 input/prj/에 앉아도 세트 폴더로 끌어온다.""" + stage_input = tmp_path / "input" + (stage_input / "shp").mkdir(parents=True) + (stage_input / "prj").mkdir(parents=True) + (stage_input / "shp" / "route.shp").write_bytes(ROUTE_SHP.read_bytes()) + (stage_input / "prj" / "route.prj").write_text("dummy", encoding="utf-8") + (stage_input / "prj" / "terrain.prj").write_text("keep", encoding="utf-8") + + moved = reconcile_shapefile_members(tmp_path) + + assert [path.name for path in moved] == ["route.prj"] + assert (stage_input / "shp" / "route.prj").exists() + assert not (stage_input / "prj" / "route.prj").exists() + assert (stage_input / "prj" / "terrain.prj").exists() # 지형 PRJ는 그대로 + + +def test_analyze_dispatches_shapefile() -> None: + """업로드 분석기가 .shp를 계획노선으로 인식한다.""" + metadata = analyze_input_metadata(ROUTE_SHP) + assert metadata["purpose"] == "planned_route" + assert metadata["shape_type"] == "PolyLine" + assert metadata["point_count"] == 121 + assert metadata["epsg"] == 5179 + assert metadata["missing_members"] == [] + + +def test_required_set_accepts_shapefile_instead_of_csv() -> None: + """필수 세트: 노선은 csv|shp 택일, shp면 형제 파일까지 본다.""" + complete = {"shp", "shx", "dbf", "route_prj", "prj", "tfw", "las"} + assert _missing_required_file_types(complete) == [] + + # 형제 파일이 빠지면 막는다 — 노선 PRJ(route_prj)도 형제다. + assert _missing_required_file_types({"shp", "prj", "tfw", "las"}) == [ + "dbf", + "route_prj", + "shx", + ] + + # 노선 PRJ만 있고 지형 PRJ가 없으면 프로젝트 좌표계를 못 정한다. + assert "prj" in _missing_required_file_types({"shp", "shx", "dbf", "route_prj", "tfw", "las"}) + + # 보관함 묶음은 확장자만 알아 노선/지형 PRJ를 못 가린다 — shx·dbf까지만 본다. + assert is_batch_required_complete({"shp", "shx", "dbf", "prj", "tfw", "las"}) + assert not is_batch_required_complete({"shp", "prj", "tfw", "las"}) + + # 노선이 아예 없으면 막는다. + assert "csv/shp" in _missing_required_file_types({"prj", "tfw", "las"}) + + # 기존 CSV 흐름은 그대로 통과한다(회귀). + assert _missing_required_file_types({"csv", "prj", "tfw", "laz"}) == [] + assert is_batch_required_complete({"csv", "prj", "tfw", "laz"}) diff --git a/resources/tester/test_route_trim_to_surface.py b/resources/tester/test_route_trim_to_surface.py new file mode 100644 index 00000000..25162a11 --- /dev/null +++ b/resources/tester/test_route_trim_to_surface.py @@ -0,0 +1,113 @@ +"""계획노선이 지표면 밖으로 나갈 때의 트림 규칙. + +배경(2026-09-01): 실무 자료 `용화.las`가 계획노선 2,136m 중 일부만 덮는다. +자르지 않으면 자동 설계 체인이 서피스 밖 정점에서 끊긴다. 서피스 가장자리는 +점 밀도가 떨어져 불규칙하므로, 잘라 낸 쪽 끝에서 30m를 더 깎는다. +""" + +import math + +import numpy as np +import pytest + +from common_util.common_util_route_geometry import densify_route, trim_route_to_surface + + +class BoxSampler: + """x가 [x_min, x_max] 안일 때만 유효한 가짜 지표면.""" + + def __init__(self, x_min: float, x_max: float) -> None: + self.x_min = x_min + self.x_max = x_max + + def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + xs = np.asarray(xy)[:, 0] + valid = (xs >= self.x_min) & (xs <= self.x_max) + return np.zeros(len(xs)), valid + + +def _line(length_m: float, step_m: float = 10.0) -> list[tuple[float, float]]: + count = int(length_m / step_m) + 1 + return [(index * step_m, 0.0) for index in range(count)] + + +def _length(points: list[tuple[float, float]]) -> float: + return sum(math.dist(points[i], points[i + 1]) for i in range(len(points) - 1)) + + +def test_route_fully_inside_is_untouched() -> None: + """전 구간이 지표면 안이면 손대지 않는다 — 멀쩡한 노선을 깎을 이유가 없다.""" + points = _line(500.0) + assert trim_route_to_surface(points, BoxSampler(-100.0, 1000.0)) == points + + +def test_tail_outside_is_cut_with_edge_margin() -> None: + """뒤쪽이 잘리면 그 끝에서 30m를 더 깎는다. 시점은 원래 자리 그대로.""" + points = _line(1000.0) + kept = trim_route_to_surface(points, BoxSampler(-100.0, 600.0), edge_trim_m=30.0) + + assert kept[0] == points[0], "시점은 지표면 안이므로 깎으면 안 된다" + assert kept[-1][0] == pytest.approx(570.0, abs=1.0), "600m 경계에서 30m 더 깎는다" + assert _length(kept) == pytest.approx(570.0, abs=1.0) + + +def test_both_ends_outside_are_cut() -> None: + points = _line(1000.0) + kept = trim_route_to_surface(points, BoxSampler(200.0, 800.0), edge_trim_m=30.0) + assert kept[0][0] == pytest.approx(230.0, abs=1.0) + assert kept[-1][0] == pytest.approx(770.0, abs=1.0) + + +def test_longest_valid_run_wins() -> None: + """가장자리에서 한두 점이 튀어도 본 구간을 잃지 않는다.""" + + class HoleSampler: + def sample_xy(self, xy): + xs = np.asarray(xy)[:, 0] + # 0~20m 짧은 조각, 200~800m 긴 조각 + valid = ((xs <= 20.0) | ((xs >= 200.0) & (xs <= 800.0))) & (xs <= 800.0) + return np.zeros(len(xs)), valid + + kept = trim_route_to_surface(_line(1000.0), HoleSampler(), edge_trim_m=30.0) + assert kept[0][0] == pytest.approx(230.0, abs=1.0), "긴 구간을 골라야 한다" + assert kept[-1][0] == pytest.approx(770.0, abs=1.0) + + +def test_route_entirely_outside_returns_empty() -> None: + assert trim_route_to_surface(_line(500.0), BoxSampler(9000.0, 9999.0)) == [] + + +def test_edge_margin_eating_everything_returns_empty() -> None: + """남는 구간이 여유보다 짧으면 빈 목록 — 못 미더운 구간으로 설계하지 않는다.""" + kept = trim_route_to_surface(_line(1000.0), BoxSampler(500.0, 540.0), edge_trim_m=30.0) + assert kept == [] + + +def test_sampler_failure_keeps_route() -> None: + """지표면을 못 읽으면 노선을 자르지 않는다 — 트림 실패가 설계를 막으면 안 된다.""" + + class BrokenSampler: + def sample_xy(self, xy): + raise OSError("DTM 손상") + + points = _line(300.0) + assert trim_route_to_surface(points, BrokenSampler()) == points + + +def test_densify_keeps_shape_and_original_vertices() -> None: + """조밀화는 같은 직선 위에 점을 더 찍을 뿐 — 형상도 끝점도 바뀌지 않는다.""" + points = [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)] + dense = densify_route(points, 4.0) + + assert dense[0] == points[0] and dense[-1] == points[-1] + for original in points: + assert original in dense, "원래 정점은 모두 남아야 한다" + gaps = [math.dist(dense[i], dense[i + 1]) for i in range(len(dense) - 1)] + assert max(gaps) <= 4.0 + 1e-9 + assert _length(dense) == pytest.approx(_length(points)), "연장이 바뀌면 형상이 바뀐 것" + + +def test_densify_leaves_close_vertices_alone() -> None: + """이미 문턱 이하면 손대지 않는다 — 쓸데없이 정점을 불리지 않는다.""" + points = [(0.0, 0.0), (2.0, 0.0), (4.0, 0.0)] + assert densify_route(points, 4.0) == points diff --git a/resources/tester/test_session_keys_registered.py b/resources/tester/test_session_keys_registered.py new file mode 100644 index 00000000..c49dbd16 --- /dev/null +++ b/resources/tester/test_session_keys_registered.py @@ -0,0 +1,113 @@ +"""등록표가 정한 값을 **제 맘대로 저장하지 않는지** — 키도, 저장소도. + +왜 (2026-09-07 실사고) — 암 경계선 오프셋은 쓰는 쪽이 `stateKey("rockb", …)` 로 +`aislo:draft:rockb:{p}:{r}` 에 쓰는데, 읽는 쪽이 옛 키 `b06:rockb:{p}:{r}` 를 날문자열로 +읽고 있었다. 옛 키는 아무도 안 쓰므로 **읽는 쪽이 늘 빈 값**을 받았고, 계획선을 고쳐 +횡단을 다시 계산할 때 사용자가 옮긴 암 경계선이 안 실려 나갔다(토사/암 나눔 = 수량). + +키 이름을 정하는 자리는 `A00_Common/b_page_state.ts` 의 등록표 하나뿐이다. 세션을 직접 +만지는 모듈이 있어도 **키는 `stateKey` 로 받아야** 한다. 이 시험은 날문자열 키를 막는다. +""" + +import os +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +#: 등록표 자신 — 여기서만 키 문자열을 만든다. +_REGISTRY = ROOT / "A00_Common" / "b_page_state.ts" + +#: 세션을 직접 만지는 호출. 인자가 따옴표·백틱으로 시작하면 **날문자열 키**다. +_RAW_CALL = re.compile(r"sessionStorage\.(?:get|set|remove)Item\(\s*[\"'`]") + +#: 훑을 화면 폴더 — 저장소 전체를 rglob 하면 `node_modules` 정션까지 타서 느리다. +_ROOTS = ( + "A00_Common", + "B05_Profile", + "B06_Section", + "B07_DesignDetail", + "common_util", + "ui_template", +) + + +def _sources() -> list[Path]: + """대상 폴더를 훑되 `node_modules` 는 **들어가기 전에** 가지친다. + (걸러내기만 하면 B07 CAD 앱의 의존성 트리를 통째로 걸어 몇 분씩 걸린다.)""" + files: list[Path] = [] + for name in _ROOTS: + folder = ROOT / name + if not folder.is_dir(): + continue + for current, dirs, names in os.walk(folder): + dirs[:] = [d for d in dirs if d not in ("node_modules", "dist", ".git")] + for filename in names: + if not filename.endswith(".ts"): + continue + path = Path(current) / filename + if path != _REGISTRY: + files.append(path) + return files + + +def test_등록표_밖에서_날문자열_세션_키를_안_쓴다(): + offenders = [] + for path in _sources(): + text = path.read_text(encoding="utf-8") + for number, line in enumerate(text.splitlines(), start=1): + if _RAW_CALL.search(line): + offenders.append(f"{path.relative_to(ROOT)}:{number}: {line.strip()[:80]}") + assert not offenders, "세션 키를 등록표 없이 만든 자리:\n" + "\n".join(offenders) + + +def test_옛_암경계_키가_코드에_안_남아_있다(): + """실사고 자리 못박기 — 읽는 쪽이 등록표를 거치는지.""" + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert 'readState>("rockb"' in persist, "등록표를 안 거침" + # 옛 키가 **코드로** 되살아나는 것만 막는다 — 주석에 사연으로 적힌 것은 남겨 둔다. + code = "\n".join( + line for line in persist.splitlines() if not line.lstrip().startswith(("*", "//", "/*")) + ) + assert "b06:rockb:" not in code, "옛 키가 코드에 남아 있음" + + +def test_시험이_빈_목록으로_통과하지_않는다(): + """훑는 대상이 실제로 있는지 — 경로가 틀리면 조용히 통과한다.""" + files = _sources() + assert len(files) > 100, f"TS 파일을 {len(files)}개밖에 못 찾음 — 경로 규칙 확인" + assert any("B06_Section" in str(path) for path in files) + + +#: 저장소를 직접 이름으로 고르는 것이 **정당한** 자리. +#: `b_page_state` 는 고르는 곳 자신이고, 나머지는 등록표 밖의 제 값을 쓰는 자리다. +_STORAGE_PICKERS_OK = { + "b_page_state.ts", # 여기가 유일하게 고르는 곳(`storageFor`/`storageOf`) +} + +#: 등록표가 관리하지 않는 앱 전역 값 — 이 키를 쓰는 줄은 이 시험의 대상이 아니다. +#: `frd_current_project_id`(`config_frontend.ts:29`)는 「지금 보고 있는 프로젝트」 가리개라 +#: 화면 상태가 아니고, 워크플로우 전체가 그 한 값을 본다. +_NON_REGISTRY_KEYS = ("CURRENT_PROJECT_ID_KEY",) + + +def test_등록표_키를_쓰면서_저장소를_직접_고르지_않는다(): + """오늘 두 번째로 같은 계통이 났다 — 키는 등록표에서 받는데 **저장소는 제 맘대로** 고르는 자리. + + `rockb` 는 키를 날문자열로 만들어 샜고(위 시험이 막음), 화면 취향은 키는 맞았는데 + 저장소를 직접 골라 **절반만 옮겨졌다**(접힘 4개는 옮겨지고 높이 3개는 세션에 남음). + 저장소 선택도 올려보내기도 `readByKey`/`writeByKey` 한 창구만 하도록 못박는다. + """ + offenders = [] + for path in _sources(): + if path.name in _STORAGE_PICKERS_OK: + continue + text = path.read_text(encoding="utf-8") + if "stateKey(" not in text and "ByKey(" not in text: + continue # 등록표와 무관한 제 값을 쓰는 자리 — 이 시험의 대상이 아니다. + for number, line in enumerate(text.splitlines(), start=1): + if any(name in line for name in _NON_REGISTRY_KEYS): + continue + if re.search(r"(? list[list[float]]: + """정사각 폐합 링 (반시계).""" + return [ + [cx - half, cy - half], + [cx + half, cy - half], + [cx + half, cy + half], + [cx - half, cy + half], + [cx - half, cy - half], + ] + + +@pytest.fixture() +def sheet_project(tmp_path: Path) -> dict: + """processed/도엽_등고선.geojson(WGS84)을 갖춘 가짜 프로젝트 저장소.""" + processed_dir = tmp_path / "B04_PreProcess" / "processed" + models_dir = tmp_path / "B04_PreProcess" / "models" + processed_dir.mkdir(parents=True) + models_dir.mkdir(parents=True) + + to_wgs84 = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True) + features = [] + # 바깥 100m → 안쪽 120m: 중심으로 갈수록 높은 언덕 + for half, elev in ((450.0, 100.0), (300.0, 110.0), (150.0, 120.0)): + ring = [list(to_wgs84.transform(x, y)) for x, y in _square_ring(X0, Y0, half)] + features.append( + { + "type": "Feature", + "properties": {"등고수치": elev}, + "geometry": {"type": "LineString", "coordinates": ring}, + } + ) + (processed_dir / "도엽_등고선.geojson").write_text( + json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False), + encoding="utf-8", + ) + return {"root": tmp_path, "processed": processed_dir, "models": models_dir} + + +def test_sheet_surface_npz_and_sampler_roundtrip(sheet_project: dict) -> None: + # 노선: 중심을 지나는 100m 직선 + route_xy = np.array([[X0 - 50.0, Y0], [X0 + 50.0, Y0]], dtype=np.float64) + models = build_sheet_surface_model( + sheet_project["root"], + sheet_project["processed"], + sheet_project["models"], + route_xy, + EPSG, + methods=["tin_sheet"], + ) + assert len(models) == 1 + model = models[0] + assert model["model_type"] == "dtm" + assert model["generation_params"]["source_filter"] == "sheet_tin_sheet" + + npz_path = sheet_project["models"] / "dtm_sheet_tin_sheet.npz" + assert npz_path.is_file() + with np.load(npz_path) as data: + for key in ("x", "y", "z", "valid_mask"): + assert key in data, key + x, y = data["x"], data["y"] + # DtmGridSampler 규약: 오름차순 축, z=(len(y), len(x)) + assert x[0] < x[-1] and y[0] < y[-1] + assert data["z"].shape == (len(y), len(x)) + + # 프리뷰 glb + assert (sheet_project["models"] / "dtm_sheet_tin_sheet_preview.glb").stat().st_size > 0 + + # 종·횡단 공용 sampler로 왕복 — 등고선 안쪽은 유효 표고. + # 최고 등고선(120) 안쪽은 마루 캡(경사 연장)으로 최대 +간격-0.5m까지 올라갈 수 있다. + sampler = build_surface_sampler(sheet_project["models"], "sheet_tin_sheet", "dtm", smooth=False) + inner = np.array([[X0, Y0 + 200.0], [X0 + 100.0, Y0 - 100.0]], dtype=np.float64) + z, valid = sampler.sample_xy(inner) + assert valid.all() + assert np.all((z >= 100.0 - 1e-6) & (z <= 124.5 + 1e-3)) + + # TIN 바깥(등고선 밖 + margin 밖)은 무효 + outer = np.array([[X0 + 10000.0, Y0]], dtype=np.float64) + _, outer_valid = sampler.sample_xy(outer) + assert not outer_valid.any() + + +def test_sheet_surface_without_contours_returns_none(tmp_path: Path) -> None: + processed_dir = tmp_path / "B04_PreProcess" / "processed" + models_dir = tmp_path / "B04_PreProcess" / "models" + processed_dir.mkdir(parents=True) + models_dir.mkdir(parents=True) + route_xy = np.array([[0.0, 0.0], [10.0, 0.0]]) + assert build_sheet_surface_model(tmp_path, processed_dir, models_dir, route_xy, EPSG) == [] + + +def test_interpolate_between_contours_is_continuous() -> None: + """등고선 사이 z가 거리 비례로 연속 변화 — 계단(같은 표고 평탄면)이 없다.""" + from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import build_distance + + burned = np.full((10, 101), np.nan, dtype=np.float32) + burned[:, 0] = 100.0 # 좌측 벽 100m + burned[:, 100] = 105.0 # 우측 벽 105m + z = build_distance(None, burned, None, 1.0) + middle = z[5] + assert abs(float(middle[0]) - 100.0) < 1e-3 + assert abs(float(middle[100]) - 105.0) < 1e-3 + assert abs(float(middle[50]) - 102.5) < 0.05 # 중앙 = 정확히 절반 + # 단조 증가이고 계단(연속 동일값)이 없다 + steps = np.diff(middle) + assert np.all(steps > 0) + + +def test_resolve_enclosed_interiors_raises_summit_lowers_pit() -> None: + """폐합 등고선 안쪽 — 바깥이 낮으면 마루로 올리고, 높으면 웅덩이로 내린다.""" + from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import _resolve_enclosed_interiors + + burned = np.full((60, 60), np.nan, dtype=np.float32) + burned[10:50, 10] = 100.0 + burned[10:50, 49] = 100.0 + burned[10, 10:50] = 100.0 + burned[49, 10:50] = 100.0 # 100m 폐합 사각 링 + surface = np.full((60, 60), 95.0, dtype=np.float32) # 바깥이 낮은 지형 + surface[11:49, 11:49] = 97.0 # 보간이 만든 분화구 + handled = _resolve_enclosed_interiors(burned, [100.0], surface, 1.0, 5.0) + assert handled.any() + center = float(surface[29:31, 29:31].max()) + assert 100.0 < center <= 100.0 + 4.5 + 1e-3 # 상한 +간격-0.5m + + # 바깥이 높으면 웅덩이 — 안쪽이 내려간다 + surface2 = np.full((60, 60), 110.0, dtype=np.float32) + surface2[11:49, 11:49] = 103.0 + assert _resolve_enclosed_interiors(burned, [100.0], surface2, 1.0, 5.0).any() + assert float(surface2[30, 30]) < 100.0 + + +def test_cone_reproduction_beats_over_relaxation() -> None: + """원뿔(동심원 등고선) 재현 — 거리 보간이 정답에 가깝고, 과도한 완화는 악화된다. + + z=r 형상은 harmonic이 아니라 biharmonic이라, 라플라스 완화를 수렴시키면 마루가 + 눌린다. ANUDEM이 thin plate spline을 쓰는 이유(Hutchinson 1988/89)를 지킨다. + """ + from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import build_distance, relax_laplace + + n = 301 + centre = n // 2 + rows, cols = np.indices((n, n)) + radius = np.hypot(rows - centre, cols - centre) + truth = 140.0 - radius * 0.1 # 반경 1m마다 0.1m 하강 = 1m 등고선 간격 10m + burned = np.full((n, n), np.nan, dtype=np.float32) + for level in (110.0, 115.0, 120.0, 125.0, 130.0, 135.0): + burned[np.abs(radius - (140.0 - level) / 0.1) < 0.5] = level + band = (radius > 55) & (radius < 295) + + base = build_distance(None, burned, None, 1.0) + base_error = float(np.abs(base - truth)[band].mean()) + assert base_error < 0.5 # 거리 보간은 원뿔을 그대로 재현한다 + + light = base.copy() + relax_laplace(light, np.isfinite(burned), 5) + assert float(np.abs(light - truth)[band].mean()) <= base_error + 0.02 + + heavy = base.copy() + relax_laplace(heavy, np.isfinite(burned), 200) + # 수렴시키면 harmonic 해로 끌려가 오차가 눈에 띄게 커진다 + assert float(np.abs(heavy - truth)[band].mean()) > base_error + + +def test_tin_sheet_follows_source_contour_lines(sheet_project: dict) -> None: + """TIN(도엽선)은 원본 등고선 자리에서 그 표고를 그대로 낸다 — 격자 계단을 타지 않는다. + + 링을 셀 격자와 어긋난 위치(+0.37m)에 두어, 격자에 구운 라인 셀이 아니라 벡터 + 정점을 쓰는지 가른다. + """ + route_xy = np.array([[X0 - 200.0, Y0 - 200.0], [X0 + 200.0, Y0 + 200.0]], dtype=np.float64) + models = build_sheet_surface_model( + sheet_project["root"], + sheet_project["processed"], + sheet_project["models"], + route_xy, + EPSG, + methods=["tin_sheet"], + ) + assert len(models) == 1 + + sampler = build_surface_sampler(sheet_project["models"], "sheet_tin_sheet", "dtm", smooth=False) + # 각 링 변 위의 점 — 표고는 그 링의 값이어야 한다 + probes, expected = [], [] + for half, elev in ((300.0, 110.0), (150.0, 120.0)): + for offset in (-100.0, 0.0, 100.0): + probes.append([X0 + offset + 0.37, Y0 - half + 0.37]) + expected.append(elev) + z, valid = sampler.sample_xy(np.asarray(probes)) + assert valid.all() + assert np.abs(z - np.asarray(expected)).max() < 1.0 diff --git a/resources/tester/test_spoil_fill_mirror.py b/resources/tester/test_spoil_fill_mirror.py new file mode 100644 index 00000000..dc84e0b7 --- /dev/null +++ b/resources/tester/test_spoil_fill_mirror.py @@ -0,0 +1,154 @@ +"""사토장(유용토운반작업장) 성토 단면 — 파이썬·TS 짝이 같은 값을 내는지 (2026-09-09). + +화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽이 갈리면 +「그림은 이런데 수량은 저렇다」가 된다. + +⚠ 폭의 시작점이 **노면 끝**이라는 것과, 폭 상한이 **지반 샘플이 있는 데까지**라는 것을 + 함께 잠근다. 둘 다 사용자 확정이다. +""" + +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_spoil_fill import ( # noqa: E402 + solve_spoil_width_m, + spoil_fill_section, + spoil_max_width_m, +) + +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +#: 왼쪽으로 완만히 내려가는 지반. 노면 끝은 (2.0, 100.0). +GROUND = [(-10.0, 98.0), (0.0, 100.0), (5.0, 99.5), (10.0, 98.0), (20.0, 95.0), (30.0, 92.0)] + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import { spoilFillSection, solveSpoilWidthM, spoilMaxWidthM } from "./common_util_spoil_fill.js"; + +const ground = %s.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })); +const base = { ground, startOffsetM: 2.0, startElevationM: 100.0, side: "left" }; +const out = { + maxWidth: spoilMaxWidthM(base), + widths: [0, 2, 4, 8, 40].map((widthM) => { + const s = spoilFillSection({ ...base, widthM, slopeRatioN: 1.5 }); + return { area: s.area_m2, toe: s.toeOffsetM, unclosed: s.unclosed, line: s.line.map((p) => [p.offset_m, p.elevation_m]) }; + }), + right: (() => { + const s = spoilFillSection({ ...base, side: "right", startOffsetM: -2.0, widthM: 4, slopeRatioN: 1.5 }); + return { area: s.area_m2, toe: s.toeOffsetM, line: s.line.map((p) => [p.offset_m, p.elevation_m]) }; + })(), + solved: [10, 40, 100000].map((target) => { + const r = solveSpoilWidthM({ ...base, slopeRatioN: 1.5 }, target); + return { widthM: r.widthM, area: r.section.area_m2 }; + }), +}; +writeFileSync(process.argv[2], JSON.stringify(out)); +""" % json.dumps(GROUND) + + +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_spoil_fill.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 ts["maxWidth"] == pytest.approx(spoil_max_width_m(GROUND, 2.0, "left")) + for index, width in enumerate((0, 2, 4, 8, 40)): + py = spoil_fill_section(GROUND, 2.0, 100.0, "left", width, 1.5) + got = ts["widths"][index] + assert got["area"] == pytest.approx(py.area_m2), width + assert got["toe"] == pytest.approx(py.toe_offset_m), width + assert got["unclosed"] == py.unclosed, width + assert [tuple(point) for point in got["line"]] == py.line, width + + right = spoil_fill_section(GROUND, -2.0, 100.0, "right", 4, 1.5) + assert ts["right"]["area"] == pytest.approx(right.area_m2) + assert [tuple(point) for point in ts["right"]["line"]] == right.line + + for index, target in enumerate((10, 40, 100000)): + width, section = solve_spoil_width_m(GROUND, 2.0, 100.0, "left", 1.5, target) + assert ts["solved"][index]["widthM"] == pytest.approx(width), target + assert ts["solved"][index]["area"] == pytest.approx(section.area_m2), target + + +def test_폭은_노면_끝에서_잰다() -> None: + """시작점이 노면 끝이라 **그 구간 노견도 이 성토 안에 든다**(사용자 확정).""" + section = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 1.5) + assert section.line[0] == (2.0, 100.0) + assert section.line[1] == (6.0, 100.0) # 평상은 노면 끝 높이 그대로 + assert section.toe_offset_m > 6.0 + + +def test_넓힐수록_많이_담긴다() -> None: + areas = [spoil_fill_section(GROUND, 2.0, 100.0, "left", w, 1.5).area_m2 for w in (0, 2, 4, 8)] + assert areas == sorted(areas) + assert areas[0] < areas[-1] + + +def test_지반_샘플_밖으로는_안_넓힌다() -> None: + """샘플 밖은 지반을 모른다 — 넓히면 근거 없는 부피가 된다.""" + limit = spoil_max_width_m(GROUND, 2.0, "left") + assert limit == pytest.approx(28.0) + wide = spoil_fill_section(GROUND, 2.0, 100.0, "left", 100.0, 1.5) + assert wide.toe_offset_m <= 30.0 + assert wide.unclosed is True # 지반을 못 만나고 잘렸음을 드러낸다 + + +def test_담을_수_없는_양은_상한_폭을_돌려준다() -> None: + """임의로 더 넓히지 않는다 — 못 담는 몫은 부르는 쪽이 「남은 사토」로 드러낸다.""" + width, section = solve_spoil_width_m(GROUND, 2.0, 100.0, "left", 1.5, 1e6) + assert width == pytest.approx(spoil_max_width_m(GROUND, 2.0, "left")) + assert section.area_m2 < 1e6 + + +def test_기울기가_완만하면_더_담긴다() -> None: + steep = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 1.0) + gentle = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 2.0) + assert gentle.area_m2 > steep.area_m2 diff --git a/resources/tester/test_storage_symlink_root.py b/resources/tester/test_storage_symlink_root.py new file mode 100644 index 00000000..6c0ef74c --- /dev/null +++ b/resources/tester/test_storage_symlink_root.py @@ -0,0 +1,37 @@ +"""storage/가 링크(정션)일 때 업로드 경로 기준이 어긋나지 않는지. + +워크트리를 나눠 쓰면 `storage/`가 본 폴더로 가는 정션이다. 저장 엔진은 +`Path.resolve()`로 링크를 따라가는데 프로젝트 루트가 링크 경로 그대로면 +`chunk_path.relative_to(project_root)`가 ValueError로 터졌다(2026-08-30 실사고). +""" + +import os +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + + +def test_resolve_stored_project_path_follows_storage_link(tmp_path, monkeypatch): + real_storage = tmp_path / "real" / "storage" + (real_storage / "1" / "3" / "proj").mkdir(parents=True) + link_storage = tmp_path / "work" / "storage" + link_storage.parent.mkdir(parents=True) + try: + os.symlink(real_storage, link_storage, target_is_directory=True) + except (OSError, NotImplementedError) as exc: # 권한 없으면 검사 불가 + pytest.skip(f"심볼릭 링크를 만들 수 없음: {exc}") + + import common_util.common_util_storage as storage + + monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(link_storage)) + resolved = Path(storage.resolve_stored_project_path("storage/1/3/proj")) + + # 저장 엔진이 쓰는 기준(resolve)과 같은 경로여야 한다 + assert resolved == resolved.resolve() + # 그래야 엔진이 만든 하위 경로를 프로젝트 루트 기준 상대경로로 뺄 수 있다 + chunk = (resolved / "B03_FileInput" / "chunks_temp" / "s1" / "00000000.chunk").resolve() + assert chunk.relative_to(resolved).as_posix().startswith("B03_FileInput/") diff --git a/resources/tester/test_structure_face_role.py b/resources/tester/test_structure_face_role.py new file mode 100644 index 00000000..4f07fd9b --- /dev/null +++ b/resources/tester/test_structure_face_role.py @@ -0,0 +1,79 @@ +"""구조물 자리가 성토면인가 절토면인가 — 판정 한 벌 (2026-09-09). + +품셈 표준경사 표가 높이 × 메/찰 × **성토/절토** 로 갈리는데 성절토만 없었다. +새 입력을 만들지 않고 `design.section_mode` + 구조물 `side` 로 가린다. +⚠ 못 가르면 `None` — 성토로 눅이지 않는다. +""" + +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 common_util.common_util_structure_face_role import ( # noqa: E402 + CUT, + FILL, + structure_face_role, +) + + +@pytest.mark.parametrize( + ("mode", "side", "expected"), + [ + ("left_cut", "좌", CUT), + ("left_cut", "우", FILL), + ("right_cut", "좌", FILL), + ("right_cut", "우", CUT), + ("both_cut", "좌", CUT), + ("both_cut", "우", CUT), + ("both_fill", "좌", FILL), + ("both_fill", "우", FILL), + ], +) +def test_네_단면유형_좌우_여덟(mode: str, side: str, expected: str) -> None: + role, reason = structure_face_role(mode, side) + assert role == expected + assert mode in reason and expected in reason + + +def test_자동은_성토면이다() -> None: + role, reason = structure_face_role("right_cut", "자동(성토 쪽)") + assert role == FILL + assert "자동" in reason + + +def test_양쪽_절토에_자동이면_가를_근거가_없다() -> None: + """⚠ 예외 하나 — 성토면이 없어 어느 쪽인지 정할 근거가 없다.""" + role, reason = structure_face_role("both_cut", "자동(성토 쪽)") + assert role is None + assert "근거 없음" in reason + + +def test_양쪽_성토에_자동이면_성토면이다() -> None: + role, _ = structure_face_role("both_fill", "자동(성토 쪽)") + assert role == FILL + + +def test_모르는_값은_None_이고_성토로_눅이지_않는다() -> None: + for mode, side in (("", "좌"), ("cut_fill", "좌"), ("left_cut", "가운데"), (None, None)): + role, reason = structure_face_role(mode, side) + assert role is None, (mode, side) + assert "근거 없음" in reason + + +def test_옛_영문_표기도_받는다() -> None: + """저장분에 영문이 남아 있을 수 있다 — 같은 뜻으로 읽는다.""" + assert structure_face_role("left_cut", "left")[0] == CUT + assert structure_face_role("left_cut", "right")[0] == FILL + assert structure_face_role("right_cut", "auto")[0] == FILL + + +def test_까닭_문구는_한_줄로_실을_수_있다() -> None: + _, reason = structure_face_role("right_cut", "좌") + assert reason == "right_cut · 좌측 → 성토면" diff --git a/resources/tester/test_structure_length_spans.py b/resources/tester/test_structure_length_spans.py new file mode 100644 index 00000000..be0203f0 --- /dev/null +++ b/resources/tester/test_structure_length_spans.py @@ -0,0 +1,51 @@ +"""구조물 연장에 **구간 목록**을 함께 낸다 — 2026-09-08 B08 창 요청. + +왜 — 연장(m)만 내면 산출근거에 **「어디부터 어디까지」**를 못 적는다. 실무 내역은 「산마루측구 +40m」 한 줄이라 없어도 서지만, 있으면 검산이 쉬워진다. + +⚠ 겹침은 **합쳐서** 낸다. 데스크탑 실측 사례 — 산마루측구 둘(80~120 · 100~140)이 **60.0m** +로 합쳐져 내역서에 갔다(단순 합 80.0m). 구조물별로 셌으면 **20m 를 더 셀** 자리였다. +""" + +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_structure_lengths import _merge # noqa: E402 + + +def test_안_겹치면_그대로(): + merged = _merge([(35.0, 45.0), (55.0, 65.0)]) + assert merged == [(35.0, 45.0), (55.0, 65.0)] + assert sum(end - start for start, end in merged) == 20.0 + + +def test_겹치면_합친다(): + """데스크탑 실측 그대로 — 80~120 과 100~140 은 80~140 한 구간(60m)이다.""" + merged = _merge([(80.0, 120.0), (100.0, 140.0)]) + assert merged == [(80.0, 140.0)] + assert sum(end - start for start, end in merged) == 60.0 + + +def test_맞닿으면_한_구간(): + """끝점이 같은 두 구간(40~50 · 50~60)은 끊긴 것이 아니다.""" + assert _merge([(40.0, 50.0), (50.0, 60.0)]) == [(40.0, 60.0)] + + +def test_품은_구간은_사라진다(): + """작은 구간이 큰 구간 안에 통째로 들면 큰 것 하나만 남는다.""" + assert _merge([(10.0, 100.0), (30.0, 40.0)]) == [(10.0, 100.0)] + + +def test_행에_구간_목록이_실린다(): + """길이는 그 목록의 합과 **같아야** 한다 — 갈리면 산출근거와 수량이 어긋난다.""" + import inspect + + from common_util import common_util_structure_lengths as module + + source = inspect.getsource(module.structure_lengths) + assert '"spans": [' in source + assert '"length_m": round(sum(end - start for start, end in merged), 2)' in source diff --git a/resources/tester/test_toe_extrapolate.mjs b/resources/tester/test_toe_extrapolate.mjs new file mode 100644 index 00000000..dd37c791 --- /dev/null +++ b/resources/tester/test_toe_extrapolate.mjs @@ -0,0 +1,63 @@ +// 교차점 탐색·외삽(B06_Section_UI_Cross_Fit) 자체검증 — 로직 복제. +// 사면이 5m를 넘어도 실제 지반 교차점을 짚어야 한다(2026-08-23 사용자 보고). +// 실행: node tmp/tests/test_toe_extrapolate.mjs +import assert from "node:assert/strict"; + +const TREND_SPAN_M = 2; +const MAX = 60; +function extrapolateMeet(designAt, groundAt, limit, startOffset) { + const outward = Math.sign(limit - startOffset) || 1; + const back = limit - outward * TREND_SPAN_M; + const diffAtLimit = designAt(limit) - groundAt(limit); + const diffAtBack = designAt(back) - groundAt(back); + const closingRate = (Math.abs(diffAtBack) - Math.abs(diffAtLimit)) / TREND_SPAN_M; + if (!(closingRate > 1e-6)) return limit; + const extra = Math.abs(diffAtLimit) / closingRate; + if (!Number.isFinite(extra)) return limit; + return limit + outward * Math.min(extra, MAX); +} + +// 성토 사면 1:1.5(바깥으로 갈수록 0.667m/m 하강), 지반 평탄 → 실제 교차점 = 노견+15m. +const edge = 3; +const designAt = (o) => 10 - ((o - edge) * 2) / 3; +const groundAt = () => 0; +// 샘플 끝 15m에서 아직 안 만남(차이 2m) → 외삽이 실제 교차점 18m를 짚어야 한다. +const meet = extrapolateMeet(designAt, groundAt, 15, edge); +assert.ok(Math.abs(meet - 18) < 1e-6, `외삽 어긋남: ${meet}`); +// 반대쪽(음의 offset)도 부호가 맞아야 한다. +const designLeft = (o) => 10 - ((-edge - o) * 2) / 3; +const meetLeft = extrapolateMeet(designLeft, groundAt, -15, -edge); +assert.ok(Math.abs(meetLeft + 18) < 1e-6, `좌측 외삽 어긋남: ${meetLeft}`); +// 벌어지기만 하는 지형(지반이 사면보다 더 빨리 내려감)은 넓히지 않는다. +const divergingGround = (o) => -Math.abs(o) * 2; +assert.equal(extrapolateMeet(designAt, divergingGround, 15, edge), 15); +// 표시 반폭 = 올림(교차점) + 1m — 18m 교차점이면 19m. +assert.equal(Math.min(Math.ceil(Math.abs(meet) + 1), MAX), 19); +console.log("ok"); + +// ── 실제 교차점 탐색(meetOffset) — 성토 사면 5m 규칙에 잘리면 안 된다. +const SCAN_STEP_M = 0.1; +const MEET_TOLERANCE_M = 0.02; +function meetOffset(designAt, groundAt, startOffset, limitOffset, outward) { + const span = Math.abs(limitOffset - startOffset); + if (!(span > 0)) return startOffset; + const steps = Math.ceil(span / SCAN_STEP_M); + let previousDiff = designAt(startOffset) - groundAt(startOffset); + for (let i = 1; i <= steps; i += 1) { + const o = startOffset + outward * Math.min(SCAN_STEP_M * i, span); + const diff = designAt(o) - groundAt(o); + if (Math.abs(diff) <= MEET_TOLERANCE_M) return o; + if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return o; + previousDiff = diff; + } + return extrapolateMeet(designAt, groundAt, limitOffset, outward); +} + +// 사면 길이 약 18m(1:1.5, 높이 10m) — 5m 컷오프면 노견+약 4.2m에서 멈춘다. +const meetFar = meetOffset(designAt, groundAt, edge, 25, 1); +assert.ok(Math.abs(meetFar - 18) < 0.15, `교차점 탐색 어긋남: ${meetFar}`); +assert.ok(meetFar > edge + 5, "5m 사면 규칙에 잘렸다"); +// 설계선이 교차 전에 끝나면(15m까지만) 외삽으로 같은 자리를 짚는다. +const meetClipped = meetOffset(designAt, groundAt, edge, 15, 1); +assert.ok(Math.abs(meetClipped - 18) < 1e-6, `외삽 어긋남: ${meetClipped}`); +console.log("ok(fit)"); diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py new file mode 100644 index 00000000..970b0c52 --- /dev/null +++ b/resources/tester/test_user_values_survive.py @@ -0,0 +1,297 @@ +"""한 계통 회귀 묶음 — **사용자가 넣은 값이 재계산·재진입·다른 경유에서 사라지거나 갈리는 것**. + +2026-09-07 하루에 이 계통으로만 일곱 건이 나왔다. 하나하나는 자리도 원인도 다르지만 +증상은 같다 — **사용자는 넣었는데 결과에는 없다**. 그래서 각 고침의 시험은 제자리에 두고 +(사연이 그 시험 머리에 적혀 있다), 여기에는 **같은 계통을 한 번에 도는 얇은 묶음**을 둔다. +다음에 어느 하나가 다시 새면 이 파일이 먼저 깨진다. + +각 줄에 어느 결함의 재발인지 커밋과 한 줄 사연을 적었다. 셋을 지킨다 — +① 값을 넣는다 ② **재계산·재진입을 태운다** ③ 값이 그대로인지 수치로 본다. +②를 건너뛰고 저장만 보는 시험은 이 계통을 못 잡는다(일곱 건 전부 ②에서 샜다). + +화면(브라우저)이 있어야 도는 것은 넣지 않았다 — 이 묶음은 `pytest` 로 언제든 돌아야 한다. +""" + +import math +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 import B06_Section_Router_Design as design_mod # noqa: E402 +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 + +MIN_RADIUS = 12.0 + + +# ── ① [확인]을 누를 때마다 계획노선이 깎이던 것 (`19444149`) ──────────────────── +# 낸 결과를 다시 입력으로 넣으면 또 단순화돼 노선이 회차마다 짧아졌다. +def test_노선은_다시_넣어도_안_깎인다(): + points = [(float(x), 6.0 * math.sin(x / 40.0)) for x in range(0, 600, 5)] + once = build_planned_polyline(points, min_radius_m=MIN_RADIUS) + twice = build_planned_polyline( + [(node.x, node.y) for node in once.nodes], + min_radius_m=MIN_RADIUS, + simplify=False, + curve_flags=[True] * len(once.nodes), + radii=[None] * len(once.nodes), + ) + assert len(twice.nodes) == len(once.nodes), ( + f"두 번째에 꺾임점이 {len(once.nodes)} → {len(twice.nodes)} 로 줄었음" + ) + + +# ── ② 편집 한 번에 맞춰 둔 반지름이 하한으로 눌리던 것 (`a0bd9c87`) ───────────── +# 묶인 곡선의 교각점은 원본 꺾임점이 아니라, 그대로 되넣으면 묶음이 흩어져 R 이 하한이 됐다. +def _arc(radius_m: float) -> list[tuple[float, float]]: + points = [(float(x), 0.0) for x in range(-150, 0, 3)] + for degree in range(0, 91, 3): + angle = math.radians(degree) + points.append((radius_m * math.sin(angle), radius_m - radius_m * math.cos(angle))) + points += [(radius_m, radius_m + y) for y in range(3, 150, 3)] + return points + + +def _flatten(result): + """화면이 모달을 열 때 하는 것 — 묶인 구간을 교각점 하나로 갈아 끼운다.""" + replaced = {curve.node_first: curve for curve in result.curves} + dropped = { + index + for curve in result.curves + for index in range(curve.node_first + 1, curve.node_last + 1) + } + nodes, radii = [], [] + for index, node in enumerate(result.nodes): + if index in dropped: + continue + curve = replaced.get(index) + nodes.append(curve.apex if curve else (node.x, node.y)) + radii.append(curve.radius_m if curve else None) + return nodes, radii + + +def test_편집을_거쳐도_맞춰_둔_반지름이_남는다(): + first = build_planned_polyline(_arc(40.0), min_radius_m=MIN_RADIUS) + nodes, radii = _flatten(first) + again = build_planned_polyline( + nodes, + min_radius_m=MIN_RADIUS, + simplify=False, + curve_flags=[True] * len(nodes), + radii=radii, + ) + kept = max(curve.radius_m for curve in again.curves) + assert kept > 30.0, f"맞춰 둔 R 이 {kept:.1f}m 로 눌렸음" + + +def test_음성대조_갈아_끼우지_않으면_눌린다(): + """그물이 헛돌지 않는다는 증명 — 갈아 끼우기를 빼면 하한으로 눌린다.""" + first = build_planned_polyline(_arc(40.0), min_radius_m=MIN_RADIUS) + raw = [(node.x, node.y) for node in first.nodes] + again = build_planned_polyline( + raw, + min_radius_m=MIN_RADIUS, + simplify=False, + curve_flags=[True] * len(raw), + radii=[None] * len(raw), + ) + assert max(c.radius_m for c in again.curves) <= MIN_RADIUS + 1e-6 + + +# ── ③ 재계산이 다단 구간값을 지우던 것 (`b6941bd2`) ──────────────────────────── +_USER_VALUES = { + "extra_spans": {"extra0": {"length_m": 15.0, "before_m": 8.0, "after_m": 7.0}}, + "extra_wall_counts": {"outlet": 1, "basin": 0}, + "revet_adjust": {"outlet": {"x": 2.0, "d": 0.5, "h": None, "m": None}}, + "display_half_width_m": 12.5, +} + + +def _fake_engine(monkeypatch): + """지형·계획고·포장구간은 이 시험의 대상이 아니다 — 재계산 자리만 본다.""" + + def fake_compute(samples, elevation, **kwargs): + return {"paved": True, "cut_area_m2": 1.0, "fill_area_m2": 2.0} + + 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: {}) + + +def _section() -> dict: + return {"chainage_m": 100.0, "samples": [], "design": {"paved": False, **_USER_VALUES}} + + +def test_포장_재계산이_구조물_조작값을_안_지운다(monkeypatch): + _fake_engine(monkeypatch) + sections = [_section()] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 + for key, value in _USER_VALUES.items(): + assert sections[0]["design"].get(key) == value, f"{key} 가 재계산에서 사라짐" + + +def test_음성대조_목록에서_빼면_사라진다(monkeypatch): + """그물이 헛돌지 않는다는 증명 — 보존 목록에서 빼면 그 값이 즉시 사라진다.""" + _fake_engine(monkeypatch) + trimmed = tuple(k for k in design_mod.USER_TOUCHED_KEYS if k != "extra_spans") + monkeypatch.setattr(design_mod, "USER_TOUCHED_KEYS", trimmed) + sections = [_section()] + design_mod.enforce_pavement_ranges({}, sections, Path("."), None) + assert sections[0]["design"].get("extra_spans") is None + + +# ── ④ 사용자가 켠 2단 절토가 재계산에서 사라지던 것 (`b8f0f7ad`) ──────────────── +# 엔진이 **적용 결과**를 저장해, 못 쓰는 자리(토사)에서 켬이 false 로 굳고 되먹여졌다. +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 test_토사에서_켠_2단_절토가_왕복해도_남는다(): + def once(flag: bool) -> dict: + return compute_cross_design( + _ground(), + 100.4, + ground_type="soil", + section_mode="left_cut", + ditch_side=None, + ditch_type="standard", + paved=False, + standard=None, + rock_boundary_offset_m=-1.5, + two_stage_slope=flag, + ditch_enabled=None, + ) + + first = once(True) + assert first["two_stage_slope"] is True + assert once(bool(first["two_stage_slope"]))["two_stage_slope"] is True + + +# ── ⑤ 노선 변경 때 옛 사용자 설계가 덮여 사라지던 것 (`908c75d5`) ─────────────── +# 이월을 측점 재생성 **앞**에서 하면 재생성이 덮어썼다. 순서가 곧 고침이라 순서를 지킨다. +def test_이월은_측점_재생성_뒤에_한다(): + source = (PROJECT_ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text( + encoding="utf-8" + ) + rebuild = source.index("_prepare_drainage_pipes_and_reprofile(project_id, new_route_id") + carry = source.index("merge_cross_section_designs(") + assert carry > rebuild, "이월이 측점 재생성보다 앞섰음 — 덮어써서 사라진다" + + +# ── ⑥ 암 경계선 오프셋을 읽는 쪽이 옛 키를 보던 것 (`f8fafd23`) ───────────────── +def test_암경계선은_등록표_키로_읽는다(): + persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text( + encoding="utf-8" + ) + assert 'readState>("rockb"' in persist + + +# ── ⑦ 저장된 표준 횡단면이 새 탭에서 안 서던 것 (`72e50b3e`) ─────────────────── +def test_저장된_표준단면이_브라우저까지_간다(): + from B06_Section.B06_Section_Schema import SectionContextResponse + + assert "stored_standard_cross_section" in SectionContextResponse.model_fields + fetch = (PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts").read_text(encoding="utf-8") + assert "function seedStandardCross(" in fetch + assert fetch.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움" + + +# ── ⑨ 다단 기슭막이가 물량에 안 닿던 것 (`e9ad8cdf`) ────────────────────────── +# 다단 벽의 성토부선이 면적 트림에 없어, 단을 올려도 폐회로가 그대로라 **물량이 안 바뀌었다**. +# 사용자가 「그대로 둠」으로 확정한 변경이라(3-5) 되돌아가면 조용히 금액이 줄어든다. +# ⚠ 값이 아니라 구조로 본다 — 면적을 실제로 내려면 `structureAreaRows` 를 태워야 하는데 +# 그 의존 사슬이 `@ui/ui_template_elements`(DOM)까지 끌고 와 화면 없이 못 돈다. +# 값으로 재는 확인은 화면 실측에 있음(보조 창 b269ea34 81.77m — 단 0 → 1 에서 성토 +# 10.9027 → 10.6743㎡). 계획서에 그 수치가 남아 있다. +def test_다단_성토부선이_면적_트림에_들어간다(): + source = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text( + encoding="utf-8" + ) + assert "const extendTrimSlope = (" in source, "트림 확장 자리가 사라짐" + # 유출측·집수정 계류측 **둘 다** — 한쪽만 넣으면 그쪽 단만 물량에 잡힌다. + assert "extendTrimSlope(drawnFillPoints(extras)" in source, "유출측 다단이 빠짐" + assert "extendTrimSlope(drawnFillPoints(basinExtras)" in source, "집수정 계류측 다단이 빠짐" + # 그리지 않는 `cut` 갈래(벽이 묻힌 자리)까지 넣으면 면적이 거꾸로 부푼다. + assert 'segment.kind !== "cut"' in source, "묻힌 구간을 안 걸러냄" + + +# ── ⑧ 암 경계선이 전 구간 재계산 뒤에도 남는 것 (`f8fafd23`) ────────────────── +# 보조 창 화면 실측(8879c53b) — 180.0m 에서 경계를 -0.5 → -0.9m 로 내리니 절토가 +# 토사 1.83 → 3.22 · 리핑암 1.78 → 0.89 로 갈렸고, **종단 계획고를 한 칸 올려 전 측점을 +# 다시 계산한 뒤에도** 그대로였다(손 안 댄 196.1m 도 그대로). 그 창구를 오프셋을 빼고 +# 부르면 A 가 3.5178 → 1.9989㎡ 로 되돌아갔다 — 값이 실제로 실려 간다는 음성 대조다. +# 여기서는 같은 성질을 엔진에서 수치로 본다: **경계를 내리면 토사가 늘고 암이 준다.** +def test_암_경계선을_내리면_토사가_늘고_암이_준다(): + def once(offset_m: float) -> dict: + return compute_cross_design( + _ground(0.45), + 100.6, + ground_type="ripping_rock", + section_mode="left_cut", + ditch_side=None, + ditch_type="standard", + paved=False, + standard=None, + rock_boundary_offset_m=offset_m, + two_stage_slope=True, + ditch_enabled=None, + ) + + shallow, deep = once(-0.5), once(-0.9) + assert shallow["cut_soil_area_m2"] > 0 and shallow["cut_rock_area_m2"] > 0, ( + "토사·암이 둘 다 나오는 자리라야 나눔이 갈리는 것을 볼 수 있음" + ) + assert deep["cut_soil_area_m2"] > shallow["cut_soil_area_m2"], ( + "경계를 내렸는데 토사가 안 늘었음" + ) + assert deep["cut_rock_area_m2"] < shallow["cut_rock_area_m2"], "경계를 내렸는데 암이 안 줄었음" + # ⚠ 합계는 안 잡는다 — 2단 절토의 무릎이 경계와 함께 움직여 **총 절토도 달라진다** + # (실측 0.9153 → 1.0512㎡). 여기서 지키는 것은 「경계가 나눔에 실제로 닿는다」이다. + + +# ── ⑩ 소단이 재계산에서 계단째 사라지던 것 (2026-09-07, 25 가 고침) ───────────── +# 소단은 다른 사용자 값과 **성질이 다르다** — 값을 나르는 게 아니라 **기하 입력**이다. +# 그래서 계산이 끝난 뒤 키만 베껴 붙이면 **설계선·면적은 계단 없이 나오고 `berm` 값만 남아** +# 저장분과 그림이 어긋난다. 고침은 순서였다: 저장분 소단을 **계산 전에** 읽어 넣는 것. +# 이 줄은 그 순서를 지킨다 — 되돌리면 「값은 있는데 계단이 없는」 자리가 되살아난다. +def test_소단은_계산_전에_읽어_넣는다(): + source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Router_Design.py").read_text( + encoding="utf-8" + ) + # 재계산이 저장분 소단을 **인자로** 넣는다(계산 뒤 베껴 붙이는 것이 아니다). + assert source.count("berm=stored_berm(design)") >= 2, ( + "포장·세월교 재계산 경로가 저장분 소단을 안 싣는다 — 계단이 사라진다" + ) + # 보존 목록에도 있어야 사용자가 놓은 값이 지워지지 않는다. + assert '"berm"' in source, "소단이 사용자 값 보존 목록에서 빠졌다" + + +# ── ⑪ 절토 경사도 같은 성질 — 「기하 입력」이다 (2026-09-07 사용자 지시) ───────── +# 사용자가 카드에 넣는 암 절토각은 소단과 같은 자리다. 계산 뒤에 키만 베껴 붙이면 설계선은 +# 옛 경사로 그려지고 숫자만 새것이 된다. 게다가 **표준 횡단면 설정을 바꿔도 개별로 고친 +# 측점은 그대로**여야 한다(사용자 원문: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」). +def test_절토_경사도_계산_전에_넣는다(): + source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Router_Design.py").read_text( + encoding="utf-8" + ) + # 재계산 세 경로(포장 강제·세월교 하강·선형 재계산)가 저장분 경사를 **인자로** 넣는다. + assert source.count("cut_slope_ratio=") >= 3, ( + "재계산 경로가 저장분 절토 경사를 안 싣는다 — 그림과 값이 어긋난다" + ) + assert '"cut_slope_ratio_user"' in source, "절토 경사가 사용자 값 보존 목록에서 빠졌다"