chore(tester): 시험을 resources/tester/ 로 옮김 — 창끼리 건너가게
⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt` 가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는 시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음. - `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침** - `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시) - 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q` 옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0 ⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 블록 라이브러리 저장·해제 검증 (조사표 4절 BLOCK/INSERT).
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config vitest.tmp.config.ts
|
||||
* jsdom을 새로 깔지 않으려고 이 모듈이 쓰는 브라우저 전역 둘만 흉내 낸다.
|
||||
*/
|
||||
import { Point, Segment } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
Object.assign(globalThis, {
|
||||
window: { dispatchEvent: () => true },
|
||||
CustomEvent: class {
|
||||
constructor(public type: string) {}
|
||||
},
|
||||
localStorage: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
},
|
||||
});
|
||||
|
||||
const { deleteBlock, deserializeBlock, getBlocks, saveBlockFromEntities } = await import(
|
||||
'../../../B07_DesignDetail/openwebcad/src/blocks/block-library'
|
||||
);
|
||||
const { LineEntity } = await import(
|
||||
'../../../B07_DesignDetail/openwebcad/src/entities/LineEntity'
|
||||
);
|
||||
|
||||
describe('블록 라이브러리', () => {
|
||||
it('기준점은 선택 영역 중심이고, 다시 풀어 삽입점으로 옮기면 그 점이 중심이 된다', async () => {
|
||||
const line = new LineEntity('layer-1', new Segment(new Point(0, 0), new Point(100, 50)));
|
||||
await saveBlockFromEntities('테스트블록', [line]);
|
||||
|
||||
const block = getBlocks().find((candidate) => candidate.name === '테스트블록');
|
||||
expect(block).toBeDefined();
|
||||
if (!block) return;
|
||||
expect(block.basePoint).toEqual({ x: 50, y: 25 });
|
||||
|
||||
const [copy] = await deserializeBlock(block);
|
||||
expect(copy.id).not.toBe(line.id);
|
||||
expect(copy.groupId).toBeTruthy();
|
||||
|
||||
// 삽입점 (200, 100)으로 옮기면 원본 크기 100×50이 그 점을 중심으로 놓인다
|
||||
copy.move(200 - block.basePoint.x, 100 - block.basePoint.y);
|
||||
const box = copy.getBoundingBox();
|
||||
expect([box.xmin, box.ymin, box.xmax, box.ymax]).toEqual([150, 75, 250, 125]);
|
||||
|
||||
deleteBlock('테스트블록');
|
||||
expect(getBlocks().find((candidate) => candidate.name === '테스트블록')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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__)))))
|
||||
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""신규 프로젝트에서 구조물이 안 보이는 원인 진단 — 읽기 전용.
|
||||
|
||||
실행: ./venv/Scripts/python.exe tmp/tests/diag_structures.py [프로젝트UUID]
|
||||
인자를 생략하면 storage 아래에서 **가장 최근에 만들어진** 프로젝트를 고른다.
|
||||
|
||||
자동설계 체인이 남겨야 할 것을 한 줄씩 대조한다:
|
||||
1. 배관 정본(edits/pipe_points.json) — 관·세월교·BOX 지정
|
||||
2. 종단 정본 stations[].structure — 구조물 측점 표식
|
||||
3. 횡단 정본 cross_*.json 의 structure — 측점 파일
|
||||
4. B06 읽기 경로(load_culvert_sets) — 실제로 세트가 붙는가
|
||||
5. 초기값 스냅샷 — 체인 직후 상태가 떠졌는가
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def newest_project() -> Path | None:
|
||||
candidates = [p for p in (ROOT / "storage").glob("*/*/*") if (p / "project_manifest.json").is_file()]
|
||||
return max(candidates, key=lambda p: p.stat().st_mtime) if candidates else None
|
||||
|
||||
|
||||
def read_json(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) > 1:
|
||||
matches = [p for p in (ROOT / "storage").glob(f"*/*/{sys.argv[1]}")]
|
||||
project = matches[0] if matches else None
|
||||
else:
|
||||
project = newest_project()
|
||||
if project is None or not project.is_dir():
|
||||
print("프로젝트를 찾지 못했습니다.")
|
||||
return 1
|
||||
print(f"프로젝트: {project}")
|
||||
|
||||
# 1) 배관 정본
|
||||
pipes_path = project / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json"
|
||||
pipes = read_json(pipes_path)
|
||||
points = (pipes or {}).get("points") or []
|
||||
print(f"1) pipe_points.json 존재={pipes_path.is_file()} 관={len(points)}건")
|
||||
for point in points:
|
||||
print(
|
||||
f" - {point.get('chainage_m')}m facility={point.get('facility') or 'pipe'}"
|
||||
f" options={list((point.get('options') or {}).keys())}"
|
||||
)
|
||||
if pipes:
|
||||
print(f" route_signature={str(pipes.get('route_signature'))[:16]}…")
|
||||
|
||||
# 2) 종단 정본 구조물 표식
|
||||
long_path = project / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
longitudinal = read_json(long_path) or {}
|
||||
stations = longitudinal.get("stations") or []
|
||||
marked = [s for s in stations if s.get("structure")]
|
||||
print(f"2) longitudinal.json 측점={len(stations)} 구조물 표식={len(marked)}건")
|
||||
for station in marked:
|
||||
print(f" - {station.get('chainage_m')}m {station.get('structure')} kind={station.get('kind')}")
|
||||
|
||||
# 3) 횡단 정본 파일
|
||||
cross_dir = project / "B06_Section" / "cross_sections"
|
||||
files = sorted(cross_dir.glob("cross_*.json")) if cross_dir.is_dir() else []
|
||||
with_structure = [(p.name, read_json(p).get("structure")) for p in files if (read_json(p) or {}).get("structure")]
|
||||
print(f"3) cross_*.json 파일={len(files)}개 구조물 표식={len(with_structure)}건 {with_structure}")
|
||||
|
||||
# 4) B06 읽기 경로 — 실제로 세트가 붙는가
|
||||
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
|
||||
|
||||
sets = load_culvert_sets(project)
|
||||
print(f"4) load_culvert_sets 세트={len(sets)}건")
|
||||
for chainage, spec in sorted(sets.items()):
|
||||
print(f" - {chainage}m kind={spec.get('kind')}")
|
||||
# 측점과 붙을 수 있는가 (허용오차 0.02m)
|
||||
chainages = [float(s.get("chainage_m", -1)) for s in stations]
|
||||
for chainage in sorted(sets):
|
||||
hit = any(abs(chainage - c) <= 0.02 for c in chainages)
|
||||
if not hit:
|
||||
print(f" ⚠ {chainage}m 에 붙을 측점이 없다 — 횡단도에 구조물이 안 뜬다")
|
||||
|
||||
# 5) 초기값 스냅샷
|
||||
snapshot = project / "initial_snapshot"
|
||||
snap_pipes = read_json(snapshot / "B04_PreProcess__drainage__edits" / "pipe_points.json")
|
||||
print(
|
||||
f"5) initial_snapshot 존재={snapshot.is_dir()}"
|
||||
f" 스냅샷 관={len((snap_pipes or {}).get('points') or [])}건"
|
||||
)
|
||||
lock = project / "initial_design.lock"
|
||||
print(f" initial_design.lock(체인 진행 중 표시)={lock.exists()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,111 @@
|
||||
/* B05 잘린 자리 윤곽(`cut-merged`) 단위검증 헬퍼 — `_Corridor_Cut.ts`를 그 자리에서
|
||||
* 트랜스파일해 Node로 돌린다(타입 import뿐이라 의존 없음). 2026-09-02 */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Corridor_Cut.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const js = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const moduleBox = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require);
|
||||
const { maskFillByPlanCurves } = moduleBox.exports;
|
||||
|
||||
/** 직선 노선 성토 리본 — x = 종방향(1m/행), y = 횡방향(1m/열), z = 0. */
|
||||
function ribbonOf(rows, cols) {
|
||||
const positions = new Float64Array(rows * cols * 3);
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let col = 0; col < cols; col += 1) {
|
||||
const i = (row * cols + col) * 3;
|
||||
positions[i] = row;
|
||||
positions[i + 1] = col;
|
||||
positions[i + 2] = 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "fill",
|
||||
side: "left",
|
||||
colCount: cols,
|
||||
chainages: Array.from({ length: rows }, (_v, row) => row),
|
||||
positions,
|
||||
groundZ: new Float64Array(rows * cols),
|
||||
};
|
||||
}
|
||||
|
||||
/** 절취 영역 — 직사각형 x∈[x0,x1], y∈[y0,y1] (닫힘점 포함). */
|
||||
function rectCurve(x0, x1, y0, y1) {
|
||||
return {
|
||||
setChainageM: (x0 + x1) / 2,
|
||||
source: "slope-projected",
|
||||
role: "inlet",
|
||||
side: "left",
|
||||
planZ: 10,
|
||||
loops: [
|
||||
[
|
||||
[x0, y0, 10],
|
||||
[x1, y0, 10],
|
||||
[x1, y1, 10],
|
||||
[x0, y1, 10],
|
||||
[x0, y0, 10],
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function insideLoop(loop, x, y) {
|
||||
let hit = false;
|
||||
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
|
||||
const [xi, yi] = loop[i];
|
||||
const [xj, yj] = loop[j];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
function run(rows, cols, curve) {
|
||||
const ribbon = ribbonOf(rows, cols);
|
||||
const { ribbons, curves } = maskFillByPlanCurves([ribbon], [curve]);
|
||||
const cut = ribbons[0];
|
||||
const merged = curves.filter((c) => c.source === "cut-merged");
|
||||
const loops = merged.flatMap((c) => c.loops);
|
||||
let masked = 0;
|
||||
let covered = 0;
|
||||
const uncovered = [];
|
||||
if (cut.cellMask) {
|
||||
for (let row = 0; row < rows - 1; row += 1) {
|
||||
for (let col = 0; col < cols - 1; col += 1) {
|
||||
if (!cut.cellMask[row * (cols - 1) + col]) continue;
|
||||
masked += 1;
|
||||
const cx = row + 0.5;
|
||||
const cy = col + 0.5;
|
||||
if (loops.some((loop) => insideLoop(loop, cx, cy))) covered += 1;
|
||||
else uncovered.push([row, col]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
masked,
|
||||
covered,
|
||||
uncovered,
|
||||
loops: loops.map((loop) => ({
|
||||
n: loop.length,
|
||||
closed:
|
||||
loop.length > 1 &&
|
||||
loop[0][0] === loop[loop.length - 1][0] &&
|
||||
loop[0][1] === loop[loop.length - 1][1],
|
||||
points: loop.map(([x, y]) => [x, y]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const out = {
|
||||
// 직사각형 — 시작 모서리(첫 행 첫 열)가 살아야 4모서리 + 닫힘점 = 5.
|
||||
rect: run(12, 6, rectCurve(1.9, 7.1, -0.5, 3.4)),
|
||||
// 한 셀짜리 — 가장 작은 고리도 4모서리를 다 지킨다.
|
||||
single: run(6, 4, rectCurve(1.9, 3.1, 0.9, 2.1)),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,85 @@
|
||||
/* B05 패치 스커트 단위검증 헬퍼 — TS를 그 자리에서 트랜스파일해 Node로 돌린다.
|
||||
* (프론트에 JS 테스트 러너가 없어 pytest가 이 스크립트를 호출한다 — 2026-08-27) */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Corridor_Skirt.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const js = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const moduleBox = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require);
|
||||
const { buildPatchSkirts } = moduleBox.exports;
|
||||
|
||||
/** 직선 노선 패치 리본 하나 — 바깥 열 z를 행마다 지정해 만든다. */
|
||||
function ribbonOf(outerZ, options = {}) {
|
||||
const cols = 3;
|
||||
const rows = outerZ.length;
|
||||
const positions = new Float64Array(rows * cols * 3);
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let col = 0; col < cols; col += 1) {
|
||||
const i = (row * cols + col) * 3;
|
||||
positions[i] = row; // x = 종방향 1m 간격
|
||||
positions[i + 1] = col; // y = 횡방향(마지막 열이 바깥)
|
||||
positions[i + 2] = col === cols - 1 ? outerZ[row] : 100;
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "fill",
|
||||
side: "left",
|
||||
colCount: cols,
|
||||
chainages: outerZ.map((_z, row) => row),
|
||||
positions,
|
||||
patch: true,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
// ① 뜬 모서리 — 바깥 끝이 지반(90)보다 0.5m 위: 마디마다 판 2장씩.
|
||||
{
|
||||
const ribbon = ribbonOf([90.5, 90.5, 90.5]);
|
||||
const { walls, stats } = buildPatchSkirts([ribbon], () => 90);
|
||||
results.floating = {
|
||||
walls: walls.length,
|
||||
triangles: stats.triangles,
|
||||
hatched: walls.every((w) => w.uvs && w.uvs.length === (w.positions.length / 3) * 2),
|
||||
marked: walls.every((w) => w.patchSkirt === true),
|
||||
};
|
||||
}
|
||||
|
||||
// ② 잠긴 모서리 — 바깥 끝이 지반 아래: 판 없음(지형이 덮는다).
|
||||
{
|
||||
const ribbon = ribbonOf([89.5, 89.5, 89.5]);
|
||||
const { walls, stats } = buildPatchSkirts([ribbon], () => 90);
|
||||
results.submerged = { walls: walls.length, skipped: stats.skippedSubmerged };
|
||||
}
|
||||
|
||||
// ③ 구조물에서 끝나는 줄 — 소유 측점(가운데 행) 낙차 2m > 허용 0.5m: 리본 전체 제외.
|
||||
{
|
||||
const ribbon = ribbonOf([92, 92, 92]);
|
||||
const { walls, stats } = buildPatchSkirts([ribbon], () => 90);
|
||||
results.toeGate = { walls: walls.length, reaches: stats.detail[0].reachesGround };
|
||||
}
|
||||
|
||||
// ④ 낙차 상한 — 게이트는 통과(가운데 0.2m)하되 한 마디가 3m 넘게 뜨면 그 마디만 건너뜀.
|
||||
{
|
||||
const ribbon = ribbonOf([90.2, 94, 90.2, 90.2]);
|
||||
const { walls, stats } = buildPatchSkirts([ribbon], () => 90);
|
||||
results.tall = { walls: walls.length, skippedTall: stats.skippedTall, tris: stats.triangles };
|
||||
}
|
||||
|
||||
// ⑤ 비패치·절토 리본은 무시.
|
||||
{
|
||||
const plain = ribbonOf([90.5, 90.5], { patch: undefined });
|
||||
const cut = ribbonOf([90.5, 90.5], { kind: "cut" });
|
||||
const { walls } = buildPatchSkirts([plain, cut], () => 90);
|
||||
results.ignored = { walls: walls.length };
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
@@ -0,0 +1,114 @@
|
||||
"""B06 성토사면 경사길이 교차검증 헬퍼 (2026-09-03).
|
||||
|
||||
화면(`B06_Section_UI_Cross_Fit.fillSlopeLengths`)이 낸 값을 **API 원본으로 다시 계산해**
|
||||
대조한다. 프론트 구현과 독립적으로 설계선·지반선만 보고 재는 것이 목적이라 pytest 가
|
||||
아니라 실행 스크립트다.
|
||||
|
||||
1) 공용 브라우저에서 `/api/projects/{pid}/sections/{route}/detail` 응답을 파일로 저장
|
||||
2) ./venv/Scripts/python.exe tmp/tests/helper_b06_fill_slope_length.py <그 파일> [측점...]
|
||||
|
||||
「≥」는 사면이 설계선 끝(계산 반폭)까지 원지반을 못 만난 측점 — 거기까지의 하한값이다.
|
||||
허용 오차 — 화면은 교차 판정에 2cm 허용오차(`MEET_TOLERANCE_M`)를 쓰고 여기서는 부호
|
||||
전환만 보므로, 사면이 지반과 나란히 붙는 자리에서 최대 0.15m 차이가 난다(2026-09-03 실측).
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
|
||||
def interp(points):
|
||||
"""(offset, elevation) 목록의 선형보간 함수와 정의역 양 끝을 준다."""
|
||||
pts = sorted(points, key=lambda t: t[0])
|
||||
|
||||
def at(x):
|
||||
if x <= pts[0][0]:
|
||||
return pts[0][1]
|
||||
if x >= pts[-1][0]:
|
||||
return pts[-1][1]
|
||||
lo, hi = 0, len(pts) - 1
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if pts[mid][0] <= x:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
(x0, z0), (x1, z1) = pts[lo], pts[hi]
|
||||
return z0 if x1 == x0 else z0 + (z1 - z0) * (x - x0) / (x1 - x0)
|
||||
|
||||
return at, pts[0][0], pts[-1][0]
|
||||
|
||||
|
||||
def slope_starts(design):
|
||||
"""좌·우 사면 시작 오프셋 — 노견 끝(그 측에 측구가 있으면 측구 바깥)."""
|
||||
edges = design["road_edges"]
|
||||
left = max(edges["left"]["offset_m"], edges["right"]["offset_m"])
|
||||
right = min(edges["left"]["offset_m"], edges["right"]["offset_m"])
|
||||
spec = design.get("ditch") or {"type": "none"}
|
||||
width = 0.0
|
||||
if design.get("ditch_enabled") is not False and spec.get("type") != "none":
|
||||
width = spec["top_width_m"] if spec["type"] == "standard" else spec["width_m"]
|
||||
return {
|
||||
"left": left + (width if design.get("ditch_side") == "left" else 0.0),
|
||||
"right": right - (width if design.get("ditch_side") == "right" else 0.0),
|
||||
}
|
||||
|
||||
|
||||
def fill_slope_lengths(section, step=0.005):
|
||||
"""성토측 사면 경사길이 — {side: (길이 m, 미교차 여부)}."""
|
||||
design = section["design"]
|
||||
ground_at, _, _ = interp(
|
||||
[
|
||||
(p["offset_m"], p["elevation_m"])
|
||||
for p in section["samples"]
|
||||
if p.get("valid") is not False and p.get("elevation_m") is not None
|
||||
]
|
||||
)
|
||||
design_at, line_min, line_max = interp(
|
||||
[(p["offset_m"], p["elevation_m"]) for p in design["design_line"]]
|
||||
)
|
||||
starts = slope_starts(design)
|
||||
slant = math.hypot(1, 1 / design["fill_slope_ratio"])
|
||||
result = {}
|
||||
for side in ("left", "right"):
|
||||
if design["section_mode"] in ("both_cut", f"{side}_cut"):
|
||||
continue
|
||||
outward = 1 if side == "left" else -1
|
||||
start = starts[side]
|
||||
limit = line_max if side == "left" else line_min
|
||||
previous = design_at(start) - ground_at(start)
|
||||
offset, meet = start, None
|
||||
while (limit - offset) * outward > 0:
|
||||
offset = start + outward * min(abs(offset - start) + step, abs(limit - start))
|
||||
diff = design_at(offset) - ground_at(offset)
|
||||
if previous != 0 and (diff > 0) != (previous > 0):
|
||||
# 부호가 뒤집힌 두 걸음 사이를 선형보간한 자리가 사면 끝이다.
|
||||
meet = offset - outward * step + outward * step * (previous / (previous - diff))
|
||||
break
|
||||
previous = diff
|
||||
end = limit if meet is None else meet
|
||||
result[side] = (abs(end - start) * slant, meet is None)
|
||||
return result
|
||||
|
||||
|
||||
def main(argv):
|
||||
detail = json.loads(pathlib.Path(argv[1]).read_text(encoding="utf-8"))
|
||||
want = {round(float(value), 1) for value in argv[2:]}
|
||||
for section in detail["cross_sections"]:
|
||||
if not section.get("design"):
|
||||
continue
|
||||
chainage = round(section["chainage_m"], 1)
|
||||
if want and chainage not in want:
|
||||
continue
|
||||
lengths = fill_slope_lengths(section)
|
||||
if not lengths:
|
||||
continue
|
||||
text = " ".join(
|
||||
f"{side}={'≥' if open_ else ''}{length:.2f}m" for side, (length, open_) in lengths.items()
|
||||
)
|
||||
print(f"ch={chainage:8.1f} {section['design']['section_mode']:10s} {text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""소단 + 다중 무릎에서 **제자리 무릎이 되풀이되어 안 끝나는지** 재현한다.
|
||||
|
||||
의심 — 암 경계선의 기울기가 **암 경사와 토사 경사 사이**에 있으면, 경계에 정확히 닿은 자리에서
|
||||
① 토사로 바꾸면 다음 걸음이 경계 아래로 내려가 「다시 암」 ② 암으로 바꾸면 다음 걸음이 경계
|
||||
위로 올라가 「다시 토사」 가 되어 **같은 자리에서 앞뒤로 뒤집힌다**. `share`(보간 비율)가 0 이라
|
||||
거리가 한 걸음도 안 나가므로 `while dist < limit` 이 끝나지 않는다.
|
||||
"""
|
||||
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from common_util.common_util_cross_berm import BermSpec, cut_profile_points # noqa: E402
|
||||
|
||||
# 경계선 기울기를 **두 설계 경사 사이**에 둔다 — 암 1:0.4 는 수평 1m 에 2.5m 오르고,
|
||||
# 토사 1:1.0 은 1.0m 오른다. 그 사이(1.5m/1m)면 「토사로 바꾸면 경계 아래, 암으로 바꾸면
|
||||
# 경계 위」가 되어 같은 자리에서 뒤집힌다.
|
||||
BOUNDARY_RISE = 1.5
|
||||
|
||||
|
||||
def boundary(dist: float) -> float:
|
||||
"""시작점에서 정확히 만나고, 그 뒤로 두 경사 사이 기울기로 오르는 경계선."""
|
||||
return 100.0 + dist * BOUNDARY_RISE
|
||||
|
||||
|
||||
def run(multi_knee: bool, seconds: int = 8):
|
||||
def on_alarm(signum, frame): # noqa: ARG001
|
||||
raise TimeoutError("끝나지 않음")
|
||||
|
||||
try:
|
||||
points = cut_profile_points(
|
||||
0.0, 100.0, 0.4, 1.0, boundary, BermSpec(0.5, 3.0, 0.0), 50.0, multi_knee
|
||||
)
|
||||
return f"끝남 · 꼭짓점 {len(points)}개"
|
||||
except RecursionError as exc:
|
||||
return f"RecursionError {exc}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import threading
|
||||
|
||||
for multi in (False, True):
|
||||
result = {"v": None}
|
||||
thread = threading.Thread(target=lambda: result.__setitem__("v", run(multi)), daemon=True)
|
||||
thread.start()
|
||||
thread.join(8)
|
||||
state = result["v"] if not thread.is_alive() else "⚠ 8초 안에 안 끝남 (무한 반복)"
|
||||
print(f"multi_knee={multi}: {state}")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""저장된 프로젝트마다 관 지점 읽기가 **어느 가지**로 가는지 잰다 (계획서 0-7).
|
||||
|
||||
투영 이월 가지를 지워도 되는지 판단하는 근거다 — 실제 자료에서 그 가지가 안 돌면
|
||||
「지문 일치」·「허용오차 안」 둘 중 하나로 끝난다는 뜻이다. 값은 안 고친다(읽기만).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from common_util.common_util_drainage_pipes import ( # noqa: E402
|
||||
ROUTE_MATCH_TOLERANCE_M,
|
||||
max_projection_shift,
|
||||
parse_pipe_points,
|
||||
)
|
||||
from common_util.common_util_route_geometry import RouteVertex # noqa: E402
|
||||
from config.config_system import STORAGE_BASE_DIR # noqa: E402
|
||||
|
||||
ROOT = Path(STORAGE_BASE_DIR)
|
||||
|
||||
|
||||
def route_vertices(project_dir: Path):
|
||||
"""B05 가 읽는 노선 정점 — `route_main.geojson`."""
|
||||
for name in ("route_main.geojson", "planned_route.geojson"):
|
||||
for path in project_dir.rglob(name):
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
# 파일 꼴이 둘이다 — Feature 하나짜리와 FeatureCollection.
|
||||
geoms = [data.get("geometry") or {}]
|
||||
geoms += [(f.get("geometry") or {}) for f in (data.get("features") or [])]
|
||||
for geom in geoms:
|
||||
if geom.get("type") == "LineString" and geom.get("coordinates"):
|
||||
# 투영은 x·y 만 본다 — z·누가거리는 0 으로 채운다(읽기 전용 계산).
|
||||
return (
|
||||
[
|
||||
RouteVertex(x=float(pair[0]), y=float(pair[1]), z=0.0, chainage_m=0.0)
|
||||
for pair in geom["coordinates"]
|
||||
],
|
||||
path.name,
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
for pipe_file in sorted(ROOT.rglob("pipe_points.json")):
|
||||
if "initial_snapshot" in str(pipe_file):
|
||||
continue
|
||||
project_dir = pipe_file.parents[3]
|
||||
document = json.loads(pipe_file.read_text(encoding="utf-8"))
|
||||
points = parse_pipe_points(document.get("points"))
|
||||
stored_sig = str(document.get("route_signature") or "")
|
||||
vertices, src = route_vertices(project_dir)
|
||||
shift = max_projection_shift(points, vertices) if vertices else None
|
||||
if shift is None:
|
||||
verdict = "노선 파일 없음 — 판정 불가"
|
||||
elif shift <= ROUTE_MATCH_TOLERANCE_M:
|
||||
verdict = f"허용오차 안 (같은 노선) — {shift:.4f}m ≤ {ROUTE_MATCH_TOLERANCE_M}m"
|
||||
else:
|
||||
verdict = f"⚠ 투영 이월 가지 — {shift:.4f}m > {ROUTE_MATCH_TOLERANCE_M}m"
|
||||
print(f"{project_dir.name[:8]} 관 {len(points):3d}건 · 지문 {stored_sig[:18]:18s} · {src} · {verdict}")
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"]])
|
||||
@@ -0,0 +1,28 @@
|
||||
"""서버가 뜨는지 — `main` 을 들여와 라우터 모듈이 전부 import 되는지 본다.
|
||||
|
||||
왜 (2026-09-06) — 재수출이 사라진 이름을 다른 라우터가 옛 자리에서 들여오고 있어
|
||||
**서버가 아예 안 뜨는** 상태로 커밋됐다(`B06_Section_Router_Confirm` →
|
||||
`B06_Section_Router._compute_default_designs`). 시험 390 건이 다 통과했는데도 못 잡았다 —
|
||||
그 경로를 아무도 import 하지 않았기 때문이다. 여기서 한 번에 잡는다.
|
||||
|
||||
`main` 을 들여오는 것만으로 27개 라우터가 전부 import 되므로, 이름이 어긋나면 즉시 터진다.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
|
||||
def test_main_을_들여올_수_있다():
|
||||
"""import 만 한다 — 서버를 띄우지도, DB 에 붙지도 않는다."""
|
||||
module = importlib.import_module("main")
|
||||
assert getattr(module, "app", None) is not None
|
||||
|
||||
|
||||
def test_라우터가_전부_붙어_있다():
|
||||
module = importlib.import_module("main")
|
||||
paths = {route.path for route in module.app.routes}
|
||||
# 각 화면에서 하나씩 — 라우터 한 벌이 통째로 빠지면 걸린다.
|
||||
for path in (
|
||||
"/api/projects/{project_id}/routes/{route_id}/corridor",
|
||||
"/api/projects/{project_id}/sections/{route_id}/detail",
|
||||
):
|
||||
assert path in paths, f"라우터 경로가 없습니다: {path}"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""직행 업로드(`POST /api/projects/{id}/files`)가 청크 경로와 같은 보호막을 갖는지.
|
||||
|
||||
왜 있나(2026-09-08) — 창 넷이 DB·저장소를 함께 쓰는데 이 갈래만
|
||||
① 분석 중 차단(`is_analysis_running`) ② 같은 이름 옛 행 내리기(`supersede_previous_input_files`)
|
||||
가 빠져 있었다. 빠지면 분석 2개가 같은 산출물 경로에서 부딪히고, 같은 파일이 두 줄로
|
||||
활성으로 남아 어느 것으로 도는지 순서에 달린다.
|
||||
|
||||
파일 내용(소스 문자열)에 기대지 않는다 — **함수 객체를 실제로 호출**해 확인한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import B03_FileInput.B03_FileInput_Router as router_module
|
||||
|
||||
|
||||
class _Cursor:
|
||||
async def __aenter__(self) -> "_Cursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, *_: Any, **__: Any) -> None:
|
||||
return None
|
||||
|
||||
async def fetchone(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Connection:
|
||||
def cursor(self, *_: Any, **__: Any) -> _Cursor:
|
||||
return _Cursor()
|
||||
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self) -> _Connection:
|
||||
return _Connection()
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self) -> _Acquire:
|
||||
return _Acquire()
|
||||
|
||||
|
||||
class _Upload(SimpleNamespace):
|
||||
"""UploadFile 흉내 — 라우터가 마지막에 `close()` 를 부른다."""
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _upload(filename: str) -> _Upload:
|
||||
return _Upload(filename=filename, size=10)
|
||||
|
||||
|
||||
_FULL_SET = ["a.las", "b.tif", "b.tfw", "b.prj", "route.csv"]
|
||||
|
||||
|
||||
def test_분석_중이면_직행_업로드를_409로_막는다(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(router_module, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(
|
||||
router_module, "get_project_storage_relative_path", _async_return("1/3/proj")
|
||||
)
|
||||
monkeypatch.setattr(router_module, "resolve_stored_project_path", lambda _p: ".")
|
||||
monkeypatch.setattr(router_module, "is_analysis_running", _async_return(True))
|
||||
|
||||
response = asyncio.run(
|
||||
router_module.upload_project_files(
|
||||
project_id=_PROJECT_ID,
|
||||
files=[_upload(name) for name in _FULL_SET],
|
||||
las_free=False,
|
||||
session={"role": "USER"},
|
||||
)
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_직행_업로드가_같은_이름_옛_행을_내린다() -> None:
|
||||
"""`supersede_previous_input_files` 가 이 모듈에 실제로 이어져 있는지."""
|
||||
from B03_FileInput.B03_FileInput_Repository import (
|
||||
supersede_previous_input_files as repository_function,
|
||||
)
|
||||
|
||||
assert router_module.supersede_previous_input_files is repository_function
|
||||
|
||||
|
||||
def test_세_갈래가_같은_보호막을_쓴다() -> None:
|
||||
"""청크·임시배치·직행이 같은 두 함수를 쓰는지 — 한 갈래만 빠지는 사고를 막는다."""
|
||||
import B03_FileInput.B03_FileInput_Router_Chunks as chunks
|
||||
import B03_FileInput.B03_FileInput_Router_Temp as temp
|
||||
|
||||
for module in (router_module, chunks, temp):
|
||||
assert hasattr(module, "is_analysis_running"), module.__name__
|
||||
assert hasattr(module, "supersede_previous_input_files"), module.__name__
|
||||
# 확인 단계도 세 갈래가 다 들고 있어야 한다 — 하나만 빠지면 그 갈래가 500 으로 죽는다
|
||||
# (2026-09-08 실제로 청크 갈래에서 `NameError` 로 겪었다).
|
||||
for module in (router_module, chunks, temp):
|
||||
assert hasattr(module, "OutputsWouldBeDiscarded"), module.__name__
|
||||
assert hasattr(module, "_confirm_replace_response"), module.__name__
|
||||
|
||||
|
||||
from uuid import UUID # noqa: E402 — 아래 상수에서만 쓴다
|
||||
|
||||
_PROJECT_ID = UUID("fa76c162-71c7-46e5-a95d-fb3930665a45")
|
||||
|
||||
|
||||
def _async_return(value: Any):
|
||||
async def _inner(*_: Any, **__: Any) -> Any:
|
||||
return value
|
||||
|
||||
return _inner
|
||||
@@ -0,0 +1,21 @@
|
||||
"""B03 카드 미리보기 — 노선 좌표열 솎기(2026-09-04)."""
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine_Shapefile import _thin_preview_path
|
||||
|
||||
|
||||
def test_thin_keeps_endpoints_and_cap() -> None:
|
||||
part = [(float(i), float(i * 2)) for i in range(5000)]
|
||||
preview = _thin_preview_path([part], limit=200)
|
||||
assert len(preview) == 1
|
||||
points = preview[0]
|
||||
# 상한(+ 끝점 보정 1)을 넘지 않는다.
|
||||
assert len(points) <= 201
|
||||
assert points[0] == [0.0, 0.0]
|
||||
assert points[-1] == [4999.0, 9998.0]
|
||||
|
||||
|
||||
def test_thin_handles_multipart_and_empty() -> None:
|
||||
assert _thin_preview_path([]) == []
|
||||
assert _thin_preview_path([[]]) == []
|
||||
preview = _thin_preview_path([[(0.0, 0.0), (1.0, 1.0)], [(5.0, 5.0), (6.0, 6.0)]], limit=200)
|
||||
assert preview == [[[0.0, 0.0], [1.0, 1.0]], [[5.0, 5.0], [6.0, 6.0]]]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""새 자료로 갈아 끼울 때 「지우고 진행할까?」를 먼저 묻는지.
|
||||
|
||||
왜 있나(2026-09-08) — 업로드 하나가 그 프로젝트의 설계 산출물·초기값 스냅숏을
|
||||
**되돌릴 수 없게** 지운다. 창 넷이 한 프로젝트를 볼 수 있어 말없이 지우면 남의 작업이
|
||||
사라진다. 사람이 올리는 갈래만 묻고, 자동 절차는 안 막는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
OutputsWouldBeDiscarded,
|
||||
_confirm_replace_response,
|
||||
describe_existing_outputs,
|
||||
)
|
||||
|
||||
|
||||
def test_산출물이_없으면_묻지_않는다(tmp_path: Path) -> None:
|
||||
(tmp_path / "B03_FileInput" / "input").mkdir(parents=True)
|
||||
(tmp_path / "B03_FileInput" / "input" / "a.las").write_text("x", encoding="utf-8")
|
||||
assert describe_existing_outputs(tmp_path) == []
|
||||
|
||||
|
||||
def test_설계_산출물과_초기값을_사람_말로_알린다(tmp_path: Path) -> None:
|
||||
(tmp_path / "B05_Profile" / "route").mkdir(parents=True)
|
||||
(tmp_path / "B05_Profile" / "route" / "route.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "B06_Section").mkdir()
|
||||
(tmp_path / "B06_Section" / "cross.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "initial_snapshot").mkdir()
|
||||
|
||||
targets = describe_existing_outputs(tmp_path)
|
||||
|
||||
assert "노선·종단 설계" in targets
|
||||
assert "횡단 설계" in targets
|
||||
assert any("초기값" in item for item in targets)
|
||||
# B03 입력은 「지워지는 것」이 아니다 — 새 자료가 얹히는 자리다.
|
||||
assert all("입력" not in item for item in targets)
|
||||
|
||||
|
||||
def test_빈_폴더만_있으면_지울_것이_없다(tmp_path: Path) -> None:
|
||||
(tmp_path / "B05_Profile" / "route").mkdir(parents=True)
|
||||
(tmp_path / "B08_Quantity").mkdir()
|
||||
assert describe_existing_outputs(tmp_path) == []
|
||||
|
||||
|
||||
def test_확인_응답은_409_이고_무엇이_지워지는지_담는다() -> None:
|
||||
response = _confirm_replace_response(OutputsWouldBeDiscarded(["횡단 설계", "수량 산출"]))
|
||||
body = response.body.decode("utf-8")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "replace_outputs" in body
|
||||
assert "\\ub418\\ub3cc\\ub9b4" in body or "되돌릴" in body # 문구에 「되돌릴 수 없」
|
||||
assert "\\ud6a1\\ub2e8" in body or "횡단" in body
|
||||
|
||||
|
||||
def test_자동_절차는_기본으로_안_묻는다() -> None:
|
||||
"""`confirm_replace` 기본이 True 여야 체인·스크립트가 물음에 안 걸린다."""
|
||||
import inspect
|
||||
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import _complete_file_input_if_ready
|
||||
|
||||
default = inspect.signature(_complete_file_input_if_ready).parameters["confirm_replace"].default
|
||||
assert default is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["B04_PreProcess", "B07_DesignDetail", "B09_Estimation"])
|
||||
def test_모든_산출물_단계를_본다(tmp_path: Path, stage: str) -> None:
|
||||
(tmp_path / stage).mkdir(parents=True)
|
||||
(tmp_path / stage / "out.json").write_text("{}", encoding="utf-8")
|
||||
assert describe_existing_outputs(tmp_path), stage
|
||||
@@ -0,0 +1,180 @@
|
||||
"""실데이터 등가성 — 래스터화가 기존 griddata 경로와 같은 표고를 내는지.
|
||||
|
||||
PLAN 2026-08-17 완료 조건 검증.
|
||||
- `meshfree`: Delaunay를 그대로 쓰므로 표고 완전 일치
|
||||
- `tin`: 저장 faces와 재Delaunay는 다른 삼각망이라 차이가 남는다. 그 차이가
|
||||
**보간 버그가 아니라 삼각망 선택 차이**임을 대조로 분리한다.
|
||||
|
||||
**주의: griddata가 호출당 3분 걸려 이 파일 전체 실행에 약 8분이 든다.**
|
||||
실제 프로젝트 산출물이 없으면 skip.
|
||||
"""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.interpolate import griddata
|
||||
from scipy.spatial import Delaunay
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Contour import (
|
||||
_grid_axes,
|
||||
extract_contours,
|
||||
rasterize_triangle_mesh,
|
||||
)
|
||||
|
||||
MODELS = Path(
|
||||
"C:/Program_coding/임도설계 및 견적자동화 프로그램 개발/storage/1/3/"
|
||||
"f9543035-4a91-4df0-b467-cdc515723507/B04_PreProcess/models"
|
||||
)
|
||||
|
||||
|
||||
def _load(name):
|
||||
path = MODELS / name
|
||||
if not path.exists():
|
||||
pytest.skip(f"실데이터 없음: {path}")
|
||||
return np.load(path)
|
||||
|
||||
|
||||
def _axes_for(xy):
|
||||
return _grid_axes(
|
||||
float(np.min(xy[:, 0])),
|
||||
float(np.max(xy[:, 0])),
|
||||
float(np.min(xy[:, 1])),
|
||||
float(np.max(xy[:, 1])),
|
||||
1.0,
|
||||
)
|
||||
|
||||
|
||||
def _compare(old, new):
|
||||
return {
|
||||
"only_old": int((np.isfinite(old) & ~np.isfinite(new)).sum()),
|
||||
"only_new": int((np.isfinite(new) & ~np.isfinite(old)).sum()),
|
||||
"both": np.isfinite(old) & np.isfinite(new),
|
||||
}
|
||||
|
||||
|
||||
def test_meshfree_matches_griddata_exactly():
|
||||
"""meshfree — 같은 Delaunay를 쓰므로 표고가 완전히 일치해야 한다."""
|
||||
points = _load("meshfree_csf.npz")["points"]
|
||||
x_coords, y_coords = _axes_for(points)
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
|
||||
triangulation = Delaunay(np.asarray(points[:, :2], dtype=np.float64))
|
||||
new = rasterize_triangle_mesh(points, triangulation.simplices, x_coords, y_coords)
|
||||
old = griddata(points[:, :2], points[:, 2], (xx, yy), method="linear")
|
||||
|
||||
result = _compare(old, new)
|
||||
assert result["both"].sum() > 10_000, "비교 대상 셀이 너무 적다"
|
||||
assert result["only_old"] == 0, "기존이 채우던 셀이 비었다"
|
||||
assert result["only_new"] == 0, "없던 셀이 생겼다"
|
||||
assert np.abs(old[result["both"]] - new[result["both"]]).max() < 1e-9
|
||||
|
||||
|
||||
def test_rasterize_matches_griddata_on_same_triangulation():
|
||||
"""래스터화 정확성 — 같은 재Delaunay를 넣으면 griddata와 완전 일치.
|
||||
|
||||
이 대조가 통과하면 tin에서 남는 차이는 전부 삼각망 선택 차이다.
|
||||
"""
|
||||
vertices = _load("tin_csf.npz")["vertices"]
|
||||
x_coords, y_coords = _axes_for(vertices)
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
|
||||
triangulation = Delaunay(np.asarray(vertices[:, :2], dtype=np.float64))
|
||||
control = rasterize_triangle_mesh(vertices, triangulation.simplices, x_coords, y_coords)
|
||||
old = griddata(vertices[:, :2], vertices[:, 2], (xx, yy), method="linear")
|
||||
|
||||
result = _compare(old, control)
|
||||
assert result["only_old"] == 0 and result["only_new"] == 0
|
||||
assert np.abs(old[result["both"]] - control[result["both"]]).max() < 1e-9
|
||||
|
||||
|
||||
def test_tin_difference_comes_only_from_triangulation():
|
||||
"""tin 저장 faces — griddata 대비 차이가 재Delaunay 대비 차이와 같아야 한다.
|
||||
|
||||
두 대조가 일치하면 차이의 원인은 삼각망 하나뿐이다. 저장 faces는 긴 변 제거·
|
||||
외곽 클리핑이 반영된 실제 TIN이므로(`ModelBuild.py:38-51`) 재Delaunay보다 덮는
|
||||
면적이 작고, 그 몫이 `only_old`로 나온다.
|
||||
"""
|
||||
data = _load("tin_csf.npz")
|
||||
vertices, faces = data["vertices"], data["faces"]
|
||||
x_coords, y_coords = _axes_for(vertices)
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
|
||||
stored = rasterize_triangle_mesh(vertices, faces, x_coords, y_coords)
|
||||
triangulation = Delaunay(np.asarray(vertices[:, :2], dtype=np.float64))
|
||||
control = rasterize_triangle_mesh(vertices, triangulation.simplices, x_coords, y_coords)
|
||||
old = griddata(vertices[:, :2], vertices[:, 2], (xx, yy), method="linear")
|
||||
|
||||
vs_griddata = _compare(old, stored)
|
||||
vs_control = _compare(control, stored)
|
||||
assert vs_griddata["only_old"] == vs_control["only_old"]
|
||||
assert vs_griddata["only_new"] == vs_control["only_new"] == 0
|
||||
|
||||
diff = np.abs(old[vs_griddata["both"]] - stored[vs_griddata["both"]])
|
||||
changed = diff > 1e-6
|
||||
print(
|
||||
f"\n[tin] 공통셀={int(vs_griddata['both'].sum()):,} "
|
||||
f"저장 faces가 안 덮는 셀={vs_griddata['only_old']:,}\n"
|
||||
f" 값 다른 셀={int(changed.sum()):,} ({changed.mean():.2%}) "
|
||||
f"최대={diff.max():.4f}m 차이셀 평균={diff[changed].mean():.4f}m"
|
||||
)
|
||||
# 대각선 선택이 다른 사각형에서만 어긋나므로 국소 지형 기복을 넘지 않아야 한다.
|
||||
assert diff.max() < 1.0, "표고차 1m 초과 — 삼각망 차이로 설명 불가"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"npz_name,representation,cached_name,exact",
|
||||
[
|
||||
("tin_csf.npz", "triangular_mesh", "contour_csf_tin_1.0m.json", False),
|
||||
("meshfree_csf.npz", "meshfree_surfels", "contour_csf_meshfree_1.0m.json", True),
|
||||
("dtm_csf.npz", "regular_grid", "contour_csf_dtm_1.0m.json", True),
|
||||
],
|
||||
)
|
||||
def test_contour_output_matches_cached(npz_name, representation, cached_name, exact):
|
||||
"""최종 산출물 대조 — 캐시된 등고선 JSON과 레벨·정점 수를 맞춰 본다."""
|
||||
import json
|
||||
|
||||
npz_path = MODELS / npz_name
|
||||
cached_path = MODELS / cached_name
|
||||
if not npz_path.exists() or not cached_path.exists():
|
||||
pytest.skip("실데이터 없음")
|
||||
|
||||
started = time.perf_counter()
|
||||
lines = extract_contours(npz_path, representation, 1.0)
|
||||
elapsed = time.perf_counter() - started
|
||||
with open(cached_path, encoding="utf-8") as handle:
|
||||
cached = json.load(handle)["contours"]
|
||||
|
||||
new_levels = {round(float(item["level"]), 3) for item in lines}
|
||||
old_levels = {round(float(item["level"]), 3) for item in cached}
|
||||
new_points = sum(len(item["coordinates"]) for item in lines)
|
||||
old_points = sum(len(item["coordinates"]) for item in cached)
|
||||
print(
|
||||
f"\n[{npz_name}] {elapsed:.2f}초 세그먼트 {len(lines):,}/{len(cached):,} "
|
||||
f"정점 {new_points:,}/{old_points:,}"
|
||||
)
|
||||
|
||||
assert new_levels == old_levels, "등고선 레벨 집합이 바뀌었다"
|
||||
if exact:
|
||||
assert len(lines) == len(cached)
|
||||
assert new_points == old_points
|
||||
else:
|
||||
# tin은 삼각망 차이만큼 경계 세그먼트가 미세하게 달라진다.
|
||||
assert abs(new_points - old_points) / old_points < 0.01
|
||||
|
||||
|
||||
def test_contour_extraction_is_fast():
|
||||
"""가속이 목적이므로 속도를 회귀로 못박는다 (변경 전 tin 119초·meshfree 302초)."""
|
||||
for npz_name, representation in (
|
||||
("tin_csf.npz", "triangular_mesh"),
|
||||
("meshfree_csf.npz", "meshfree_surfels"),
|
||||
):
|
||||
path = MODELS / npz_name
|
||||
if not path.exists():
|
||||
pytest.skip("실데이터 없음")
|
||||
started = time.perf_counter()
|
||||
extract_contours(path, representation, 1.0)
|
||||
elapsed = time.perf_counter() - started
|
||||
print(f"\n[속도] {npz_name} {elapsed:.2f}초")
|
||||
assert elapsed < 30.0, f"{npz_name} {elapsed:.1f}초 — 가속 실패"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""B04 지면 필터 회귀 테스트 — 고정 상수 때문에 지면점이 사라지던 결함 방지.
|
||||
|
||||
원 결함(2026-09-01): CSF 하강 예산이 0.3185m x 150회 = 47.8m로 고정돼 기복이
|
||||
그보다 큰 산악지에서 천이 지면에 닿지 못했고(용화.las 지면점 0.06%),
|
||||
grid_min_z의 3x3 minimum_filter가 급경사에서 기준면을 경사만큼 파고들었다.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
|
||||
filter_classification,
|
||||
has_classified_ground,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Grid import filter_grid_min_z
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import resolve_auto_source_filters
|
||||
|
||||
# CSF 고정 하강 예산 (옛 코드): 9.8 * 0.05 * 0.65 * 150
|
||||
LEGACY_CSF_DROP_BUDGET_M = 47.775
|
||||
|
||||
|
||||
def _sloped_ground(relief_m: float, slope: float = 0.3, spacing_m: float = 0.5) -> np.ndarray:
|
||||
"""지정한 경사로 relief_m 만큼 오르내리는 지면 점군을 만든다.
|
||||
|
||||
경사와 기복을 따로 잡는다 — 결함은 경사가 아니라 **절대 기복**이 CSF 하강
|
||||
예산을 넘을 때 터졌다. 점 간격은 격자·천 셀보다 촘촘하게 둔다(실제 LAS와 동일).
|
||||
셀 안 표고 편차가 임계값을 넘으면 필터와 무관하게 점이 떨어지기 때문이다.
|
||||
"""
|
||||
axis = np.arange(0.0, relief_m / slope, spacing_m, dtype=np.float64)
|
||||
x, y = np.meshgrid(axis, axis)
|
||||
z = 100.0 + slope * x
|
||||
return np.column_stack([x.ravel(), y.ravel(), z.ravel()])
|
||||
|
||||
|
||||
def _structured(points: np.ndarray, classification: np.ndarray | None = None) -> dict:
|
||||
data = {
|
||||
"xyz": points,
|
||||
"bounds": np.array(
|
||||
[
|
||||
[points[:, 0].min(), points[:, 0].max()],
|
||||
[points[:, 1].min(), points[:, 1].max()],
|
||||
[points[:, 2].min(), points[:, 2].max()],
|
||||
]
|
||||
),
|
||||
}
|
||||
if classification is not None:
|
||||
data["classification"] = classification
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relief_m", [20.0, 120.0])
|
||||
def test_csf_recovers_ground_regardless_of_relief(relief_m: float) -> None:
|
||||
"""기복이 옛 고정 예산(47.8m)을 넘어도 지면이 살아남는다."""
|
||||
ground = _sloped_ground(relief_m)
|
||||
mask = filter_csf(_structured(ground))
|
||||
|
||||
assert mask.mean() > 0.5, f"기복 {relief_m}m 에서 지면 회수율 {mask.mean():.1%}"
|
||||
# 옛 결함은 낮은 쪽 띠만 남기고 높은 쪽을 통째로 버렸다.
|
||||
high_side = ground[:, 2] > ground[:, 2].min() + LEGACY_CSF_DROP_BUDGET_M
|
||||
if high_side.any():
|
||||
assert mask[high_side].mean() > 0.5, "옛 하강 예산 위쪽 지면이 잡히지 않는다"
|
||||
|
||||
|
||||
def test_csf_rejects_canopy_points() -> None:
|
||||
"""6단계 수목 필터가 실제로 동작한다 (피연산자 역전 시 항상 참이 되던 자리)."""
|
||||
ground = _sloped_ground(60.0, slope=0.3)
|
||||
canopy = ground.copy()
|
||||
canopy[:, 2] += 12.0
|
||||
points = np.vstack([ground, canopy])
|
||||
mask = filter_csf(_structured(points))
|
||||
|
||||
is_canopy = np.zeros(len(points), dtype=bool)
|
||||
is_canopy[len(ground) :] = True
|
||||
assert not mask[is_canopy].any(), "수목 반사점이 지면으로 분류됐다"
|
||||
assert mask[~is_canopy].mean() > 0.5
|
||||
|
||||
|
||||
def test_grid_min_z_keeps_ground_on_steep_slope() -> None:
|
||||
"""급경사에서 3x3 최소필터가 기준면을 파고들던 결함 방지."""
|
||||
# 경사 100%. 셀 1m 안 표고 편차는 1.0m로 임계값(1.5m) 이내지만, 옛 코드는
|
||||
# 3x3 최소필터가 2m 밖 셀 값을 끌어와 기준면을 2.0m 낮췄다 -> 임계값 초과.
|
||||
ground = _sloped_ground(120.0, slope=1.0)
|
||||
mask = filter_grid_min_z(_structured(ground), cell_size=1.0, height_threshold=1.5)
|
||||
assert mask.mean() > 0.9, f"급경사 지면 회수율 {mask.mean():.1%}"
|
||||
|
||||
|
||||
def test_grid_min_z_rejects_points_above_threshold() -> None:
|
||||
ground = _sloped_ground(10.0)
|
||||
above = ground.copy()
|
||||
above[:, 2] += 5.0 # 임계값 1.5m 초과
|
||||
points = np.vstack([ground, above])
|
||||
mask = filter_grid_min_z(_structured(points))
|
||||
assert not mask[len(ground) :].any()
|
||||
|
||||
|
||||
def test_classification_filter_reads_class_two() -> None:
|
||||
points = _sloped_ground(10.0)
|
||||
classification = np.ones(len(points), dtype=np.uint8)
|
||||
classification[::4] = 2
|
||||
mask = filter_classification(_structured(points, classification))
|
||||
assert mask.sum() == len(points[::4])
|
||||
|
||||
|
||||
def test_auto_filters_prefer_classification_but_always_build_csf() -> None:
|
||||
"""자동 전처리 목록 — 첫 항목이 기본 확정값. csf는 분류가 있어도 늘 함께 만든다."""
|
||||
points = _sloped_ground(10.0)
|
||||
unclassified = np.zeros(len(points), dtype=np.uint8)
|
||||
classified = np.ones(len(points), dtype=np.uint8)
|
||||
classified[::4] = 2
|
||||
|
||||
# 분류가 있으면 classification 이 기본이고, csf 는 검증용으로 같이 만든다.
|
||||
assert resolve_auto_source_filters(_structured(points, classified)) == [
|
||||
"classification",
|
||||
"csf",
|
||||
]
|
||||
# 분류가 없으면 csf 하나뿐 — 빈 마스크로 모델을 만들지 않는다.
|
||||
assert resolve_auto_source_filters(_structured(points, unclassified)) == ["csf"]
|
||||
assert not has_classified_ground(_structured(points, unclassified))
|
||||
# classification 이 없는 구조화 데이터(옛 캐시)에서도 죽지 않는다.
|
||||
assert resolve_auto_source_filters(_structured(points)) == ["csf"]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""계곡 통과 시설 확장 — PipePoint의 시설종류·구간·옵션 (PLAN 2026-08-17 컨테이너 병합 1단계).
|
||||
|
||||
배관 정본 `pipe_points.json`은 유역 계산의 입력이라 저장소를 옮기지 않고 제자리 확장한다.
|
||||
구 파일(`{chainage_m, source}`뿐)은 배관·폭 미지정으로 읽혀야 한다 (하위 호환).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_BOX,
|
||||
PIPE_FACILITY_FORD_BRIDGE,
|
||||
PIPE_FACILITY_FORD_PAVEMENT,
|
||||
PIPE_FACILITY_PIPE,
|
||||
PIPE_SOURCE_STREAM,
|
||||
PIPE_SOURCE_USER,
|
||||
PipePoint,
|
||||
carry_facility_attributes,
|
||||
parse_pipe_points,
|
||||
)
|
||||
|
||||
|
||||
# ── 하위 호환 — 구 파일 형식 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_legacy_dict_defaults_to_pipe_with_no_span():
|
||||
points = parse_pipe_points([{"chainage_m": 120.0, "source": "stream"}])
|
||||
assert len(points) == 1
|
||||
point = points[0]
|
||||
assert point.facility == PIPE_FACILITY_PIPE
|
||||
assert point.start_m is None and point.end_m is None
|
||||
assert point.source == PIPE_SOURCE_STREAM
|
||||
|
||||
|
||||
def test_legacy_bare_number_still_parses():
|
||||
points = parse_pipe_points([80.5])
|
||||
assert points[0].chainage_m == 80.5
|
||||
assert points[0].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
def test_legacy_dict_roundtrip_stays_compact():
|
||||
"""구 형식 저장분은 확장 필드 없이 그대로 다시 저장된다 (파일 불변)."""
|
||||
point = parse_pipe_points([{"chainage_m": 60.0, "source": "user"}])[0]
|
||||
assert point.as_dict() == {"chainage_m": 60.0, "source": PIPE_SOURCE_USER}
|
||||
|
||||
|
||||
# ── 시설 종류 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_facility_roundtrip():
|
||||
for facility in (
|
||||
PIPE_FACILITY_PIPE,
|
||||
PIPE_FACILITY_BOX,
|
||||
PIPE_FACILITY_FORD_PAVEMENT,
|
||||
PIPE_FACILITY_FORD_BRIDGE,
|
||||
):
|
||||
data = {"chainage_m": 100.0, "facility": facility}
|
||||
parsed = parse_pipe_points([data])[0]
|
||||
assert parsed.facility == facility
|
||||
assert parse_pipe_points([parsed.as_dict()])[0].facility == facility
|
||||
|
||||
|
||||
def test_unknown_facility_falls_back_to_pipe():
|
||||
points = parse_pipe_points([{"chainage_m": 100.0, "facility": "bridge"}])
|
||||
assert points[0].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
# ── 구간 (기준점 + 시작·종료) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_span_roundtrip():
|
||||
data = {"chainage_m": 100.0, "facility": "box_culvert", "start_m": 92.0, "end_m": 111.0}
|
||||
point = parse_pipe_points([data])[0]
|
||||
assert (point.start_m, point.end_m) == (92.0, 111.0)
|
||||
again = parse_pipe_points([point.as_dict()])[0]
|
||||
assert (again.start_m, again.end_m) == (92.0, 111.0)
|
||||
|
||||
|
||||
def test_span_is_normalized_to_contain_anchor():
|
||||
"""시작·종료가 뒤집혔거나 기준점을 안 품으면 정규화한다 (파서는 관대하게)."""
|
||||
flipped = parse_pipe_points([{"chainage_m": 100.0, "start_m": 110.0, "end_m": 90.0}])[0]
|
||||
assert (flipped.start_m, flipped.end_m) == (90.0, 110.0)
|
||||
|
||||
outside = parse_pipe_points([{"chainage_m": 100.0, "start_m": 104.0, "end_m": 112.0}])[0]
|
||||
assert outside.start_m == 100.0 and outside.end_m == 112.0
|
||||
|
||||
|
||||
def test_half_span_is_completed_with_anchor():
|
||||
"""한쪽만 오면 기준점으로 나머지를 채운다."""
|
||||
point = parse_pipe_points([{"chainage_m": 100.0, "end_m": 115.0}])[0]
|
||||
assert (point.start_m, point.end_m) == (100.0, 115.0)
|
||||
|
||||
|
||||
def test_non_numeric_span_is_dropped():
|
||||
point = parse_pipe_points([{"chainage_m": 100.0, "start_m": "십", "end_m": None}])[0]
|
||||
assert point.start_m is None and point.end_m is None
|
||||
|
||||
|
||||
# ── 시설 옵션 (세월교 관 종류/크기/수량 등) ────────────────────────────────
|
||||
|
||||
|
||||
def test_options_roundtrip():
|
||||
data = {
|
||||
"chainage_m": 100.0,
|
||||
"facility": "ford_bridge",
|
||||
"options": {"pipe_kind": "흄관", "pipe_diameter_mm": 800, "pipe_count": 3},
|
||||
}
|
||||
point = parse_pipe_points([data])[0]
|
||||
assert point.options == {"pipe_kind": "흄관", "pipe_diameter_mm": 800, "pipe_count": 3}
|
||||
again = parse_pipe_points([point.as_dict()])[0]
|
||||
assert again.options == point.options
|
||||
|
||||
|
||||
def test_pipe_accessory_options_roundtrip():
|
||||
"""배관 부속 키(2026-08-17 재편) — 유형·집수정·기슭막이·돌붙임이 왕복 보존되는지."""
|
||||
data = {
|
||||
"chainage_m": 240.0,
|
||||
"facility": "pipe",
|
||||
"start_m": 236.0,
|
||||
"end_m": 246.0,
|
||||
"options": {
|
||||
"flow_type": "계곡부형",
|
||||
"pipe_diameter_mm": 1000,
|
||||
"catch_basin": "없음",
|
||||
"revetment": "있음",
|
||||
"revet_length_m": 10.0,
|
||||
"revet_front_m": 4.0,
|
||||
"stone_pitching": "있음",
|
||||
},
|
||||
}
|
||||
point = parse_pipe_points([data])[0]
|
||||
again = parse_pipe_points([point.as_dict()])[0]
|
||||
assert again.options == data["options"]
|
||||
assert (again.start_m, again.end_m) == (236.0, 246.0)
|
||||
|
||||
|
||||
def test_non_dict_options_are_dropped():
|
||||
point = parse_pipe_points([{"chainage_m": 100.0, "options": "D800x3"}])[0]
|
||||
assert point.options is None
|
||||
|
||||
|
||||
def test_empty_options_are_omitted_from_dict():
|
||||
point = PipePoint(chainage_m=50.0, options={})
|
||||
assert "options" not in point.as_dict()
|
||||
|
||||
|
||||
# ── 시설 속성 승계 — 세부유역 계산 왕복에서 종류가 사라지면 안 된다 ────────
|
||||
|
||||
|
||||
def test_carry_attributes_restores_facility_after_rebuild():
|
||||
"""계산기는 chainage만 다뤄 재구성 목록이 전부 기본 배관이 된다 — 되붙여야 한다."""
|
||||
requested = parse_pipe_points(
|
||||
[
|
||||
{
|
||||
"chainage_m": 100.0,
|
||||
"facility": "ford_bridge",
|
||||
"start_m": 95.0,
|
||||
"end_m": 108.0,
|
||||
"options": {"pipe_count": 2},
|
||||
},
|
||||
{"chainage_m": 200.0},
|
||||
]
|
||||
)
|
||||
rebuilt = [PipePoint(chainage_m=100.0, source="user"), PipePoint(chainage_m=200.0)]
|
||||
|
||||
carried = carry_facility_attributes(rebuilt, requested)
|
||||
|
||||
assert carried[0].facility == PIPE_FACILITY_FORD_BRIDGE
|
||||
assert (carried[0].start_m, carried[0].end_m) == (95.0, 108.0)
|
||||
assert carried[0].options == {"pipe_count": 2}
|
||||
assert carried[1].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
def test_carry_attributes_uses_nearest_when_chainage_shifted():
|
||||
"""계산기가 좌표를 미세 조정해도(스냅) 가장 가까운 원본에서 승계한다 — _retag과 동일 기준."""
|
||||
requested = parse_pipe_points([{"chainage_m": 150.0, "facility": "box_culvert"}])
|
||||
rebuilt = [PipePoint(chainage_m=150.37)]
|
||||
|
||||
carried = carry_facility_attributes(rebuilt, requested)
|
||||
|
||||
assert carried[0].facility == PIPE_FACILITY_BOX
|
||||
|
||||
|
||||
def test_carry_attributes_without_reference_is_noop():
|
||||
rebuilt = [PipePoint(chainage_m=100.0)]
|
||||
assert carry_facility_attributes(rebuilt, []) is rebuilt
|
||||
assert rebuilt[0].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
# ── 정렬 유지 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_points_stay_sorted_by_chainage():
|
||||
points = parse_pipe_points(
|
||||
[
|
||||
{"chainage_m": 300.0, "facility": "ford_pavement"},
|
||||
{"chainage_m": 100.0},
|
||||
{"chainage_m": 200.0, "facility": "box_culvert"},
|
||||
]
|
||||
)
|
||||
assert [point.chainage_m for point in points] == [100.0, 200.0, 300.0]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""코리도 저장본이 리본 표식을 잃지 않는지 (2026-09-02).
|
||||
|
||||
`patch`·`patchClip` 은 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도** 본다
|
||||
(`B05_Profile_UI_Viewer.ts` 의 지형 스냅 제외 가드). 그런데 직렬화 대상에서 빠져 있어
|
||||
저장본으로 다시 연 코리도는 패치인 줄 몰랐고, 바깥 끝이 원지반까지 끌려 내려갔다.
|
||||
|
||||
같은 종류의 실수를 다시 잡으려고 **대칭**을 잠근다 — `serialize()` 가 리본에 쓰는
|
||||
선택 필드는 전부 `deserialize()` 가 되읽어야 한다. 필드를 새로 담으면서 되읽기를
|
||||
빠뜨리면 여기서 걸린다.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 저장 형식(serialize/deserialize)은 브라우저·서버 공용 모듈로 옮겼다(2026-09-04 분리).
|
||||
CORRIDOR = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Envelope.ts"
|
||||
VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Viewer.ts"
|
||||
CARVE_WING = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve_Wing.ts"
|
||||
CARVE = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve.ts"
|
||||
|
||||
# 700줄 제한으로 함수가 다른 파일로 갈릴 수 있다 — 후보를 모두 이어 붙여 훑는다.
|
||||
# ⚠ 못 찾을 때 최상위에서 터지면 **이 파일 하나 때문에 회귀 전체가 멈춘다**(2026-09-08).
|
||||
_CANDIDATES = (CORRIDOR, PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor.ts")
|
||||
SOURCE = "\n".join(path.read_text(encoding="utf-8") for path in _CANDIDATES if path.is_file())
|
||||
|
||||
|
||||
def _block(name: str) -> str:
|
||||
"""`function <name>(` 부터 다음 최상위 `function ` 앞까지."""
|
||||
start = SOURCE.find(f"function {name}(")
|
||||
if start < 0:
|
||||
pytest.skip(
|
||||
f"`function {name}(` 를 못 찾음 — 다른 파일로 갈라져 나갔는지 확인할 것",
|
||||
allow_module_level=True,
|
||||
)
|
||||
rest = SOURCE[start + 1 :]
|
||||
# 공용 모듈에서는 최상위 함수가 `export function` 으로도 나온다(2026-09-04 분리).
|
||||
ends = [pos for pos in (rest.find("\nfunction "), rest.find("\nexport function ")) if pos >= 0]
|
||||
return rest if not ends else rest[: min(ends)]
|
||||
|
||||
|
||||
SERIALIZE = _block("serialize")
|
||||
DESERIALIZE = _block("deserialize")
|
||||
|
||||
|
||||
# ── 표식이 실제로 오간다 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_serialize_carries_patch_flags():
|
||||
"""저장 쪽이 두 표식을 담는다."""
|
||||
assert "patch: true" in SERIALIZE
|
||||
assert "patchClip: true" in SERIALIZE
|
||||
|
||||
|
||||
def test_deserialize_restores_patch_flags():
|
||||
"""복원 쪽이 두 표식을 되살린다 — 이게 빠져서 결함이 났다."""
|
||||
assert "patch: true" in DESERIALIZE
|
||||
assert "patchClip: true" in DESERIALIZE
|
||||
|
||||
|
||||
def test_envelope_version_bumped():
|
||||
"""담는 필드가 바뀌었으므로 형식 버전이 올라가야 옛 저장본이 만료된다."""
|
||||
version = int(re.search(r"const ENVELOPE_VERSION = (\d+);", SOURCE).group(1))
|
||||
assert version >= 6
|
||||
|
||||
|
||||
def test_viewer_still_guards_on_patch():
|
||||
"""뷰어가 표식을 보는 것이 이 수정의 이유다 — 가드가 사라지면 전제가 무너진다."""
|
||||
assert "if (ribbon.patch) return;" in VIEWER.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── 대칭 — 쓴 것은 되읽는다 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _optional_ribbon_fields(block: str) -> set[str]:
|
||||
"""`...(조건 ? { 키: … } : {})` 로 붙는 선택 필드 이름."""
|
||||
return set(re.findall(r"\?\s*\{\s*(\w+):", block)) | set(
|
||||
re.findall(r"\?\s*\{\s*(\w+)\s*:", block)
|
||||
)
|
||||
|
||||
|
||||
def test_every_saved_optional_field_is_restored():
|
||||
"""저장이 담는 선택 필드는 전부 복원이 되읽어야 한다."""
|
||||
saved = _optional_ribbon_fields(SERIALIZE)
|
||||
restored = _optional_ribbon_fields(DESERIALIZE)
|
||||
# 이름이 다른 짝(직렬화 base64 ↔ 복원 원본형)은 접미사를 떼고 맞춘다.
|
||||
normalize = lambda names: {n.removesuffix("Base64") for n in names} # noqa: E731
|
||||
missing = normalize(saved) - normalize(restored)
|
||||
assert not missing, f"복원이 되읽지 않는 필드: {sorted(missing)}"
|
||||
|
||||
|
||||
# ── 죽은 코드 제거 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reverted_wing_trim_tools_are_gone():
|
||||
"""버전 76~78 트림 도구는 원복 뒤 참조가 0이었다 — 되살아나면 여기서 걸린다."""
|
||||
source = CARVE_WING.read_text(encoding="utf-8")
|
||||
for symbol in ("wingOuterLines", "wingTrimAt", "clipRunToRange", "WingTrimLine"):
|
||||
assert f"export function {symbol}" not in source
|
||||
assert f"export interface {symbol}" not in source
|
||||
|
||||
|
||||
def test_carve_still_imports_live_wing_tools():
|
||||
"""살아 있는 도구까지 지우지 않았는지 — 호출부 import 가 그대로여야 한다."""
|
||||
source = CARVE.read_text(encoding="utf-8")
|
||||
for symbol in ("wingSpanOf", "wingSpanOnRoute", "wingRangeAt", "clipRunBelow"):
|
||||
assert symbol in source
|
||||
@@ -0,0 +1,44 @@
|
||||
"""코리도 저장본의 열쇠(해시)만 머리에서 떼어 보는 길 — 18.6MB 를 안 받고 판정한다.
|
||||
|
||||
왜 — B05 진입 때 저장본을 통째로 받은 뒤 해시가 다르면 버렸다(2026-09-06 실측).
|
||||
이제 `?hash=` 를 주면 서버가 머리 4KB 만 읽어 맞춰 보고, 어긋나면 파일 대신 `stale` 만 낸다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from B05_Profile.B05_Profile_Router_Corridor import _stored_hash
|
||||
|
||||
|
||||
def _write_envelope(path, hash_value: str, ribbon_count: int = 200) -> None:
|
||||
"""실제 봉투와 같은 칸 차례(version → hash → ribbons)로 쓴다."""
|
||||
payload = {
|
||||
"version": 3,
|
||||
"hash": hash_value,
|
||||
# 머리 4KB 뒤로 밀리는 덩치를 흉내 낸다 — 해시가 앞에 있어야 찾을 수 있다.
|
||||
"ribbons": [{"name": f"ribbon-{i}", "data": "x" * 64} for i in range(ribbon_count)],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def test_머리에서_해시를_찾는다(tmp_path):
|
||||
path = tmp_path / "corridor.json"
|
||||
_write_envelope(path, "1a2b3c4d")
|
||||
assert asyncio.run(_stored_hash(path)) == "1a2b3c4d"
|
||||
# 파일 전체를 읽지 않아도 되는지 — 머리 4KB 보다 훨씬 큰 파일이어야 의미가 있다.
|
||||
assert path.stat().st_size > 4096
|
||||
|
||||
|
||||
def test_해시가_없으면_None(tmp_path):
|
||||
path = tmp_path / "corridor.json"
|
||||
path.write_text(json.dumps({"version": 3, "ribbons": []}), encoding="utf-8")
|
||||
assert asyncio.run(_stored_hash(path)) is None
|
||||
|
||||
|
||||
def test_머리_밖으로_밀리면_못_찾는다(tmp_path):
|
||||
"""봉투 차례가 바뀌어 해시가 4KB 뒤로 가면 None — 그때는 종전대로 파일을 내려보낸다."""
|
||||
path = tmp_path / "corridor.json"
|
||||
path.write_text(
|
||||
json.dumps({"pad": "y" * 8000, "version": 3, "hash": "deadbeef"}), encoding="utf-8"
|
||||
)
|
||||
assert asyncio.run(_stored_hash(path)) is None
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""코리도 초기값 — 스냅샷 복원이 **새 route id 이름**으로 되돌리는지(2026-09-04).
|
||||
|
||||
[초기화]는 routes 행을 새로 넣어 route id가 바뀐다. 코리도 저장본 파일명에는 번호가
|
||||
박혀 있어, 스냅샷을 그대로 복사하면 브라우저가 못 찾아 매번 다시 만든다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Router_Corridor import corridor_path # noqa: E402
|
||||
from common_util.common_util_initial_snapshot import ( # noqa: E402
|
||||
_CORRIDOR_NAME,
|
||||
restore_snapshot_files,
|
||||
snapshot_dir,
|
||||
)
|
||||
|
||||
|
||||
def _seed_snapshot(root: Path, payload: dict) -> None:
|
||||
target = snapshot_dir(root)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / _CORRIDOR_NAME).write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def test_restore_renames_corridor_to_new_route_id(tmp_path: Path) -> None:
|
||||
_seed_snapshot(tmp_path, {"version": 6, "hash": "abcd1234", "ribbons": []})
|
||||
# 옛 번호로 남아 있던 작업본 — 복원 뒤에도 새 번호 파일이 서야 한다.
|
||||
old = corridor_path(tmp_path, 139)
|
||||
old.parent.mkdir(parents=True, exist_ok=True)
|
||||
old.write_text("{}", encoding="utf-8")
|
||||
|
||||
restore_snapshot_files(tmp_path, 141)
|
||||
|
||||
fresh = corridor_path(tmp_path, 141)
|
||||
assert fresh.is_file(), "새 route id 이름으로 코리도가 복원되어야 한다"
|
||||
assert json.loads(fresh.read_text(encoding="utf-8"))["hash"] == "abcd1234"
|
||||
|
||||
|
||||
def test_restore_without_route_id_leaves_corridor_alone(tmp_path: Path) -> None:
|
||||
"""재계산 경로(스냅샷 없음)에서는 route id가 없다 — 코리도는 건드리지 않는다."""
|
||||
_seed_snapshot(tmp_path, {"version": 6, "hash": "abcd1234", "ribbons": []})
|
||||
restore_snapshot_files(tmp_path, None)
|
||||
assert not (tmp_path / "B05_Profile" / "corridor").exists()
|
||||
|
||||
|
||||
def test_restore_without_snapshot_corridor_is_noop(tmp_path: Path) -> None:
|
||||
snapshot_dir(tmp_path).mkdir(parents=True, exist_ok=True)
|
||||
restore_snapshot_files(tmp_path, 141)
|
||||
assert not corridor_path(tmp_path, 141).exists()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""초기화가 주인 없는 코리도 파일을 지우는지 — 2026-08-28 백로그.
|
||||
|
||||
route 행이 지워지면 corridor_00NN.json 은 주인을 잃는다(실측: 한 프로젝트에 19개·약 85MB).
|
||||
"""
|
||||
|
||||
from B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files
|
||||
|
||||
|
||||
def _make(root, route_ids):
|
||||
directory = root / "B05_Profile" / "corridor"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
for route_id in route_ids:
|
||||
(directory / f"corridor_{route_id:04d}.json").write_text("{}", encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
def test_keeps_only_live_routes(tmp_path):
|
||||
directory = _make(tmp_path, [75, 91, 92, 94])
|
||||
assert prune_corridor_files(tmp_path, {94}) == 3
|
||||
assert sorted(path.name for path in directory.glob("*.json")) == ["corridor_0094.json"]
|
||||
|
||||
|
||||
def test_keeps_all_requested_ids(tmp_path):
|
||||
directory = _make(tmp_path, [10, 11])
|
||||
assert prune_corridor_files(tmp_path, {10, 11}) == 0
|
||||
assert len(list(directory.glob("*.json"))) == 2
|
||||
|
||||
|
||||
def test_missing_directory_is_not_an_error(tmp_path):
|
||||
assert prune_corridor_files(tmp_path, {1}) == 0
|
||||
|
||||
|
||||
def test_other_files_are_left_alone(tmp_path):
|
||||
directory = _make(tmp_path, [5])
|
||||
(directory / "메모.txt").write_text("보존", encoding="utf-8")
|
||||
assert prune_corridor_files(tmp_path, set()) == 1
|
||||
assert [path.name for path in directory.iterdir()] == ["메모.txt"]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""B05 코리도(예상형상) 저장 라우터 검증.
|
||||
|
||||
파생 데이터 보관 계약 — 경로 규칙, PUT 검증(형식·크기), 파일 왕복.
|
||||
DB 의존(_resolve_project_root)은 스텁으로 대체해 파일 로직만 검증한다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from uuid import uuid4 # noqa: E402
|
||||
|
||||
import B05_Profile.B05_Profile_Router_Corridor as corridor # noqa: E402
|
||||
|
||||
|
||||
def test_corridor_path_layout(tmp_path):
|
||||
"""저장 경로 = {project_root}/B05_Profile/corridor/corridor_{route:04d}.json."""
|
||||
path = corridor.corridor_path(tmp_path, 7)
|
||||
assert path == tmp_path / "B05_Profile" / "corridor" / "corridor_0007.json"
|
||||
|
||||
|
||||
def test_put_rejects_wrong_shape(tmp_path, monkeypatch):
|
||||
"""ribbons 없는 본문은 422 — 파생 데이터라도 최소 형식은 지킨다."""
|
||||
|
||||
async def fake_root(_project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
|
||||
response = asyncio.run(corridor.put_corridor(uuid4(), 1, {"nope": True}))
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_put_then_get_roundtrip(tmp_path, monkeypatch):
|
||||
"""PUT 저장본을 GET이 그대로 돌려준다(원자 기록·경로 일치)."""
|
||||
|
||||
async def fake_root(_project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
|
||||
payload = {
|
||||
"version": 1,
|
||||
"hash": "deadbeef",
|
||||
"ribbons": [{"kind": "cut", "side": "left", "colCount": 2, "chainages": [0, 2]}],
|
||||
"outline": {"chainages": [0, 2], "left": [[0, 1]], "right": [[0, -1]]},
|
||||
}
|
||||
put_response = asyncio.run(corridor.put_corridor(uuid4(), 3, payload))
|
||||
assert put_response.status_code == 200
|
||||
stored = tmp_path / "B05_Profile" / "corridor" / "corridor_0003.json"
|
||||
assert stored.is_file()
|
||||
|
||||
get_response = asyncio.run(corridor.get_corridor(uuid4(), 3))
|
||||
assert get_response.status_code == 200
|
||||
assert json.loads(get_response.body) == payload
|
||||
|
||||
|
||||
def test_get_missing_returns_404(tmp_path, monkeypatch):
|
||||
"""저장본 없음 = 404 — 프론트가 빌드로 폴백하는 계약."""
|
||||
|
||||
async def fake_root(_project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
|
||||
response = asyncio.run(corridor.get_corridor(uuid4(), 99))
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_put_rejects_oversize(tmp_path, monkeypatch):
|
||||
"""크기 상한 초과는 413 — 비정상 요청 차단."""
|
||||
|
||||
async def fake_root(_project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(corridor, "MAX_CORRIDOR_BYTES", 64)
|
||||
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
|
||||
payload = {"version": 1, "hash": "x", "ribbons": [{"blob": "y" * 200}], "outline": {}}
|
||||
response = asyncio.run(corridor.put_corridor(uuid4(), 1, payload))
|
||||
assert response.status_code == 413
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""절성토 완전 분리(2026-08-23 사용자 지시) 검증.
|
||||
|
||||
전환 구간을 상대 측점의 축퇴점으로 모핑하던 방식을 버리고, 행마다 두 측점
|
||||
지반선을 보간한 **행 지반선**과 비탈선을 직접 교차시켜 종결한다.
|
||||
TS `trimSlopeToGround()`와 같은 식을 파이썬으로 재현해 규칙을 잠근다:
|
||||
- 절토는 지반 아래에서 시작해 **위로** 만나는 곳까지, 성토는 위에서 **아래로**.
|
||||
- 시작부터 지반이 반대편이면 그 행에 그 종류는 없다(None → 조각 드롭 → 분리).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
BUILD = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Build.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
STATION = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Station.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
CATCH_EPS = 0.005
|
||||
MIN_WIDTH = 0.02
|
||||
EXTEND_MAX = 30.0
|
||||
EXTEND_STEP = 0.25
|
||||
|
||||
|
||||
def _trim(inner2outer, kind, ground_at):
|
||||
"""TS trimSlopeToGround와 같은 규칙."""
|
||||
if len(inner2outer) < 2:
|
||||
return None
|
||||
sign = -1 if kind == "cut" else 1
|
||||
|
||||
def e_of(p):
|
||||
return (p[1] - ground_at(p[0])) * sign
|
||||
|
||||
first = inner2outer[0]
|
||||
if e_of(first) < -CATCH_EPS:
|
||||
return None
|
||||
kept = [first]
|
||||
prev, e_prev = first, e_of(first)
|
||||
closed = False
|
||||
for point in inner2outer[1:]:
|
||||
e_now = e_of(point)
|
||||
if e_now > CATCH_EPS:
|
||||
kept.append(point)
|
||||
prev, e_prev = point, e_now
|
||||
continue
|
||||
ratio = 0 if e_prev - e_now <= 1e-12 else e_prev / (e_prev - e_now)
|
||||
offset = prev[0] + (point[0] - prev[0]) * ratio
|
||||
kept.append((offset, ground_at(offset)))
|
||||
closed = True
|
||||
break
|
||||
if not closed:
|
||||
tail, before = inner2outer[-1], inner2outer[-2]
|
||||
run_off = tail[0] - before[0]
|
||||
run_len = ((run_off) ** 2 + (tail[1] - before[1]) ** 2) ** 0.5
|
||||
if abs(run_off) > 1e-9 and run_len > 1e-9:
|
||||
so = run_off / run_len * EXTEND_STEP
|
||||
sz = (tail[1] - before[1]) / run_len * EXTEND_STEP
|
||||
cursor, e_cur = tail, e_of(tail)
|
||||
step = 0
|
||||
while step * EXTEND_STEP < EXTEND_MAX:
|
||||
nxt = (cursor[0] + so, cursor[1] + sz)
|
||||
e_nxt = e_of(nxt)
|
||||
if e_nxt <= CATCH_EPS:
|
||||
ratio = 0 if e_cur - e_nxt <= 1e-12 else e_cur / (e_cur - e_nxt)
|
||||
offset = cursor[0] + so * ratio
|
||||
kept.append((offset, ground_at(offset)))
|
||||
closed = True
|
||||
break
|
||||
cursor, e_cur = nxt, e_nxt
|
||||
kept.append(cursor)
|
||||
step += 1
|
||||
if len(kept) < 2 or abs(kept[-1][0] - kept[0][0]) < MIN_WIDTH:
|
||||
return None
|
||||
return kept
|
||||
|
||||
|
||||
def test_cut_trims_where_slope_meets_ground_above():
|
||||
"""절토: 노면(지반 아래)에서 1:1로 올라가 지반과 만나는 지점에서 잘린다."""
|
||||
slope = [(0.0, 100.0), (2.0, 102.0), (4.0, 104.0)] # 1:1 상승
|
||||
trimmed = _trim(slope, "cut", lambda o: 101.0) # 평평한 지반 101
|
||||
assert trimmed is not None
|
||||
assert abs(trimmed[-1][0] - 1.0) < 1e-9 # 100+o = 101 → o=1
|
||||
assert abs(trimmed[-1][1] - 101.0) < 1e-9 # 끝점은 지반 위
|
||||
|
||||
|
||||
def test_cut_dropped_when_ground_is_below_road():
|
||||
"""절토인데 그 행의 지반이 노면 아래(성토 상황)면 조각이 없다 — 완전 분리 핵심.
|
||||
(기존 모핑은 이런 행에도 절토 서피스를 만들어 지반으로 내려갔다.)"""
|
||||
slope = [(0.0, 100.0), (2.0, 102.0)]
|
||||
assert _trim(slope, "cut", lambda o: 99.0) is None
|
||||
|
||||
|
||||
def test_fill_trims_where_slope_meets_ground_below():
|
||||
"""성토: 노면(지반 위)에서 내려가 지반과 만나는 지점에서 잘린다."""
|
||||
slope = [(0.0, 100.0), (2.4, 98.0), (4.8, 96.0)] # 1:1.2 하강
|
||||
trimmed = _trim(slope, "fill", lambda o: 97.0)
|
||||
assert trimmed is not None
|
||||
assert abs(trimmed[-1][1] - 97.0) < 1e-9
|
||||
|
||||
|
||||
def test_fill_dropped_when_ground_is_above_road():
|
||||
"""성토인데 지반이 노면 위(절토 상황)면 조각이 없다."""
|
||||
slope = [(0.0, 100.0), (2.4, 98.0)]
|
||||
assert _trim(slope, "fill", lambda o: 101.0) is None
|
||||
|
||||
|
||||
def test_extends_last_grade_when_section_too_short():
|
||||
"""단면 반폭 안에서 지반을 못 만나면 마지막 구배로 연장해 접점을 찾는다."""
|
||||
slope = [(0.0, 100.0), (1.0, 101.0)] # 지반 103은 폴리라인 밖
|
||||
trimmed = _trim(slope, "cut", lambda o: 103.0)
|
||||
assert trimmed is not None
|
||||
assert abs(trimmed[-1][0] - 3.0) < EXTEND_STEP + 1e-9 # 100+o=103 → o=3 부근
|
||||
assert abs(trimmed[-1][1] - 103.0) < 1e-9
|
||||
|
||||
|
||||
def test_pinched_slope_is_dropped():
|
||||
"""전환점 부근처럼 접점이 코앞이면(폭 < 하한) 조각을 만들지 않는다."""
|
||||
slope = [(0.0, 100.0), (2.0, 102.0)]
|
||||
assert _trim(slope, "cut", lambda o: 100.005) is None
|
||||
|
||||
|
||||
def test_source_no_longer_morphs_to_degenerate_point():
|
||||
"""빌더에서 모핑 장치(degeneratePiece·taper)가 사라지고 트림이 들어갔는지."""
|
||||
assert "degeneratePiece" not in BUILD
|
||||
assert "taper" not in BUILD
|
||||
assert "trimSlopeToGround" in BUILD and "mixedGround" in BUILD
|
||||
assert "slopeRelative" in STATION # 비탈도 측구처럼 도로부 앵커 상대 좌표.
|
||||
|
||||
|
||||
def test_outline_follows_row_extent():
|
||||
"""클리핑 외곽이 측점 보간이 아니라 행 실점유(비탈→측구→노견)를 따른다."""
|
||||
assert "outerOf" in BUILD
|
||||
assert "lerpOuter" not in BUILD
|
||||
@@ -0,0 +1,85 @@
|
||||
"""코리도 평면 궤적 스플라인 성질 검증 (TS 구현과 같은 식을 파이썬으로 재현).
|
||||
|
||||
노선 정점을 직선으로 이으면 정점 간격(실측 평균 2.6m)만큼 각진다. Centripetal
|
||||
Catmull-Rom으로 바꾼 뒤 지켜야 할 두 가지를 확인한다.
|
||||
1) 제어점(노선 정점)을 그대로 지난다 — 노선을 벗어나면 안 된다.
|
||||
2) 급커브(실측 최대 42°)에서도 오버슈트가 없다 — 제어 다각형 밖으로 튀지 않는다.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
ALPHA = 0.5
|
||||
|
||||
|
||||
def _catmull_rom(p0, p1, p2, p3, t):
|
||||
"""TS `catmullRom()`과 같은 식(Barry-Goldman 재귀, centripetal)."""
|
||||
|
||||
def knot(previous, a, b):
|
||||
return previous + max(1e-6, math.dist(a, b) ** ALPHA)
|
||||
|
||||
t0 = 0.0
|
||||
t1 = knot(t0, p0, p1)
|
||||
t2 = knot(t1, p1, p2)
|
||||
t3 = knot(t2, p2, p3)
|
||||
time = t1 + (t2 - t1) * t
|
||||
|
||||
def mix(a, b, ta, tb):
|
||||
span = (tb - ta) or 1e-9
|
||||
w = (tb - time) / span
|
||||
return (a[0] * w + b[0] * (1 - w), a[1] * w + b[1] * (1 - w))
|
||||
|
||||
a1 = mix(p0, p1, t0, t1)
|
||||
a2 = mix(p1, p2, t1, t2)
|
||||
a3 = mix(p2, p3, t2, t3)
|
||||
b1 = mix(a1, a2, t0, t2)
|
||||
b2 = mix(a2, a3, t1, t3)
|
||||
return mix(b1, b2, t1, t2)
|
||||
|
||||
|
||||
def test_passes_through_control_points():
|
||||
"""t=0은 p1, t=1은 p2 — 스플라인이 노선 정점을 그대로 지난다."""
|
||||
p0, p1, p2, p3 = (0.0, 0.0), (2.0, 0.0), (4.0, 1.0), (6.0, 1.0)
|
||||
start = _catmull_rom(p0, p1, p2, p3, 0.0)
|
||||
end = _catmull_rom(p0, p1, p2, p3, 1.0)
|
||||
assert math.dist(start, p1) < 1e-9
|
||||
assert math.dist(end, p2) < 1e-9
|
||||
|
||||
|
||||
def test_no_overshoot_on_sharp_turn():
|
||||
"""42° 꺾임에서도 구간이 제어 다각형 근방을 벗어나지 않는다(centripetal 성질)."""
|
||||
angle = math.radians(42)
|
||||
p0 = (-2.6, 0.0)
|
||||
p1 = (0.0, 0.0)
|
||||
p2 = (2.6 * math.cos(angle), 2.6 * math.sin(angle))
|
||||
p3 = (p2[0] + 2.6 * math.cos(angle), p2[1] + 2.6 * math.sin(angle))
|
||||
chord = math.dist(p1, p2)
|
||||
for step in range(21):
|
||||
point = _catmull_rom(p0, p1, p2, p3, step / 20)
|
||||
# 구간 양 끝에서의 거리 합이 현의 길이를 크게 넘지 않으면 부풀지 않은 것이다.
|
||||
detour = math.dist(point, p1) + math.dist(point, p2)
|
||||
assert detour <= chord * 1.10, f"t={step / 20}: 우회 {detour:.3f} > 현 {chord:.3f}"
|
||||
|
||||
|
||||
def test_max_turn_angle_drops():
|
||||
"""스플라인은 큰 꺾임 하나를 작은 꺾임 여럿으로 나눈다 — **최대** 꺾임각이 준다.
|
||||
(합이 아니라 최대각이 지표다: 점이 많아지면 합은 당연히 커진다.)"""
|
||||
pts = [(0.0, 0.0), (2.6, 0.0), (4.5, 1.8), (7.1, 1.8), (9.0, 3.6)]
|
||||
|
||||
def max_turn(path):
|
||||
worst = 0.0
|
||||
for i in range(1, len(path) - 1):
|
||||
a = (path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1])
|
||||
b = (path[i + 1][0] - path[i][0], path[i + 1][1] - path[i][1])
|
||||
na, nb = math.hypot(*a), math.hypot(*b)
|
||||
if na < 1e-9 or nb < 1e-9:
|
||||
continue
|
||||
cos = max(-1.0, min(1.0, (a[0] * b[0] + a[1] * b[1]) / (na * nb)))
|
||||
worst = max(worst, math.degrees(math.acos(cos)))
|
||||
return worst
|
||||
|
||||
dense = []
|
||||
for i in range(1, len(pts) - 2):
|
||||
for step in range(10):
|
||||
dense.append(_catmull_rom(pts[i - 1], pts[i], pts[i + 1], pts[i + 2], step / 10))
|
||||
assert max_turn(dense) < max_turn(pts) / 2
|
||||
@@ -0,0 +1,88 @@
|
||||
"""코리도 절토↔성토 전환점과 측구 타입(일반/L형) 반영 검증.
|
||||
|
||||
전환점(2026-08-23 사용자 확정): 두 측점 사이에서 계획선이 지면선을 통과하는 자리가
|
||||
측구·절토의 종점이고, 거기서부터 성토가 시작된다. TS `buildCrossingFinder()`와 같은
|
||||
식을 파이썬으로 재현해 경계 처리를 확인하고, 측구 두 형식이 엔진에서 서로 다른
|
||||
단면으로 나오는지도 대조한다(3D는 그 단면을 잘라 쓰므로 형식이 그대로 반영된다).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 2026-08-23 700줄 분리로 측점 분류부가 Station 파일로 이사했다.
|
||||
BUILD_SOURCE = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Station.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _crossing(samples, lo, hi):
|
||||
"""TS buildCrossingFinder와 같은 규칙 — 구간 안쪽 교차만 돌려준다."""
|
||||
for i in range(1, len(samples)):
|
||||
a_ch, a_diff = samples[i - 1]
|
||||
b_ch, b_diff = samples[i]
|
||||
if b_ch <= lo or a_ch >= hi:
|
||||
continue
|
||||
if a_diff == 0:
|
||||
if lo < a_ch < hi:
|
||||
return a_ch
|
||||
continue
|
||||
if a_diff * b_diff >= 0:
|
||||
continue
|
||||
span = b_ch - a_ch
|
||||
ratio = 0 if span <= 1e-12 else a_diff / (a_diff - b_diff)
|
||||
crossing = a_ch + span * ratio
|
||||
if lo < crossing < hi:
|
||||
return crossing
|
||||
return None
|
||||
|
||||
|
||||
def test_finds_cut_to_fill_crossing():
|
||||
"""절토(-)에서 성토(+)로 넘어가는 자리를 선형보간으로 집는다."""
|
||||
samples = [(0.0, -1.0), (10.0, -0.5), (20.0, 0.5)]
|
||||
assert _crossing(samples, 0.0, 20.0) == 15.0
|
||||
|
||||
|
||||
def test_start_point_touching_ground_is_not_a_crossing():
|
||||
"""BP·EP는 정의상 지반과 만난다 — 구간 경계의 0을 전환점으로 삼으면 안 된다.
|
||||
(실제로 이걸 안 걸러 측구·절토가 0.4m 만에 끝났다 — 2026-08-23 실측)"""
|
||||
samples = [(0.0, 0.0), (10.0, -0.4), (20.0, 0.7)]
|
||||
crossing = _crossing(samples, 0.0, 20.0)
|
||||
assert crossing is not None and crossing > 0.0
|
||||
assert abs(crossing - 10.0 - 10.0 * (0.4 / 1.1)) < 1e-9
|
||||
|
||||
|
||||
def test_no_crossing_when_all_fill():
|
||||
"""전 구간 성토면 전환점이 없다 — 호출부가 중간(0.5)으로 폴백한다."""
|
||||
assert _crossing([(0.0, 0.3), (10.0, 0.8), (20.0, 1.4)], 0.0, 20.0) is None
|
||||
|
||||
|
||||
def test_ditch_width_follows_type():
|
||||
"""3D 빌더가 측구 형식별 폭 필드를 구분해 쓰는지 — 일반은 상단폭, L형은 폭."""
|
||||
assert 'ditch.type === "standard"' in BUILD_SOURCE
|
||||
assert "ditch.top_width_m" in BUILD_SOURCE
|
||||
assert "ditch.width_m" in BUILD_SOURCE
|
||||
|
||||
|
||||
def test_ditch_shapes_differ_between_types():
|
||||
"""엔진이 두 형식을 서로 다른 단면으로 낸다(3D는 이 단면을 잘라 쓴다)."""
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
|
||||
samples = [{"offset_m": o / 2, "elevation_m": 100.0 + o * 0.02, "valid": True} for o in range(-40, 41)]
|
||||
common = dict(
|
||||
samples=samples,
|
||||
design_elevation_m=100.0,
|
||||
ground_type="ripping_rock",
|
||||
section_mode="left_cut",
|
||||
ditch_side="left",
|
||||
)
|
||||
standard = compute_cross_design(**common, ditch_type="standard")
|
||||
l_type = compute_cross_design(**common, ditch_type="l_type")
|
||||
assert standard["ditch"]["type"] == "standard"
|
||||
assert l_type["ditch"]["type"] == "l_type"
|
||||
# 일반은 사다리꼴(상단폭/저폭/깊이), L형은 폭·깊이만 — 형상이 다르다.
|
||||
assert "top_width_m" in standard["ditch"] and "bottom_width_m" in standard["ditch"]
|
||||
assert "width_m" in l_type["ditch"] and "bottom_width_m" not in l_type["ditch"]
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B05 잘린 자리 윤곽(`cut-merged`) 회귀검증 — 2026-09-02.
|
||||
|
||||
`dropCollinear`가 추적 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 시작 모서리를
|
||||
지우던 결함(실측 63셀 구멍)의 재발 방지. Node 헬퍼(helper_b05_cut_merged_loop.cjs)가
|
||||
⚠ 도우미 확장자가 **`.cjs`** 인 이유(2026-09-07) — 루트 `package.json` 에
|
||||
`"type": "module"` 이 있어 `.js` 는 Node 판·환경에 따라 ESM 으로 읽힌다. 그러면 도우미의
|
||||
`require` 가 「require is not defined in ES module scope」로 죽는다(보조 창 폴더에서 실제로
|
||||
9건이 그렇게 실패했고, 같은 파일이 이 폴더에서는 통과해 **환경 차이**임이 드러났다).
|
||||
`.cjs` 는 `type` 과 무관하게 언제나 CommonJS 라 어느 폴더·어느 Node 에서도 같게 돈다.
|
||||
|
||||
`_Corridor_Cut.ts`를 트랜스파일해 합성 리본에 돌리고, pytest가 결과를 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_cut_merged_loop.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_rect_every_masked_cell_inside_loop(results):
|
||||
"""도려낸 셀은 전부 병합 고리 안 — 시작 모서리 쐐기가 빠지면 여기서 걸린다."""
|
||||
r = results["rect"]
|
||||
assert r["masked"] > 0
|
||||
assert r["uncovered"] == [], r["uncovered"]
|
||||
assert r["covered"] == r["masked"]
|
||||
|
||||
|
||||
def test_rect_loop_keeps_four_corners_and_closure(results):
|
||||
"""직사각형 마스크 → 모서리 4 + 닫힘점 1. 종전에는 머리 모서리가 빠져 3+1이었다."""
|
||||
loops = results["rect"]["loops"]
|
||||
assert len(loops) == 1
|
||||
assert loops[0]["closed"] is True
|
||||
assert loops[0]["n"] == 5, loops[0]["points"]
|
||||
|
||||
|
||||
def test_single_cell_loop_intact(results):
|
||||
"""한 셀짜리 고리도 4모서리 유지."""
|
||||
r = results["single"]
|
||||
assert r["masked"] >= 1
|
||||
assert r["uncovered"] == []
|
||||
assert all(loop["n"] == 5 and loop["closed"] for loop in r["loops"]), r["loops"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""설계속도 축 검증 — 임도 종류 3종 · 기본 20km/h · 법정 상한 매핑.
|
||||
|
||||
2026-08-19 사용자 확정: 임도는 속도를 낼 수 없는 노선이라 설계속도 기본은 20이고,
|
||||
간선·산불진화만 30·40을 고를 수 있다. 작업임도는 20 고정(별표2 Ⅰ.3).
|
||||
종단기울기 상한 표(지식DB 설계제원_총괄 §6)는 설계속도 × 지형 구분으로 정해진다.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct, resolve_design_speed
|
||||
from config.config_system import FOREST_ROAD_PROFILE_CRITERIA, ROUTE_GRADE_CLASSES
|
||||
|
||||
|
||||
def test_road_types_are_current_three_plus_legacy():
|
||||
"""현행 3종(간선·산불진화·작업) + 폐지된 지선(기존 저장분 호환)."""
|
||||
assert set(ROUTE_GRADE_CLASSES) == {"trunk", "fire", "work", "branch"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("grade_class", ["trunk", "fire", "work", "branch"])
|
||||
def test_default_design_speed_is_20(grade_class):
|
||||
"""설계속도를 고르지 않으면 어느 종류든 20km/h가 기본이다."""
|
||||
assert resolve_design_speed(grade_class) == 20
|
||||
|
||||
|
||||
def test_selectable_speeds_by_road_type():
|
||||
"""간선·산불진화는 20~40, 작업임도는 20만 고를 수 있다."""
|
||||
selectable = FOREST_ROAD_PROFILE_CRITERIA["selectable_design_speeds"]
|
||||
assert selectable["trunk"] == (20, 30, 40)
|
||||
assert selectable["fire"] == (20, 30, 40)
|
||||
assert selectable["work"] == (20,)
|
||||
|
||||
|
||||
def test_selected_speed_wins_when_allowed():
|
||||
"""허용된 속도를 고르면 그 값이 쓰이고, 허용 밖이면 기본으로 돌아간다."""
|
||||
assert resolve_design_speed("trunk", 40) == 40
|
||||
assert resolve_design_speed("fire", 30) == 30
|
||||
# 작업임도는 20만 허용 — 40을 보내도 20으로 되돌린다.
|
||||
assert resolve_design_speed("work", 40) == 20
|
||||
# 표에 없는 값도 기본으로.
|
||||
assert resolve_design_speed("trunk", 25) == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("speed", "normal", "special"),
|
||||
[(40, 7.0, 10.0), (30, 8.0, 12.0), (20, 9.0, 14.0)],
|
||||
)
|
||||
def test_grade_limit_follows_design_speed(speed, normal, special):
|
||||
"""종단기울기 상한 = 설계속도 × 지형(지식DB 설계제원_총괄 §6 표와 동일)."""
|
||||
assert legal_grade_limit_pct("trunk", "normal", False, speed) == normal
|
||||
assert legal_grade_limit_pct("trunk", "special", False, speed) == special
|
||||
|
||||
|
||||
def test_work_road_ignores_high_speed_and_uses_20_limits():
|
||||
"""작업임도는 40을 보내도 20km/h 기준(9%/14%)을 쓴다."""
|
||||
assert legal_grade_limit_pct("work", "normal", False, 40) == 9.0
|
||||
assert legal_grade_limit_pct("work", "special", False, 40) == 14.0
|
||||
|
||||
|
||||
def test_paved_special_terrain_exception_still_applies():
|
||||
"""특수지형 + 노면포장은 18% 예외 상한(별표2) — 설계속도와 무관하게 유지."""
|
||||
assert legal_grade_limit_pct("trunk", "special", True, 40) == 18.0
|
||||
assert legal_grade_limit_pct("work", "special", True, 20) == 18.0
|
||||
@@ -0,0 +1,135 @@
|
||||
"""배수 추천 구조물·관경과 자동 배치 주입 (PLAN 2026-08-17 「4」).
|
||||
|
||||
추천 근거는 **유량(유효직경)뿐**이다 — 계곡 횡단경사·하천 차수는 지형 계산이 필요해
|
||||
이번 범위에서 뺐고 화면이 "현장 확인"으로 안내한다(사용자 확정).
|
||||
|
||||
D ≤ 1,500㎜ 배관 (레지스트리 선택지로 스냅, 하한 800㎜)
|
||||
1,500 < D ≤ 2,000 BOX암거 후보
|
||||
D > 2,000㎜ 세월교·물넘이 검토
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import _apply_recommendations
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from common_util.common_util_drainage_detail import (
|
||||
DrainageDetail,
|
||||
WatershedBasin,
|
||||
recommend_structure,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_BOX,
|
||||
PIPE_FACILITY_FORD_BRIDGE,
|
||||
PIPE_FACILITY_PIPE,
|
||||
PIPE_SOURCE_STREAM,
|
||||
PIPE_SOURCE_USER,
|
||||
PipePoint,
|
||||
)
|
||||
from config.config_system import DRAINAGE_RECOMMEND_DIAMETERS_MM
|
||||
|
||||
|
||||
# ── 추천 판정 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"diameter_mm, expected",
|
||||
[
|
||||
(None, (PIPE_FACILITY_PIPE, None)), # 강우량표 없음 — 관경 미정
|
||||
(300.0, (PIPE_FACILITY_PIPE, 800)), # 하한 800㎜ 아래로는 안 내린다
|
||||
(800.0, (PIPE_FACILITY_PIPE, 800)),
|
||||
(800.1, (PIPE_FACILITY_PIPE, 1000)), # 규격을 넘으면 바로 위 규격
|
||||
(1000.0, (PIPE_FACILITY_PIPE, 1000)),
|
||||
(1100.0, (PIPE_FACILITY_PIPE, 1200)),
|
||||
(1500.0, (PIPE_FACILITY_PIPE, 1500)), # 경계값은 아직 배관
|
||||
(1500.1, (PIPE_FACILITY_BOX, None)), # 초과 → BOX암거 후보
|
||||
(2000.0, (PIPE_FACILITY_BOX, None)), # 경계값은 아직 BOX암거
|
||||
(2000.1, (PIPE_FACILITY_FORD_BRIDGE, None)), # 관 최대 규격 초과
|
||||
],
|
||||
)
|
||||
def test_recommend_structure_thresholds(diameter_mm, expected):
|
||||
assert recommend_structure(diameter_mm) == expected
|
||||
|
||||
|
||||
def test_recommended_sizes_match_registry_choices():
|
||||
"""추천 관경은 폼에서 고를 수 있어야 한다 — 레지스트리 선택지와 같은 목록."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
options = {option.key: option for option in by_id["pipe"].options}
|
||||
choices = [int(value) for value in options["pipe_diameter_mm"].choices]
|
||||
assert list(DRAINAGE_RECOMMEND_DIAMETERS_MM) == choices
|
||||
|
||||
|
||||
# ── 자동 배치 주입 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _detail(*basins: WatershedBasin) -> DrainageDetail:
|
||||
detail = DrainageDetail.__new__(DrainageDetail)
|
||||
detail.basins = list(basins)
|
||||
return detail
|
||||
|
||||
|
||||
def _basin(chainage: float, facility: str, diameter: int | None) -> WatershedBasin:
|
||||
return WatershedBasin(
|
||||
index=1,
|
||||
chainage_m=chainage,
|
||||
outlet_x=0.0,
|
||||
outlet_y=0.0,
|
||||
recommended_facility=facility,
|
||||
recommended_diameter_mm=diameter,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_pipe_gets_recommended_diameter():
|
||||
"""자동 배치 관은 폼을 열지 않아도 유역 유량에 맞는 관경을 갖는다."""
|
||||
points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_STREAM)]
|
||||
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1200)), points)
|
||||
assert points[0].options == {"pipe_diameter_mm": 1200}
|
||||
assert points[0].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
def test_auto_point_switches_facility_to_box():
|
||||
"""추천이 BOX암거면 시설 종류까지 바꾼다(관경은 붙이지 않는다)."""
|
||||
points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_STREAM)]
|
||||
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points)
|
||||
assert points[0].facility == PIPE_FACILITY_BOX
|
||||
assert points[0].options is None
|
||||
|
||||
|
||||
def test_user_placed_point_is_never_overwritten():
|
||||
"""사용자가 직접 놓거나 옮긴 관은 재계산이 건드리지 않는다."""
|
||||
points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_USER)]
|
||||
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points)
|
||||
assert points[0].facility == PIPE_FACILITY_PIPE
|
||||
assert points[0].options is None
|
||||
|
||||
|
||||
def test_existing_option_is_kept():
|
||||
"""설계자가 고른 관경을 추천이 되돌리면 안 된다 — 빈칸만 채운다."""
|
||||
points = [
|
||||
PipePoint(
|
||||
chainage_m=100.0,
|
||||
source=PIPE_SOURCE_STREAM,
|
||||
options={"pipe_diameter_mm": 800},
|
||||
)
|
||||
]
|
||||
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1500)), points)
|
||||
assert points[0].options == {"pipe_diameter_mm": 800}
|
||||
|
||||
|
||||
def test_already_chosen_facility_is_kept():
|
||||
"""이미 다른 시설로 바꿔 둔 지점은 추천이 되돌리지 않는다."""
|
||||
points = [
|
||||
PipePoint(
|
||||
chainage_m=100.0,
|
||||
source=PIPE_SOURCE_STREAM,
|
||||
facility=PIPE_FACILITY_FORD_BRIDGE,
|
||||
)
|
||||
]
|
||||
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points)
|
||||
assert points[0].facility == PIPE_FACILITY_FORD_BRIDGE
|
||||
|
||||
|
||||
def test_point_without_basin_is_untouched():
|
||||
"""담당 유역이 없는 관(유역 미산출)은 그대로 둔다."""
|
||||
points = [PipePoint(chainage_m=500.0, source=PIPE_SOURCE_STREAM)]
|
||||
_apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1200)), points)
|
||||
assert points[0].options is None
|
||||
@@ -0,0 +1,301 @@
|
||||
"""구조물(비정규) 측점의 단일 공급자 — 2026-08-28 [초기화] 후 측점 실종 건.
|
||||
|
||||
배경: 측점 정본은 파일(longitudinal.json stations + cross_*.json)인데, 그 파일을 쓰는
|
||||
`run_section_generation`에는 공급자가 없었다. 유일한 공급자가 프론트 [임시저장]이라
|
||||
초기화·반폭 재생성이 파일을 다시 쓸 때마다 구조물 측점이 통째로 사라졌다(실측: 초기화
|
||||
직후 cross 23 → 19, irregular 4 → 0).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
_load_pipe_points,
|
||||
cross_filename,
|
||||
resolve_extra_stations,
|
||||
run_section_generation,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
|
||||
# 실측 정본(프로젝트 c1bb453f) 그대로 — 라벨 문자열이 종단 정본과 글자까지 같아야 한다.
|
||||
LIVE_POINTS = [
|
||||
{
|
||||
"chainage_m": 84.3,
|
||||
"source": "spacing",
|
||||
"options": {"pipe_kind": "파형강관", "pipe_diameter_mm": 1000},
|
||||
},
|
||||
{"chainage_m": 149.73, "source": "stream", "facility": "ford_bridge"},
|
||||
{"chainage_m": 200.92, "source": "spacing", "facility": "box_culvert"},
|
||||
{"chainage_m": 275.71, "source": "spacing", "options": {"pipe_diameter_mm": 800}},
|
||||
]
|
||||
LIVE_LABELS = (
|
||||
(84.3, "파형강관 D1000"),
|
||||
(149.73, "세월교"),
|
||||
(200.92, "BOX암거"),
|
||||
(275.71, "파형강관 D800"),
|
||||
)
|
||||
|
||||
STRAIGHT_LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]]
|
||||
|
||||
|
||||
class _FlatSampler:
|
||||
"""표고 고정 스텁 — 측점 구성만 보는 테스트라 지형은 상수로 둔다."""
|
||||
|
||||
def sample_xy(self, xy):
|
||||
count = len(np.asarray(xy, dtype=np.float64))
|
||||
return np.full(count, 100.0), np.ones(count, dtype=bool)
|
||||
|
||||
|
||||
def _write_pipe_points(root, points, polyline=STRAIGHT_LINE, signature=None):
|
||||
edits = root / "B04_PreProcess" / "drainage" / "edits"
|
||||
edits.mkdir(parents=True, exist_ok=True)
|
||||
vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in polyline]
|
||||
(edits / "pipe_points.json").write_text(
|
||||
json.dumps(
|
||||
{"route_signature": signature or route_signature(vertices), "points": points},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_structures(root, structures):
|
||||
route_dir = root / "B05_Profile" / "route"
|
||||
route_dir.mkdir(parents=True, exist_ok=True)
|
||||
(route_dir / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# ── 라벨 파생 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_pipe_points_become_labelled_stations(tmp_path):
|
||||
assert resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) == LIVE_LABELS
|
||||
|
||||
|
||||
def test_pipe_without_options_falls_back_to_config_defaults(tmp_path):
|
||||
extras = resolve_extra_stations(tmp_path, parse_pipe_points([{"chainage_m": 30.0}]))
|
||||
assert extras == ((30.0, "파형강관 D800"),)
|
||||
|
||||
|
||||
def test_ford_pavement_is_not_an_irregular_station(tmp_path):
|
||||
"""물넘이포장은 비정규 측점이 아니다 — 종단 그래프 구조물 표시 몫(2026-08-28 사용자 확정)."""
|
||||
points = [{"chainage_m": 40.0, "facility": "ford_pavement"}, {"chainage_m": 60.0}]
|
||||
extras = resolve_extra_stations(tmp_path, parse_pipe_points(points))
|
||||
assert [chainage for chainage, _label in extras] == [60.0]
|
||||
|
||||
|
||||
# ── 구조물 정본 A군 합류 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_group_a_point_and_revetment_facility_join(tmp_path):
|
||||
"""A군 점형은 기준 한 곳, 기슭막이(관 시설)는 시작·기준·종료 세 곳(2026-08-28).
|
||||
|
||||
기슭막이는 구조물 정본에서 관 지점 정본으로 옮겨갔다(`managed_by: pipe_points`) —
|
||||
구간형 구조물이 아니라 관 시설 목록에서 온다.
|
||||
"""
|
||||
_write_structures(
|
||||
tmp_path,
|
||||
[{"type_id": "cross_drain_exposed", "chainage_m": 60.0, "placement": "point"}],
|
||||
)
|
||||
points = [
|
||||
*LIVE_POINTS,
|
||||
{
|
||||
"chainage_m": 100.0,
|
||||
"source": "user",
|
||||
"facility": "revetment",
|
||||
"start_m": 95.0,
|
||||
"end_m": 115.0,
|
||||
},
|
||||
]
|
||||
extras = resolve_extra_stations(tmp_path, parse_pipe_points(points))
|
||||
assert (60.0, "노출형 횡단수로") in extras
|
||||
assert [chainage for chainage, label in extras if label == "기슭막이"] == [95.0, 100.0, 115.0]
|
||||
assert [chainage for chainage, _label in extras] == [
|
||||
60.0,
|
||||
84.3,
|
||||
95.0,
|
||||
100.0,
|
||||
115.0,
|
||||
149.73,
|
||||
200.92,
|
||||
275.71,
|
||||
]
|
||||
|
||||
|
||||
def test_other_groups_do_not_plant_stations(tmp_path):
|
||||
"""C·E·G군은 측점을 만들지 않는다 — 지금 켠 것은 A군과 D군 구간형뿐이다."""
|
||||
_write_structures(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type_id": "retaining_wall",
|
||||
"chainage_m": 40.0,
|
||||
"start_m": 40.0,
|
||||
"end_m": 60.0,
|
||||
"placement": "interval",
|
||||
},
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"chainage_m": 300.0,
|
||||
"start_m": 295.0,
|
||||
"end_m": 305.0,
|
||||
"placement": "interval",
|
||||
},
|
||||
],
|
||||
)
|
||||
extras = resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS))
|
||||
assert [chainage for chainage, _label in extras] == [84.3, 149.73, 200.92, 275.71]
|
||||
|
||||
|
||||
def test_broken_structures_file_keeps_pipe_derived_stations(tmp_path):
|
||||
route_dir = tmp_path / "B05_Profile" / "route"
|
||||
route_dir.mkdir(parents=True, exist_ok=True)
|
||||
(route_dir / "structures.json").write_text("{not json", encoding="utf-8")
|
||||
assert resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) == LIVE_LABELS
|
||||
|
||||
|
||||
# ── 관 정본 로드·지문 게이트 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_missing_pipe_points_file_is_empty_not_error(tmp_path):
|
||||
assert _load_pipe_points(tmp_path, STRAIGHT_LINE) == []
|
||||
|
||||
|
||||
def test_route_signature_mismatch_drops_points_with_warning(tmp_path, caplog):
|
||||
"""지문이 다르고 좌표도 없는 구 저장분만 버린다 — 침묵하지 않고 경고를 남긴다.
|
||||
|
||||
좌표가 있으면 그 선에 투영해 이월한다(2026-08-30). LIVE_POINTS에는 좌표가 없다.
|
||||
"""
|
||||
_write_pipe_points(tmp_path, LIVE_POINTS, signature="옛노선-0000")
|
||||
with caplog.at_level("WARNING"):
|
||||
assert _load_pipe_points(tmp_path, STRAIGHT_LINE) == []
|
||||
assert any("좌표 없는 구 저장분" in record.getMessage() for record in caplog.records)
|
||||
|
||||
|
||||
def test_matching_signature_loads_points(tmp_path):
|
||||
_write_pipe_points(tmp_path, LIVE_POINTS)
|
||||
assert [pipe.chainage_m for pipe in _load_pipe_points(tmp_path, STRAIGHT_LINE)] == [
|
||||
84.3,
|
||||
149.73,
|
||||
200.92,
|
||||
275.71,
|
||||
]
|
||||
|
||||
|
||||
# ── 횡단 파일명 충돌 스냅(격자 우선 불변식) ────────────────────────────────
|
||||
|
||||
|
||||
def _stations(extra_stations):
|
||||
result = generate_sections(
|
||||
STRAIGHT_LINE,
|
||||
_FlatSampler(),
|
||||
SectionGenerationOptions(station_interval_m=20.0, extra_stations=extra_stations),
|
||||
)
|
||||
return result["longitudinal"]["stations"]
|
||||
|
||||
|
||||
def test_extra_station_within_one_meter_snaps_to_grid():
|
||||
stations = _stations(((100.4, "X"),))
|
||||
assert 100.4 not in [station["chainage_m"] for station in stations]
|
||||
labelled = [station for station in stations if station.get("structure") == "X"]
|
||||
assert [station["chainage_m"] for station in labelled] == [100.0]
|
||||
assert labelled[0]["kind"] == "regular" # 격자 측점 우선 — 기존 불변식
|
||||
|
||||
|
||||
def test_two_extras_in_same_meter_bucket_keep_one():
|
||||
stations = _stations(((80.2, "A"), (80.4, "B")))
|
||||
assert [station["chainage_m"] for station in stations if station.get("structure")] == [80.0]
|
||||
|
||||
|
||||
def test_live_extras_stay_irregular_and_filenames_stay_unique():
|
||||
stations = _stations(LIVE_LABELS)
|
||||
irregular = [station for station in stations if station["kind"] == "irregular"]
|
||||
assert [station["chainage_m"] for station in irregular] == [84.3, 149.73, 200.92, 275.71]
|
||||
assert [station["structure"] for station in irregular] == [
|
||||
label for _chainage, label in LIVE_LABELS
|
||||
]
|
||||
assert len({cross_filename(station["chainage_m"]) for station in stations}) == len(stations)
|
||||
|
||||
|
||||
# ── 단일 공급자 계약 (run_section_generation) ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path, monkeypatch):
|
||||
route_dir = tmp_path / "B05_Profile" / "route"
|
||||
route_dir.mkdir(parents=True, exist_ok=True)
|
||||
(route_dir / "route_main.geojson").write_text(
|
||||
json.dumps({"geometry": {"type": "LineString", "coordinates": STRAIGHT_LINE}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"B05_Profile.B05_Profile_Engine_Sections.build_surface_sampler",
|
||||
lambda *args, **kwargs: _FlatSampler(),
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _generate(project_root, options=None):
|
||||
return run_section_generation(
|
||||
project_root,
|
||||
"B05_Profile/route/route_main.geojson",
|
||||
"csf",
|
||||
"dtm",
|
||||
True,
|
||||
options=options or SectionGenerationOptions(station_interval_m=20.0),
|
||||
)
|
||||
|
||||
|
||||
def _saved_stations(project_root):
|
||||
path = project_root / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))["stations"]
|
||||
|
||||
|
||||
def test_run_section_generation_derives_stations_from_truth_files(project):
|
||||
_write_pipe_points(project, LIVE_POINTS)
|
||||
result = _generate(project)
|
||||
|
||||
stations = _saved_stations(project)
|
||||
irregular = [station for station in stations if station["kind"] == "irregular"]
|
||||
assert [station["chainage_m"] for station in irregular] == [84.3, 149.73, 200.92, 275.71]
|
||||
assert [station["structure"] for station in irregular] == [
|
||||
label for _chainage, label in LIVE_LABELS
|
||||
]
|
||||
|
||||
cross_dir = project / "B06_Section" / "cross_sections"
|
||||
for name in ("cross_00084m", "cross_00150m", "cross_00201m", "cross_00276m"):
|
||||
assert (cross_dir / f"{name}.json").is_file()
|
||||
# 파일 = DB 행 — 종전에는 파일 23 vs DB 19로 갈렸다.
|
||||
assert len(result["cross_sections"]) == len(stations)
|
||||
|
||||
|
||||
def test_caller_supplied_extra_stations_are_ignored(project):
|
||||
"""호출자 값을 되먹이면 B04에서 지운 관이 스냅샷을 타고 부활한다 — 정본 파일만 쓴다."""
|
||||
_write_pipe_points(project, LIVE_POINTS)
|
||||
_generate(
|
||||
project,
|
||||
SectionGenerationOptions(station_interval_m=20.0, extra_stations=((10.0, "가짜"),)),
|
||||
)
|
||||
stations = _saved_stations(project)
|
||||
assert all(station.get("structure") != "가짜" for station in stations)
|
||||
assert [station["chainage_m"] for station in stations if station["kind"] == "irregular"] == [
|
||||
84.3,
|
||||
149.73,
|
||||
200.92,
|
||||
275.71,
|
||||
]
|
||||
|
||||
|
||||
def test_without_pipe_points_only_grid_stations(project):
|
||||
_generate(project)
|
||||
assert all(station["kind"] != "irregular" for station in _saved_stations(project))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""평면(지도)에 **구간형 구조물 띠**가 서는지 — 계획서 3-6 마지막 항목.
|
||||
|
||||
왜 (2026-09-07 사용자 지시) — 산마루측구·도수로·옹벽처럼 **구간**으로 놓이는 시설이 종단에는
|
||||
띠로 보이는데 **평면에는 아무 표시가 없었다**. 계획서에는 「노선 위에 임의 구간을 얹는 부품이
|
||||
없어 새로 짜야 함」으로 남아 있었다.
|
||||
|
||||
새로 짠 것은 작다 — 계획선 표본(`strengthSamples`)이 **1m 간격이라 배열 인덱스가 곧
|
||||
누가거리**여서, 구간 → 화면 선은 그 토막을 잘라 굵게 긋기만 하면 된다.
|
||||
|
||||
이 시험이 지키는 것 — ① 띠를 그리는 부품이 있고 ② 배수유역도가 그것을 계획선 **위**,
|
||||
강도 색칠 **아래**에 그리며 ③ 띠 자료는 **종단과 같은 자료**(구조물 정본 + 타입 레지스트리)에서
|
||||
나오고 ④ 구간이 없는 점형 시설은 빠지는 것.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
_B05 = PROJECT_ROOT / "B05_Profile"
|
||||
_SPANS = _B05 / "B05_Profile_UI_Drainage_Spans.ts"
|
||||
_RENDER = _B05 / "B05_Profile_UI_Drainage_Render.ts"
|
||||
_PANEL = _B05 / "B05_Profile_UI_Drainage_Panel.ts"
|
||||
_PROFILE = _B05 / "B05_Profile_UI_Profile_Panel.ts"
|
||||
|
||||
|
||||
def test_띠를_그리는_부품이_있다():
|
||||
source = _SPANS.read_text(encoding="utf-8")
|
||||
assert "export function drawRouteSpans" in source
|
||||
# 인덱스 = 누가거리(m) 라는 전제가 코드에 남아 있어야 한다 — 표본 간격이 바뀌면 깨진다.
|
||||
assert "Math.floor(Math.min(span.startM, span.endM))" in source
|
||||
|
||||
|
||||
def test_범위_밖_구간도_안전하게_잘린다():
|
||||
"""저장분에 노선보다 긴 구간이 남아 있어도 그리다 죽지 않아야 한다."""
|
||||
source = _SPANS.read_text(encoding="utf-8")
|
||||
assert "Math.max(0, Math.min(last," in source
|
||||
assert "Math.max(from + 1," in source, "시작=끝인 구간이 사라지지 않게 한 칸을 준다"
|
||||
|
||||
|
||||
def test_계획선_위_강도색칠_아래에_그린다():
|
||||
"""쌓는 순서가 뒤집히면 띠가 계획선을 덮거나 마커에 가린다."""
|
||||
render = _RENDER.read_text(encoding="utf-8")
|
||||
assert "drawRouteSpans(context, scene.strengthSamples" in render
|
||||
# 호출 자리끼리 견준다 — 파일 머리의 import 는 순서 판정에 쓰면 안 된다.
|
||||
order_route = render.index("drawPreparedLayer(context, scene.routeLayer")
|
||||
order_span = render.index("drawRouteSpans(context")
|
||||
order_strength = render.index("drawStrengthLine(context")
|
||||
assert order_route < order_span < order_strength
|
||||
|
||||
|
||||
def test_종단과_같은_자료에서_뽑는다():
|
||||
"""색은 레지스트리 표시색, 구간은 구조물 정본 — 새 정본을 만들지 않는다."""
|
||||
spans = _SPANS.read_text(encoding="utf-8")
|
||||
assert "export function routeSpansFromStructures" in spans
|
||||
assert 'item.placement !== "interval"' in spans, "구간형만 띠로 그린다"
|
||||
assert "type.style?.color" in spans, "색을 새로 정하지 않고 레지스트리 값을 쓴다"
|
||||
profile = _PROFILE.read_text(encoding="utf-8")
|
||||
assert "drainagePanel.setIntervalSpans(routeSpansFromStructures(" in profile
|
||||
|
||||
|
||||
def test_목록이_바뀌면_다시_그린다():
|
||||
"""구조물을 넣거나 빼면 띠도 따라와야 한다 — 안 그러면 옛 띠가 남는다."""
|
||||
profile = _PROFILE.read_text(encoding="utf-8")
|
||||
body = profile[profile.index("setStructures(next: StructureInstance[])") :][:400]
|
||||
assert "syncDrainageSpans()" in body
|
||||
types_body = profile[profile.index("setStructureTypes(types: StructureType[])") :][:300]
|
||||
assert "syncDrainageSpans()" in types_body
|
||||
panel = _PANEL.read_text(encoding="utf-8")
|
||||
assert "setIntervalSpans(spans) {" in panel and "scheduleDraw();" in panel
|
||||
@@ -0,0 +1,149 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""종단 편집 최소고 가드 개편(2026-08-23) 검증.
|
||||
|
||||
기존 가드는 측점 ▼에만 걸리고 관경 고정 산식(지반+관경+토피)이라 ① 구간 쉬프트가
|
||||
횡단배수 앵커를 최소고 아래로 끌어내렸고 ② BOX암거·세월교를 과소, 물넘이포장을
|
||||
과잉 차단했다. 개편: 편집 후보로 정렬을 계산해 시설별 최소고(minCoverPoints 원천)
|
||||
위반이 **새로 생기거나 커지면** 차단 — 측점·구간·곡선 경유 하강을 한 가드로 잡는다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
RENDER = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Render.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
GUARD = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _violations(targets, ground_at, plan_at, tolerance=0.001):
|
||||
"""TS findMinCoverViolations와 같은 규칙."""
|
||||
result = []
|
||||
for chainage, clearance in targets:
|
||||
required = ground_at(chainage) + clearance
|
||||
planned = plan_at(chainage)
|
||||
if planned < required - tolerance:
|
||||
result.append((chainage, required - planned))
|
||||
return result
|
||||
|
||||
|
||||
def _blocks(targets, ground_at, plan_now, plan_next):
|
||||
"""TS blocksMinCover와 같은 규칙 — 위반이 새로 생기거나 커지면 True."""
|
||||
planned = _violations(targets, ground_at, plan_next)
|
||||
if not planned:
|
||||
return False
|
||||
current = dict(_violations(targets, ground_at, plan_now))
|
||||
return any(short > current.get(chainage, 0) + 1e-6 for chainage, short in planned)
|
||||
|
||||
|
||||
TARGETS = [(40.0, 2.5)] # BOX암거 H2.0 → 지반 +2.5
|
||||
GROUND = lambda c: 100.0 # noqa: E731
|
||||
|
||||
|
||||
def test_blocks_new_violation():
|
||||
"""최소고 위(102.6)에서 아래(102.4)로 내리는 편집은 차단된다."""
|
||||
assert _blocks(TARGETS, GROUND, lambda c: 102.6, lambda c: 102.4)
|
||||
|
||||
|
||||
def test_allows_edit_down_to_exact_minimum():
|
||||
"""정확히 최소고(102.5)까지는 허용 — 한계에 앉히는 편집을 막지 않는다."""
|
||||
assert not _blocks(TARGETS, GROUND, lambda c: 102.6, lambda c: 102.5)
|
||||
|
||||
|
||||
def test_allows_recovery_when_already_violating():
|
||||
"""이미 위반(102.0)이면 악화가 아닌 한 허용 — 복구(올림) 편집을 막으면 안 된다."""
|
||||
assert not _blocks(TARGETS, GROUND, lambda c: 102.0, lambda c: 102.3)
|
||||
assert _blocks(TARGETS, GROUND, lambda c: 102.0, lambda c: 101.9) # 악화는 차단.
|
||||
|
||||
|
||||
def test_unlisted_station_is_free():
|
||||
"""대상 목록에 없는 자리(물넘이포장 등)는 어떤 편집도 막지 않는다."""
|
||||
assert not _blocks([], GROUND, lambda c: 102.6, lambda c: 90.0)
|
||||
|
||||
|
||||
def test_source_guard_sits_on_the_single_apply_path():
|
||||
"""가드가 **모든 편집이 지나는 한 곳**(`applyEdits`)에 걸려 있는지 소스 검사.
|
||||
|
||||
2026-09-02: 측점 끌기(`_Profile_Render`)에만 걸려 있어 [직선화]·[쉬프트]·틸팅·
|
||||
방향키가 그냥 지나갔다. 판정 로직을 `_Profile_MinCover.blocksMinCover` 로 옮기고
|
||||
`_Profile_Panel.applyEdits` 한 곳에서만 부른다 — 화면 쪽에는 사본을 두지 않는다.
|
||||
"""
|
||||
assert (
|
||||
"if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;"
|
||||
in PANEL
|
||||
)
|
||||
assert PANEL.count("blocksMinCover(") == 1 # 부르는 자리는 한 곳뿐.
|
||||
assert "export function blocksMinCover(" in GUARD
|
||||
assert "blocksMinCover" not in RENDER # 화면 모듈에 사본이 남지 않았다.
|
||||
# 판정점 = 제어점 z(라운드 중심) — 이웃 틸팅이 잠기지 않는다(2026-08-23).
|
||||
assert GUARD.count("controlElevationAt(") >= 2
|
||||
assert "planElevationAt(candidate" not in GUARD
|
||||
|
||||
|
||||
def test_source_old_diameter_formula_removed():
|
||||
"""관경 고정 산식·배관 판정이 사라지고 시설별 산식(minCoverPoints 원천)을 쓴다."""
|
||||
assert "blocksPipeLowering" not in RENDER
|
||||
assert "MIN_PIPE_COVER_M" not in RENDER
|
||||
assert "diameterMm ?? 1000" not in RENDER
|
||||
assert "findMinCoverViolations" in GUARD
|
||||
assert "minCoverTargets: () => minCoverTargets" in PANEL
|
||||
|
||||
|
||||
ALIGNMENT = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_shift_moves_hinges_not_fixed_ends():
|
||||
"""쉬프트 재정의(2026-08-23 사용자 개념 확정) 소스 잠금:
|
||||
고정점(BP·EP·배수 앵커)은 안 움직이고 안쪽 미틸트 측점 외곽 2개가 힌지로
|
||||
승격(라운드 생성)된다. 힌지 부족이면 쉬프트 불가(버튼 숨김)."""
|
||||
assert "export function shiftMovingPoints" in ALIGNMENT
|
||||
assert "export function controlElevationAt" in ALIGNMENT
|
||||
body = ALIGNMENT[ALIGNMENT.index("export function shiftSegment") :]
|
||||
assert "shiftMovingPoints(current, segment)" in body
|
||||
assert "if (!moving) return edits;" in body
|
||||
assert "FEEDBACK_PASSES" in body # 라운드(중앙종거) 낀 자리도 1클릭 = 정확 delta.
|
||||
EDIT = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Edit.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# 구간 쉬프트 버튼(⬆⬇)과 그 노출 조건(canShift)은 2026-09-02 지시로 삭제됐다.
|
||||
# 기하 함수는 남겨 두기로 했으므로(PLAN.md) 위 존재 검사는 그대로 두고 배선만 잠근다.
|
||||
assert "canShift" not in EDIT
|
||||
assert "canShift:" not in RENDER
|
||||
|
||||
|
||||
def _moving_points(stations, edited, seg_from, seg_to):
|
||||
"""TS shiftMovingPoints와 같은 규칙 — 끝점이 틸팅점이면 그 점, 아니면 안쪽 외곽 측점."""
|
||||
interior = [s for s in stations if seg_from + 1e-6 < s < seg_to - 1e-6]
|
||||
left = seg_from if seg_from in edited else (interior[0] if interior else None)
|
||||
right = seg_to if seg_to in edited else (interior[-1] if interior else None)
|
||||
if left is None or right is None or right - left < 1e-6:
|
||||
return None
|
||||
return (left, right)
|
||||
|
||||
|
||||
def test_moving_points_promote_interior_hinges():
|
||||
"""앵커-앵커 구간: 안쪽 미틸트 측점 외곽 2개(첫·끝)가 힌지가 된다."""
|
||||
assert _moving_points([20, 40, 60, 80], set(), 0, 84.3) == (20, 80)
|
||||
|
||||
|
||||
def test_moving_points_need_two_hinges():
|
||||
"""안쪽 측점이 1개 이하면 힌지 둘을 못 만든다 — 쉬프트 불가."""
|
||||
assert _moving_points([20], set(), 0, 30) is None
|
||||
assert _moving_points([], set(), 0, 30) is None
|
||||
|
||||
|
||||
def test_moving_points_reuse_tilted_ends():
|
||||
"""사용자가 틸팅한 끝점은 직접 움직인다 — 같은 구간 반복 쉬프트 경로."""
|
||||
assert _moving_points([40, 60], {20, 80}, 20, 80) == (20, 80)
|
||||
assert _moving_points([40, 60], {80}, 20, 80) == (40, 80)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""B05 횡단배수 최소 계획고 규칙 — TS 산식이 지식DB·엔진 상수와 맞는지 대조.
|
||||
|
||||
TS 구현(`B05_Profile_UI_Profile_MinCover.ts`)의 산식은
|
||||
배수관: 지반고 + 관경(m) + 토피 0.5
|
||||
BOX암거: 지반고 + 구체 높이(m) + 토피 0.5
|
||||
이다(2026-08-23 사용자 확정). 여기서는 그 상수·기본값이 코드에 그대로 있는지와
|
||||
B06 배수관 엔진의 토피 상수와 같은 값인지 확인한다 — 두 화면이 다른 토피를 쓰면
|
||||
종단에서 통과한 계획선이 횡단에서 성립하지 않는다.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import MIN_PIPE_COVER_M # noqa: E402
|
||||
from common_util.common_util_drainage_pipes import facility_clearance_m # noqa: E402
|
||||
|
||||
SOURCE = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _const(name: str) -> float:
|
||||
match = re.search(rf"{name}\s*=\s*([\d.]+)", SOURCE)
|
||||
assert match, f"{name} 상수를 찾지 못했습니다"
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def test_cover_matches_culvert_engine():
|
||||
"""토피 0.5m는 B06 배수관 엔진과 같은 값이어야 한다(두 화면 일관성)."""
|
||||
assert _const("MIN_COVER_M") == MIN_PIPE_COVER_M == 0.5
|
||||
|
||||
|
||||
def test_default_sizes_match_user_examples():
|
||||
"""사용자 확정 예시 기본값 — 배수관 Ø1000, BOX 2.0m."""
|
||||
assert _const("DEFAULT_PIPE_DIAMETER_MM") == 1000
|
||||
assert _const("DEFAULT_BOX_HEIGHT_M") == 2
|
||||
|
||||
|
||||
def test_clearance_examples():
|
||||
"""예시 그대로: Ø1000 → +1.5m, BOX 2.0×2.0 → +2.5m."""
|
||||
cover = _const("MIN_COVER_M")
|
||||
assert _const("DEFAULT_PIPE_DIAMETER_MM") / 1000 + cover == 1.5
|
||||
assert _const("DEFAULT_BOX_HEIGHT_M") + cover == 2.5
|
||||
|
||||
|
||||
def test_ford_bridge_is_pipe_plus_extra():
|
||||
"""세월교 = 배관 다발 — 배수관 산식 + 물넘이 몫 0.5m (2026-08-23 사용자 확정)."""
|
||||
assert facility_clearance_m("ford_bridge", {}) == 1.0 + MIN_PIPE_COVER_M + 0.5 == 2.0
|
||||
assert facility_clearance_m("ford_bridge", {"pipe_diameter_mm": 800}) == 0.8 + 0.5 + 0.5
|
||||
|
||||
|
||||
def test_ford_pavement_needs_no_clearance():
|
||||
"""물넘이포장은 도로에 그대로 만든다 — 요구 여유 0."""
|
||||
assert facility_clearance_m("ford_pavement", {}) == 0.0
|
||||
assert facility_clearance_m("ford_pavement", {"ford_height_m": 0.4}) == 0.0
|
||||
|
||||
|
||||
def test_backend_is_the_source_of_truth():
|
||||
"""화면 사본과 백엔드 정본 산식이 같은 값을 내야 한다(두 화면 일관성)."""
|
||||
assert facility_clearance_m("pipe", {}) == _const("DEFAULT_PIPE_DIAMETER_MM") / 1000 + _const(
|
||||
"MIN_COVER_M"
|
||||
)
|
||||
assert facility_clearance_m("box_culvert", {}) == _const("DEFAULT_BOX_HEIGHT_M") + _const(
|
||||
"MIN_COVER_M"
|
||||
)
|
||||
assert facility_clearance_m("ford_bridge", {}) == _const(
|
||||
"DEFAULT_PIPE_DIAMETER_MM"
|
||||
) / 1000 + _const("MIN_COVER_M") + _const("FORD_BRIDGE_EXTRA_M")
|
||||
assert facility_clearance_m("pipe", {"pipe_diameter_mm": 600}) == 0.6 + MIN_PIPE_COVER_M
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B05 패치 스커트(`B05_Profile_UI_Corridor_Skirt.ts`) 단위검증 — 2026-08-27.
|
||||
|
||||
프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_b05_patch_skirt.cjs)가 TS를
|
||||
⚠ 도우미 확장자가 **`.cjs`** 인 이유(2026-09-07) — 루트 `package.json` 에
|
||||
`"type": "module"` 이 있어 `.js` 는 Node 판·환경에 따라 ESM 으로 읽힌다. 그러면 도우미의
|
||||
`require` 가 「require is not defined in ES module scope」로 죽는다(보조 창 폴더에서 실제로
|
||||
9건이 그렇게 실패했고, 같은 파일이 이 폴더에서는 통과해 **환경 차이**임이 드러났다).
|
||||
`.cjs` 는 `type` 과 무관하게 언제나 CommonJS 라 어느 폴더·어느 Node 에서도 같게 돈다.
|
||||
|
||||
트랜스파일해 실행하고, pytest는 그 결과(JSON)를 검증한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def skirt_results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_patch_skirt.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_floating_edge_builds_hatched_skirt(skirt_results):
|
||||
"""뜬 모서리(지반 위 0.5m)는 마디마다 판 2장 + 빗금 UV + patchSkirt 표식."""
|
||||
r = skirt_results["floating"]
|
||||
assert r["walls"] == 1
|
||||
assert r["triangles"] == 4 # 마디 2개 × 2장
|
||||
assert r["hatched"] is True
|
||||
assert r["marked"] is True
|
||||
|
||||
|
||||
def test_submerged_edge_builds_nothing(skirt_results):
|
||||
"""잠긴 모서리(지반 아래)는 판 없음 — 지형이 덮는다."""
|
||||
r = skirt_results["submerged"]
|
||||
assert r["walls"] == 0
|
||||
assert r["skipped"] == 2
|
||||
|
||||
|
||||
def test_structure_ending_run_excluded(skirt_results):
|
||||
"""소유 측점 행 낙차 2m > 허용 0.5m — 벽 뒷면·구체 상단에서 끝나는 줄은 리본째 제외."""
|
||||
r = skirt_results["toeGate"]
|
||||
assert r["walls"] == 0
|
||||
assert r["reaches"] is False
|
||||
|
||||
|
||||
def test_tall_segment_skipped(skirt_results):
|
||||
"""게이트 통과 후에도 낙차 3m 초과 마디는 건너뛴다(지느러미 방지)."""
|
||||
r = skirt_results["tall"]
|
||||
assert r["skippedTall"] == 2 # 94를 낀 두 마디
|
||||
assert r["tris"] == 2 # 남은 정상 마디 1개 × 2장
|
||||
assert r["walls"] == 1
|
||||
|
||||
|
||||
def test_non_patch_and_cut_ignored(skirt_results):
|
||||
"""patch 표식 없는 리본·절토 리본은 스커트 대상이 아니다."""
|
||||
assert skirt_results["ignored"]["walls"] == 0
|
||||
@@ -0,0 +1,168 @@
|
||||
"""B05 횡단배수 최소고 강제 스위치(2026-09-01 사용자 지시).
|
||||
|
||||
배수 자리가 종단 계획고를 잡아당겨 제어가 어렵다는 지적에 따라, 최소고 적용을
|
||||
`enforce_pipe_clearance` 하나로 여닫게 했다. **기본은 해제**다. 여기서는
|
||||
① 기본이 해제인지
|
||||
② 요청·저장값이 스위치를 켜는지
|
||||
③ 켜고 끔에 따라 계획선 표고가 실제로 달라지는지 (산식은 그대로)
|
||||
④ 화면(TS) 편집 가드도 같은 스위치를 보는지
|
||||
를 확인한다. 산식 자체(관경+토피 등)는 `test_b05_min_cover_rules.py`가 잠근다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_pipe_anchored_profile,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteSolveRequest # noqa: E402
|
||||
|
||||
|
||||
def _options(**kwargs):
|
||||
return resolve_grade_options("trunk", **kwargs)
|
||||
|
||||
|
||||
# ── ① 기본 해제 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_default_is_released():
|
||||
"""기본은 해제 — 자동 전처리가 배수 자리마다 계획고를 들어 올리지 않는다."""
|
||||
assert _options().enforce_pipe_clearance is False
|
||||
|
||||
|
||||
def test_as_dict_carries_the_switch():
|
||||
"""저장·복원 경로(criteria/grade_options)에 스위치가 실린다."""
|
||||
assert _options().as_dict()["enforce_pipe_clearance"] is False
|
||||
assert (
|
||||
_options(requested={"enforce_pipe_clearance": True}).as_dict()["enforce_pipe_clearance"]
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
# ── ② 요청 → 저장 → 기본 순서 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_requested_turns_it_on():
|
||||
assert _options(requested={"enforce_pipe_clearance": True}).enforce_pipe_clearance is True
|
||||
|
||||
|
||||
def test_stored_is_used_when_request_is_silent():
|
||||
"""재생성처럼 요청이 비어 오는 경로는 확정 당시 저장값을 따른다."""
|
||||
assert _options(stored={"enforce_pipe_clearance": True}).enforce_pipe_clearance is True
|
||||
|
||||
|
||||
def test_request_wins_over_stored():
|
||||
"""사용자가 방금 끈 것이 옛 저장값을 이긴다."""
|
||||
resolved = _options(
|
||||
requested={"enforce_pipe_clearance": False},
|
||||
stored={"enforce_pipe_clearance": True},
|
||||
)
|
||||
assert resolved.enforce_pipe_clearance is False
|
||||
|
||||
|
||||
def test_schema_defaults_to_none_and_ships_the_flag():
|
||||
"""스키마 기본은 None(미지정) — 저장값을 덮어쓰지 않는다."""
|
||||
request = RouteSolveRequest(
|
||||
filter_key="f",
|
||||
bp={"x": 0.0, "y": 0.0},
|
||||
ep={"x": 100.0, "y": 0.0},
|
||||
)
|
||||
assert request.enforce_pipe_clearance is None
|
||||
assert "enforce_pipe_clearance" in request.grade_options()
|
||||
|
||||
|
||||
# ── ③ 계획선 표고가 실제로 달라진다 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _flat_longitudinal(length_m: float = 400.0, step: float = 20.0) -> dict:
|
||||
"""지반고가 일정한 종단 — 최소고 적용 여부만 표고 차이로 드러난다."""
|
||||
chainages = np.arange(0.0, length_m + step, step)
|
||||
return {
|
||||
"length_m": float(length_m),
|
||||
"samples": [
|
||||
{"chainage_m": float(c), "elevation_m": 100.0, "valid": True} for c in chainages
|
||||
],
|
||||
"stations": [
|
||||
{"chainage_m": float(c), "kind": "regular", "elevation_m": 100.0} for c in chainages
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _elevation_at(profile: dict, chainage_m: float) -> float:
|
||||
samples = profile["samples"]
|
||||
xs = [float(s["chainage_m"]) for s in samples]
|
||||
ys = [float(s["elevation_m"]) for s in samples]
|
||||
return float(np.interp(chainage_m, xs, ys))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anchor_m", [200.0])
|
||||
def test_released_profile_stays_on_the_ground(anchor_m):
|
||||
"""해제(기본): 배관 자리 계획고가 지반고를 그대로 통과한다."""
|
||||
_, entry = design_pipe_anchored_profile(
|
||||
_flat_longitudinal(),
|
||||
_options(),
|
||||
[anchor_m],
|
||||
station_interval_m=20.0,
|
||||
pipe_clearances=None,
|
||||
)
|
||||
assert _elevation_at(entry, anchor_m) == pytest.approx(100.0, abs=0.05)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anchor_m,clearance_m", [(200.0, 1.5)])
|
||||
def test_enforced_profile_is_lifted_by_the_clearance(anchor_m, clearance_m):
|
||||
"""강제: 배관 자리 계획고가 시설 여유만큼 들린다(Ø1000 → +1.5m)."""
|
||||
_, entry = design_pipe_anchored_profile(
|
||||
_flat_longitudinal(),
|
||||
_options(requested={"enforce_pipe_clearance": True}),
|
||||
[anchor_m],
|
||||
station_interval_m=20.0,
|
||||
pipe_clearances={anchor_m: clearance_m},
|
||||
)
|
||||
assert _elevation_at(entry, anchor_m) == pytest.approx(100.0 + clearance_m, abs=0.05)
|
||||
|
||||
|
||||
def test_criteria_round_trip_keeps_the_switch():
|
||||
"""계획선에 기록된 criteria로 스위치가 되살아난다(재편집 경로)."""
|
||||
_, entry = design_pipe_anchored_profile(
|
||||
_flat_longitudinal(),
|
||||
_options(requested={"enforce_pipe_clearance": True}),
|
||||
[200.0],
|
||||
station_interval_m=20.0,
|
||||
pipe_clearances={200.0: 1.5},
|
||||
)
|
||||
assert entry["criteria"]["enforce_pipe_clearance"] is True
|
||||
|
||||
|
||||
# ── ④ 화면 가드도 같은 스위치를 본다 ─────────────────────────────────────────
|
||||
|
||||
RENDER_SOURCE = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Render.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
SECTIONS_SOURCE = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_Engine_Sections.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_edit_guard_is_conditional():
|
||||
"""편집 차단 가드가 스위치를 먼저 본다 — 해제면 막지 않는다.
|
||||
|
||||
가드는 2026-09-02 에 `_Profile_MinCover.blocksMinCover` 로 옮겼다(모든 편집이
|
||||
지나는 `applyEdits` 한 곳에서 부른다). 스위치는 그 인자 `enforced` 로 들어온다.
|
||||
"""
|
||||
guard = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "if (!base || !alignment || !enforced || !targets.length) return false;" in guard
|
||||
|
||||
|
||||
def test_section_generation_passes_clearances_only_when_enabled():
|
||||
"""자동설계는 켜졌을 때만 여유값을 넘긴다."""
|
||||
assert "grade_options.enforce_pipe_clearance" in SECTIONS_SOURCE
|
||||
@@ -0,0 +1,110 @@
|
||||
"""계획선 출처(`basis`) 구분과 B04 3D 화면맞춤 범위 (2026-09-02 결함 2건).
|
||||
|
||||
① 1차(배관 정착)와 2차(직선 분할)가 같은 `_profile_entry()` 를 쓰다 보니 저장본
|
||||
`basis` 가 둘 다 `station_alignment` 로 나갔다. 사고 조사 때 "폴백으로 떨어졌다" 를
|
||||
저장본에서 확인하지 못한 원인이다. 값을 갈랐는지 잠근다.
|
||||
② B04 지표면 3D 뷰어가 `referenceBounds`(지표면 범위)만으로 카메라 거리를 정해,
|
||||
라이다가 노선의 일부만 덮으면 나머지가 화면 밖으로 잘렸다(용화 실측 —
|
||||
노선 2,136m 중 라이다 1,400m). 노선까지 담는 대칭 확장이 들어갔는지 잠근다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
ALIGNMENT_BASIS,
|
||||
PIPE_ANCHORED_BASIS,
|
||||
design_alignment_profile,
|
||||
design_pipe_anchored_profile,
|
||||
rebuild_alignment_profile,
|
||||
)
|
||||
|
||||
|
||||
def _longitudinal(length_m: float = 400.0, step: float = 20.0) -> dict:
|
||||
"""지반고가 완만하게 내려가는 종단 — 두 진입점 모두 계획선을 만들 수 있다."""
|
||||
chainages = np.arange(0.0, length_m + step, step)
|
||||
return {
|
||||
"length_m": float(length_m),
|
||||
"samples": [
|
||||
{"chainage_m": float(c), "elevation_m": 100.0 - 0.02 * float(c), "valid": True}
|
||||
for c in chainages
|
||||
],
|
||||
"stations": [
|
||||
{"chainage_m": float(c), "kind": "regular", "elevation_m": 100.0 - 0.02 * float(c)}
|
||||
for c in chainages
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── ① 계획선 출처가 저장본에서 갈린다 ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_two_bases_are_distinct_values():
|
||||
"""두 값이 같으면 저장본으로 진입점을 못 가른다."""
|
||||
assert PIPE_ANCHORED_BASIS != ALIGNMENT_BASIS
|
||||
|
||||
|
||||
def test_pipe_anchored_profile_reports_its_own_basis():
|
||||
"""1차(배관 정착)는 `pipe_anchored` 로 남는다."""
|
||||
_, entry = design_pipe_anchored_profile(
|
||||
_longitudinal(),
|
||||
resolve_grade_options("trunk"),
|
||||
[200.0],
|
||||
station_interval_m=20.0,
|
||||
)
|
||||
assert entry["basis"] == PIPE_ANCHORED_BASIS
|
||||
|
||||
|
||||
def test_fallback_profile_keeps_the_station_basis():
|
||||
"""2차(직선 분할 폴백)는 종전 값 그대로 — 옛 저장본과 뜻이 어긋나지 않는다."""
|
||||
_, entry = design_alignment_profile(
|
||||
_longitudinal(),
|
||||
resolve_grade_options("trunk"),
|
||||
station_interval_m=20.0,
|
||||
)
|
||||
assert entry["basis"] == ALIGNMENT_BASIS
|
||||
|
||||
|
||||
def test_rebuild_inherits_the_stored_basis():
|
||||
"""사용자 편집 재구성은 저장된 자동 선형을 그대로 쓰므로 출처도 물려받는다."""
|
||||
longitudinal = _longitudinal()
|
||||
alignment, entry = design_pipe_anchored_profile(
|
||||
longitudinal,
|
||||
resolve_grade_options("trunk"),
|
||||
[200.0],
|
||||
station_interval_m=20.0,
|
||||
)
|
||||
# 저장 경로(`B05_Profile_Engine_Sections.py:252`)와 같은 자리에 선형을 얹는다.
|
||||
longitudinal["design_profiles"] = [entry]
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
_, rebuilt = rebuild_alignment_profile(longitudinal, None)
|
||||
assert rebuilt["basis"] == PIPE_ANCHORED_BASIS
|
||||
|
||||
|
||||
# ── ② B04 3D 화면맞춤이 노선까지 담는다 ──────────────────────────────────────
|
||||
|
||||
VIEWER_SOURCE = (PROJECT_ROOT / "B04_PreProcess" / "B04_PreProcess_UI_TerrainViewer.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_fit_camera_uses_route_expanded_bounds():
|
||||
"""`fitCamera` 가 지표면 범위 대신 노선을 담은 범위를 쓴다."""
|
||||
assert "const bounds = fitBounds();" in VIEWER_SOURCE
|
||||
assert "getTopFitDistance(bounds, aspect)" in VIEWER_SOURCE
|
||||
assert "getTopFitDistance(referenceBounds, aspect)" not in VIEWER_SOURCE
|
||||
|
||||
|
||||
def test_fit_bounds_expands_symmetrically_around_the_reference_center():
|
||||
"""카메라 타깃이 지표면 중심이라 한쪽만 넓히면 소용없다 — 반폭을 대칭으로 잡는다."""
|
||||
assert "halfX = Math.max(halfX, Math.abs(point.x - cx))" in VIEWER_SOURCE
|
||||
assert "halfY = Math.max(halfY, Math.abs(point.y - cy))" in VIEWER_SOURCE
|
||||
assert "x_min: cx - halfX" in VIEWER_SOURCE
|
||||
assert "x_max: cx + halfX" in VIEWER_SOURCE
|
||||
@@ -0,0 +1,78 @@
|
||||
"""보호공 개편(2026-08-17) 구 저장분 마이그레이션.
|
||||
|
||||
개편 전 유입·유출 바닥 보호는 `*_pitching`(있음/없음)과 `*_pitching_finish`(찰/메)
|
||||
두 축이었다. 도수로·산비탈수로(B4)가 유출부에서 돌붙임 대신 쓰는 공종이라 세 값을
|
||||
`*_protection` 한 축(돌붙임(찰)/돌붙임(메)/도수로)으로 합쳤고, "없음"은 삭제했다
|
||||
(물이 흐르는 자리라 보호공은 필수 — 사용자 확정).
|
||||
|
||||
`parse_pipe_points`가 읽는 순간 새 키로 옮기므로 화면·수량 어느 쪽도 옛 키를 모른다.
|
||||
"""
|
||||
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
|
||||
|
||||
def _options(payload: dict) -> dict:
|
||||
points = parse_pipe_points([{"chainage_m": 100.0, "options": payload}])
|
||||
assert len(points) == 1
|
||||
return points[0].options or {}
|
||||
|
||||
|
||||
def test_legacy_pitching_with_finish_becomes_protection():
|
||||
"""있음 + 찰/메 → 돌붙임(찰)/돌붙임(메). 면적은 새 키로 옮겨 탄다."""
|
||||
options = _options(
|
||||
{
|
||||
"inlet_pitching": "있음",
|
||||
"inlet_pitching_finish": "찰",
|
||||
"inlet_pitching_area_m2": 9.0,
|
||||
"outlet_pitching": "있음",
|
||||
"outlet_pitching_finish": "메",
|
||||
"outlet_pitching_area_m2": 25.0,
|
||||
}
|
||||
)
|
||||
assert options["inlet_protection"] == "돌붙임(찰)"
|
||||
assert options["outlet_protection"] == "돌붙임(메)"
|
||||
assert options["inlet_protection_area_m2"] == 9.0
|
||||
assert options["outlet_protection_area_m2"] == 25.0
|
||||
|
||||
|
||||
def test_legacy_keys_are_removed():
|
||||
"""옛 키가 남으면 화면·수량이 두 벌을 보게 된다."""
|
||||
options = _options(
|
||||
{"inlet_pitching": "있음", "inlet_pitching_finish": "찰", "inlet_pitching_area_m2": 9.0}
|
||||
)
|
||||
for gone in ("inlet_pitching", "inlet_pitching_finish", "inlet_pitching_area_m2"):
|
||||
assert gone not in options, gone
|
||||
|
||||
|
||||
def test_legacy_none_is_promoted_to_part_default():
|
||||
"""구 "없음"은 선택지가 사라졌으므로 부위 기본값(유입 찰·유출 메)으로 올린다."""
|
||||
options = _options({"inlet_pitching": "없음", "outlet_pitching": "없음"})
|
||||
assert options["inlet_protection"] == "돌붙임(찰)"
|
||||
assert options["outlet_protection"] == "돌붙임(메)"
|
||||
|
||||
|
||||
def test_legacy_without_finish_falls_back_to_part_default():
|
||||
"""표면처리가 빠진 구 저장분도 부위 기본값으로 채운다."""
|
||||
options = _options({"outlet_pitching": "있음"})
|
||||
assert options["outlet_protection"] == "돌붙임(메)"
|
||||
|
||||
|
||||
def test_new_keys_win_over_legacy():
|
||||
"""이미 새 키가 있으면 구 키가 덮어쓰지 않는다(재저장분 보호)."""
|
||||
options = _options(
|
||||
{
|
||||
"inlet_protection": "도수로",
|
||||
"inlet_protection_width_m": 1.0,
|
||||
"inlet_pitching": "있음",
|
||||
"inlet_pitching_finish": "찰",
|
||||
}
|
||||
)
|
||||
assert options["inlet_protection"] == "도수로"
|
||||
assert options["inlet_protection_width_m"] == 1.0
|
||||
assert "inlet_pitching" not in options
|
||||
|
||||
|
||||
def test_untouched_options_pass_through():
|
||||
"""보호공과 무관한 옵션은 그대로 둔다."""
|
||||
options = _options({"pipe_kind": "파형강관", "pipe_diameter_mm": 1000})
|
||||
assert options == {"pipe_kind": "파형강관", "pipe_diameter_mm": 1000}
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""종단 계획고 변경이 횡단 설계에 반영되는지(2026-08-23 사용자 지시).
|
||||
|
||||
횡단 설계는 계산 당시 계획고(design.design_elevation_m)를 기준으로 설계선 좌표를
|
||||
굳혀 둔다. 계획선이 뒤에 바뀌면 설계선은 옛 자리에 남는데 화면의 계획고 십자선·
|
||||
3D 예상형상은 최신 계획선을 쓴다 — 두 기준이 어긋난다(실측: 한 노선 최대 2.21m).
|
||||
어긋남 판정을 B05·B06 공용 규칙(hasStaleDesigns)으로 두고, B05 진입 때도
|
||||
재계산이 돌게 한 것을 소스로 잠근다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
COMMON = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
B05_PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
B06_PAGE = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _stale(profile_samples, sections, tol=1e-3):
|
||||
"""TS hasStaleDesigns와 같은 규칙 — 계획선 보간값과 계산 기준의 차이."""
|
||||
|
||||
def plan_at(ch):
|
||||
if not profile_samples:
|
||||
return None
|
||||
if ch <= profile_samples[0][0]:
|
||||
return profile_samples[0][1]
|
||||
if ch >= profile_samples[-1][0]:
|
||||
return profile_samples[-1][1]
|
||||
for i in range(1, len(profile_samples)):
|
||||
c0, z0 = profile_samples[i - 1]
|
||||
c1, z1 = profile_samples[i]
|
||||
if ch <= c1:
|
||||
span = c1 - c0
|
||||
t = (ch - c0) / span if span > 0 else 0
|
||||
return z0 + (z1 - z0) * t
|
||||
return profile_samples[-1][1]
|
||||
|
||||
out = []
|
||||
for ch, used in sections:
|
||||
if used is None:
|
||||
continue
|
||||
planned = plan_at(ch)
|
||||
if planned is not None and abs(planned - used) > tol:
|
||||
out.append(ch)
|
||||
return out
|
||||
|
||||
|
||||
PROFILE = [(0.0, 100.0), (20.0, 102.0), (40.0, 104.0)]
|
||||
|
||||
|
||||
def test_detects_design_computed_from_old_plan():
|
||||
"""계획선이 바뀌어 계산 기준과 어긋난 측점을 잡는다."""
|
||||
assert _stale(PROFILE, [(20.0, 101.0)]) == [20.0]
|
||||
|
||||
|
||||
def test_clean_when_design_matches_plan():
|
||||
"""계획선과 계산 기준이 같으면 재계산 대상이 아니다."""
|
||||
assert _stale(PROFILE, [(0.0, 100.0), (20.0, 102.0), (40.0, 104.0)]) == []
|
||||
|
||||
|
||||
def test_interpolates_between_profile_samples():
|
||||
"""측점이 계획선 샘플 사이에 있으면 보간값과 비교한다(비정규 측점·배수관 자리)."""
|
||||
assert _stale(PROFILE, [(10.0, 101.0)]) == []
|
||||
assert _stale(PROFILE, [(10.0, 100.5)]) == [10.0]
|
||||
|
||||
|
||||
def test_sections_without_design_are_skipped():
|
||||
"""설계가 아직 없는 측점은 판정 대상이 아니다(최초 생성 전)."""
|
||||
assert _stale(PROFILE, [(20.0, None)]) == []
|
||||
|
||||
|
||||
def test_tolerance_ignores_rounding_noise():
|
||||
"""저장 반올림 수준(1e-3 이하)은 어긋남으로 보지 않는다 — 무한 재계산 방지."""
|
||||
assert _stale(PROFILE, [(20.0, 102.0005)]) == []
|
||||
|
||||
|
||||
def test_source_shared_rule_and_b05_entry_recompute():
|
||||
"""공용 판정 유틸을 B05 진입·B06 재계산이 함께 쓰는지 소스 검사."""
|
||||
assert "export function hasStaleDesigns" in COMMON
|
||||
# B05: 진입 렌더에서 어긋남이 있으면 프리뷰 1회로 맞춘다.
|
||||
assert "hasStaleDesigns" in B05_PANEL
|
||||
# 진입 즉시 한 번 맞춘다 — 편집 반영 계획선을 넣어 부르고, 어긋나면 예약한다.
|
||||
assert "hasStaleDesigns({" in B05_PANEL
|
||||
assert "scheduleCrossPreview();" in B05_PANEL
|
||||
# B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다
|
||||
# (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다).
|
||||
assert "hasStaleDesigns(sectionDetail)" in B06_PAGE
|
||||
assert "two_stage_slope === undefined" in COMMON
|
||||
assert "two_stage_slope === undefined" not in B06_PAGE
|
||||
@@ -0,0 +1,37 @@
|
||||
/* 구조물 측점과 겹치는 규칙 측점 제거 — 2026-09-06.
|
||||
TS 를 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다. */
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const out = mkdtempSync(join(tmpdir(), "aislo-station-"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"./config/node_modules/typescript/bin/tsc",
|
||||
"B05_Profile/B05_Profile_Util_Station.ts",
|
||||
"--outDir", out,
|
||||
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
const { dropStationsNear, STATION_MERGE_TOLERANCE_M } = await import(
|
||||
pathToFileURL(join(out, "B05_Profile_Util_Station.js")).href
|
||||
);
|
||||
|
||||
const regular = [0, 20, 40, 60, 80].map((chainage_m) => ({ chainage_m, kind: "regular" }));
|
||||
const structures = [{ chainage_m: 40.05 }, { chainage_m: 63.0 }];
|
||||
const kept = dropStationsNear(regular, structures);
|
||||
|
||||
assert.deepEqual(
|
||||
kept.map((s) => s.chainage_m),
|
||||
[0, 20, 60, 80],
|
||||
"0.1m 안에서 겹친 40m 규칙 측점만 빠져야 한다",
|
||||
);
|
||||
assert.equal(STATION_MERGE_TOLERANCE_M, 0.1);
|
||||
// 구조물이 없으면 아무것도 지우지 않는다.
|
||||
assert.equal(dropStationsNear(regular, []).length, regular.length);
|
||||
console.log("OK — 구조물 측점과 겹친 규칙 측점만 제거");
|
||||
@@ -0,0 +1,175 @@
|
||||
"""마이그레이션 테스트 — 서버 저장 형식(구조물 문자열만)과 클라이언트 형식 모두 커버.
|
||||
|
||||
크로스체크 지적 1(2026-08-16): 확정 저장분은 `{chainage_m, structure}` 문자열뿐이다
|
||||
(`B05_Profile_UI_Page.ts` confirmRoute 전송부). structureType이 없어도 문자열에서
|
||||
종류를 알아내야 한다.
|
||||
|
||||
2026-09-01 사용자 확정: 정본이 갈린 타입(기슭막이 = `managed_by: pipe_points`)은
|
||||
구조물 목록이 아니라 관 지점 정본으로 간다. 결과도 두 갈래(`MigrationPlan`)다.
|
||||
"""
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations
|
||||
|
||||
# ── 서버 저장 형식 (structure 문자열만) ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_server_format_gisungmagi_maps_to_revetment():
|
||||
(item,) = migrate_irregular_stations(
|
||||
[{"chainage_m": 120.0, "structure": "기성막이"}]
|
||||
).pipe_facilities
|
||||
assert item.facility == "revetment"
|
||||
|
||||
|
||||
def test_server_format_escape_route_maps_to_refuge_with_width():
|
||||
(item,) = migrate_irregular_stations(
|
||||
[{"chainage_m": 300.0, "structure": "대피로 2.5m"}]
|
||||
).structures
|
||||
assert item.type_id == "refuge"
|
||||
assert item.options["width_m"] == 2.5
|
||||
|
||||
|
||||
def test_server_format_pipe_label_is_skipped():
|
||||
"""배관 라벨(관종 D직경)은 관 정본 소관 — 옮기지 않는다."""
|
||||
for label in ("파형강관 D800", "흄관 D1000", "배관"):
|
||||
plan = migrate_irregular_stations([{"chainage_m": 50.0, "structure": label}])
|
||||
assert plan.structures == [] and plan.pipe_facilities == []
|
||||
|
||||
|
||||
def test_server_format_unknown_text_becomes_etc():
|
||||
(item,) = migrate_irregular_stations(
|
||||
[{"chainage_m": 15.0, "structure": "돌망태 보강"}]
|
||||
).structures
|
||||
assert item.type_id == "etc"
|
||||
assert item.options["name"] == "돌망태 보강"
|
||||
|
||||
|
||||
# ── 클라이언트 형식 (structureType 포함) ────────────────────────────────────
|
||||
|
||||
|
||||
def test_client_format_pipe_entries_are_skipped():
|
||||
plan = migrate_irregular_stations(
|
||||
[
|
||||
{"chainage_m": 50.0, "structure": "파형강관 D800", "structureType": "배관"},
|
||||
{"chainage_m": 60.0, "structure": "배관", "origin": "pipe"},
|
||||
]
|
||||
)
|
||||
assert plan.structures == [] and plan.pipe_facilities == []
|
||||
|
||||
|
||||
def test_client_format_gisungmagi_maps_to_revetment():
|
||||
"""기슭막이는 관 시설 — 구간·제원은 레지스트리 기본값을 승계한다(2026-09-01)."""
|
||||
plan = migrate_irregular_stations(
|
||||
[{"chainage_m": 120.0, "structure": "기성막이", "structureType": "기성막이"}]
|
||||
)
|
||||
assert plan.structures == []
|
||||
(item,) = plan.pipe_facilities
|
||||
assert item.facility == "revetment"
|
||||
assert item.chainage_m == 120.0
|
||||
# 기준측점 전/후 5m — 레지스트리 기본값.
|
||||
assert (item.start_m, item.end_m) == (115.0, 125.0)
|
||||
assert item.options["form"] == "돌쌓기(메)"
|
||||
assert item.options["height_m"] == 2.5 and item.options["length_m"] == 10
|
||||
|
||||
|
||||
def test_client_format_escape_route_width_field_wins():
|
||||
(item,) = migrate_irregular_stations(
|
||||
[
|
||||
{
|
||||
"chainage_m": 300.0,
|
||||
"structure": "대피로 2.5m",
|
||||
"structureType": "대피로",
|
||||
"escapeWidthM": 3.0,
|
||||
}
|
||||
]
|
||||
).structures
|
||||
assert item.type_id == "refuge"
|
||||
assert item.options["width_m"] == 3.0 # 명시 필드가 라벨 파싱보다 우선
|
||||
|
||||
|
||||
def test_etc_keeps_custom_name():
|
||||
(item,) = migrate_irregular_stations(
|
||||
[
|
||||
{
|
||||
"chainage_m": 80.0,
|
||||
"structure": "임시 표지",
|
||||
"structureType": "기타",
|
||||
"customName": "임시 표지",
|
||||
}
|
||||
]
|
||||
).structures
|
||||
assert item.type_id == "etc"
|
||||
assert item.options["name"] == "임시 표지"
|
||||
|
||||
|
||||
def test_duplicate_positions_are_deduplicated():
|
||||
entries = [
|
||||
{"chainage_m": 100.0, "structure": "기성막이"},
|
||||
{"chainage_m": 100.0, "structure": "기성막이"},
|
||||
]
|
||||
assert len(migrate_irregular_stations(entries).pipe_facilities) == 1
|
||||
|
||||
|
||||
def test_migration_is_idempotent():
|
||||
entries = [
|
||||
{"chainage_m": 10.0, "structure": "기성막이"},
|
||||
{"chainage_m": 20.0, "structure": "대피로 2.0m"},
|
||||
]
|
||||
first = migrate_irregular_stations(entries)
|
||||
second = migrate_irregular_stations(entries)
|
||||
assert [(i.type_id, i.start_m) for i in first.structures] == [
|
||||
(i.type_id, i.start_m) for i in second.structures
|
||||
]
|
||||
assert [(p.facility, p.start_m) for p in first.pipe_facilities] == [
|
||||
(p.facility, p.start_m) for p in second.pipe_facilities
|
||||
]
|
||||
|
||||
|
||||
def test_empty_input_returns_empty():
|
||||
plan = migrate_irregular_stations([])
|
||||
assert plan.structures == [] and plan.pipe_facilities == []
|
||||
|
||||
|
||||
# ── 다른 정본이 관리하는 A군 라벨 (2026-08-28) ──────────────────────────────
|
||||
|
||||
|
||||
def test_group_a_labels_from_server_are_not_re_migrated():
|
||||
"""서버가 종단 정본에 심는 A군 라벨은 구조물로 옮기지 않는다.
|
||||
|
||||
옮기면 정본이 이중화되어 "기타" 고스트가 쌓인다 — 실증: 초기화 전 프로젝트의
|
||||
structures.json에 `etc@149.73`(세월교)·`etc@200.92`(BOX암거)가 남아 있었다.
|
||||
"""
|
||||
entries = [
|
||||
{"chainage_m": 149.73, "structure": "세월교"},
|
||||
{"chainage_m": 200.92, "structure": "BOX암거"},
|
||||
{"chainage_m": 210.0, "structure": "물넘이포장"},
|
||||
{"chainage_m": 30.0, "structure": "배수관"},
|
||||
{"chainage_m": 60.0, "structure": "노출형 횡단수로"},
|
||||
{"chainage_m": 70.0, "structure": "개거(겉도랑)"},
|
||||
]
|
||||
plan = migrate_irregular_stations(entries)
|
||||
assert plan.structures == [] and plan.pipe_facilities == []
|
||||
|
||||
|
||||
def test_pipe_owned_label_is_not_re_migrated():
|
||||
"""관 시설에서 투영된 "기슭막이" 라벨은 되옮기지 않는다 — 되옮기면 "기타"로 굳는다.
|
||||
|
||||
구 이름("기성막이")만 이관 대상이다(2026-08-28 이관 뒤 라벨이 갈렸다).
|
||||
"""
|
||||
plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": "기슭막이"}])
|
||||
assert plan.structures == [] and plan.pipe_facilities == []
|
||||
|
||||
|
||||
def test_non_group_a_labels_still_migrate():
|
||||
"""대조군 — 필터가 과하게 넓어지면 이 세 건이 함께 사라진다."""
|
||||
(revetment,) = migrate_irregular_stations(
|
||||
[{"chainage_m": 120.0, "structure": "기성막이"}]
|
||||
).pipe_facilities
|
||||
assert revetment.facility == "revetment"
|
||||
(refuge,) = migrate_irregular_stations(
|
||||
[{"chainage_m": 300.0, "structure": "대피로 2.0m"}]
|
||||
).structures
|
||||
assert refuge.type_id == "refuge"
|
||||
(etc,) = migrate_irregular_stations(
|
||||
[{"chainage_m": 15.0, "structure": "돌망태 보강"}]
|
||||
).structures
|
||||
assert etc.type_id == "etc"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""구 비정규 측점 이관은 **한 번만** — 2026-08-24 사용자 확정.
|
||||
|
||||
원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, 옮긴 이력만 구조물 정본에
|
||||
남긴다. 그래야 사용자가 그 구조물을 지운 뒤 B05에 다시 들어와도 되살아나지 않는다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import ( # noqa: E402
|
||||
load_migrated_legacy,
|
||||
load_structures,
|
||||
save_structures,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations # noqa: E402
|
||||
|
||||
|
||||
def _key(item) -> str:
|
||||
return f"{item.type_id}@{round(item.anchor_m(), 3)}"
|
||||
|
||||
|
||||
def test_history_survives_plain_save(tmp_path):
|
||||
"""일반 저장(구조물 편집)이 이력을 지우면 안 된다 — 지우면 삭제분이 되살아난다."""
|
||||
root = str(tmp_path)
|
||||
revision = save_structures(root, [], base_revision=0, migrated_legacy={"erosion_check@10.0"})
|
||||
assert load_migrated_legacy(root) == {"erosion_check@10.0"}
|
||||
# 이력 인자 없이 다시 저장해도 남아 있어야 한다.
|
||||
save_structures(root, [], base_revision=revision)
|
||||
assert load_migrated_legacy(root) == {"erosion_check@10.0"}
|
||||
|
||||
|
||||
def test_history_accumulates(tmp_path):
|
||||
"""여러 번 이관해도 이력은 합쳐진다."""
|
||||
root = str(tmp_path)
|
||||
revision = save_structures(root, [], base_revision=0, migrated_legacy={"a@1.0"})
|
||||
save_structures(root, [], base_revision=revision, migrated_legacy={"b@2.0"})
|
||||
assert load_migrated_legacy(root) == {"a@1.0", "b@2.0"}
|
||||
|
||||
|
||||
def test_migration_candidate_keys_are_stable(tmp_path):
|
||||
"""이관 후보의 표식이 저장 왕복에도 같아야 한다 — 달라지면 매번 다시 옮긴다."""
|
||||
stations = [{"chainage_m": 10.0, "structure": "돌쌓기 기슭막이"}]
|
||||
candidates = migrate_irregular_stations(stations).structures
|
||||
if not candidates: # 매핑이 없는 문구면 이 검증은 대상 아님
|
||||
return
|
||||
root = str(tmp_path)
|
||||
save_structures(root, candidates, base_revision=0)
|
||||
_revision, stored = load_structures(root)
|
||||
assert {_key(item) for item in stored} == {_key(item) for item in candidates}
|
||||
|
||||
|
||||
def test_deleted_structure_is_not_recreated(tmp_path):
|
||||
"""이관 → 삭제 → 재이관 시도: 이력에 있으므로 다시 만들지 않는다."""
|
||||
stations = [{"chainage_m": 10.0, "structure": "돌쌓기 기슭막이"}]
|
||||
candidates = migrate_irregular_stations(stations).structures
|
||||
if not candidates:
|
||||
return
|
||||
root = str(tmp_path)
|
||||
history = {_key(item) for item in candidates}
|
||||
revision = save_structures(root, candidates, base_revision=0, migrated_legacy=history)
|
||||
|
||||
# 사용자가 전부 지운다(일반 저장 경로).
|
||||
revision = save_structures(root, [], base_revision=revision)
|
||||
assert load_structures(root)[1] == []
|
||||
|
||||
# 재진입 — 라우터와 같은 판정: 자리도 비었지만 이력에 있으니 후보에서 빠진다.
|
||||
migrated_before = load_migrated_legacy(root)
|
||||
fresh = [item for item in candidates if _key(item) not in migrated_before]
|
||||
assert fresh == []
|
||||
@@ -0,0 +1,563 @@
|
||||
"""레지스트리 정책 검증 — phase 단계 분리·선택지 재편·기본값 원칙.
|
||||
|
||||
2026-08-17 선택지 재편: B05는 유무·종류·위치만 고르고 상세 치수는 B06/B07에서
|
||||
받는다. 종단배수(B군)·생태/녹화(F군)·노면공(G군)은 B05 선택지에서 빠지고
|
||||
`enabled: false`로 보존된다(B06 횡단도 옵션 재사용). 바닥막이·생태연못은 임도
|
||||
미사용으로 완전 삭제. 배관·BOX암거는 부속 옵션 정의를 갖는다.
|
||||
"""
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
|
||||
# 기본값을 남겨 둘 수 있는 항목 — 법정 단일값·사용자 확정값·표시용 문자열뿐이다.
|
||||
ALLOWED_DEFAULT_KEYS = {
|
||||
# 돌 조달 「채집」 기본 — **사용자 확정 ②(2026-09-09) 「기본은 캔다, 구조물마다 바꿀 수
|
||||
# 있게」**. 법도 그쪽을 권한다: 별표2 「석축 등에 필요한 야면석 등은 가급적 현장에서
|
||||
# 채취·사용하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 구입 단가가 0건.
|
||||
("masonry_wet", "stone_supply"),
|
||||
("masonry_dry", "stone_supply"),
|
||||
("boulder_masonry", "stone_supply"),
|
||||
("erosion_check", "stone_supply"),
|
||||
("bed_sill", "stone_supply"),
|
||||
("revetment", "stone_supply"),
|
||||
# 독립 기슭막이 설치 측(2026-08-28 사용자 확정: 자동 판정 없이 수동으로 받는다).
|
||||
# "좌"는 셀렉트 첫 항목일 뿐 도메인 확정값이 아니다 — 측점마다 사용자가 고른다.
|
||||
("revetment", "side"),
|
||||
# C군 다섯(옹벽·돌쌓기 찰/메·흙막이·큰돌쌓기)의 설치 측 기본 "자동(성토 쪽)"
|
||||
# (2026-09-07 사용자 지시로 폼 칸 신설). **도메인 확정값이 아니라 「지금까지의 동작」**
|
||||
# 이다 — 칸이 생기기 전에도 기하는 성토 쪽에 세우고 있었고("좌"/"우" 를 고르면 그쪽),
|
||||
# 그 행동을 그대로 이어받는 표식이다. 사용자가 측점마다 좌·우로 바꾼다.
|
||||
("retaining_wall", "side"),
|
||||
("masonry_wet", "side"),
|
||||
("masonry_dry", "side"),
|
||||
("soil_guard", "side"),
|
||||
("boulder_masonry", "side"),
|
||||
# 흙막이 — **기슭막이 칸을 한 벌로 옮긴 것**(2026-09-09 사용자 확정 4차 원문:
|
||||
# 「흙막이는 횡단도에서 표현방식들과 옵션들은 동일하게 반영 · 형상은 동일 ·
|
||||
# 데이터는 분리하여 계산」). 기본값의 까닭도 기슭막이 줄과 **같다** —
|
||||
# 단 수 1 = 다단 없음 · 올림/이동 0 = 자동 자리 그대로 · 조달 채집 = 별표2 권고.
|
||||
("soil_guard", "tiers"),
|
||||
("soil_guard", "lift_m"),
|
||||
("soil_guard", "shift_m"),
|
||||
("soil_guard", "stone_supply"),
|
||||
# 사토장 쌓는 쪽 기본 "자동(성토 쪽)" — C군 side 와 같은 뜻이다. 도메인 확정값이 아니라
|
||||
# 「흙은 성토 쪽에 쌓는다」는 지금까지의 동작을 이어받는 표식이고, 측점마다 좌·우로 바꾼다.
|
||||
("spoil_bank", "side"),
|
||||
# 추가 운반거리 기본 0 = 「추가 없음」. 노선 안 사토장은 측점 사이 누가거리로 거리가
|
||||
# 그대로 나오므로(2026-09-09 사용자 확정 ③) 이 칸은 **외부 사토장 대비 여벌**이다.
|
||||
# 0 은 도메인 수치가 아니라 lift_m·shift_m 과 같은 「안 더함」 표식이다.
|
||||
("spoil_bank", "extra_distance_m"),
|
||||
# 단 수 기본 1 = 단일 벽(다단 아님). 도메인 수치가 아니라 "다단 없음" 표식이다.
|
||||
("revetment", "tiers"),
|
||||
# 자리 이동 기본 0 = 자동 자리(성토면 끝) 그대로. 이동량은 사용자 조작값이다.
|
||||
("revetment", "lift_m"),
|
||||
("revetment", "shift_m"),
|
||||
# 포장 구간 길이 10m / 전·후 5m — 기슭막이와 같은 출발값(2026-08-28 사용자 확정).
|
||||
# 지식DB에 포장 구간 길이 기준은 없다. 화면 출발값일 뿐 도메인 확정값이 아니다.
|
||||
("pavement_concrete", "length_m"),
|
||||
("pavement_concrete", "before_m"),
|
||||
("pavement_concrete", "after_m"),
|
||||
# ── 소단(berm·ditch_berm) 기본값 — 근거 확인 끝(2026-09-07, 보조 창 구현 + 원문 대조) ──
|
||||
# 폭 0.5m · 간격(사면길이) 3m = **별표2 범위 안에서 가장 적게 파는 조합**이다.
|
||||
# 지식DB `01_임도/02_상세설계/절토_비탈면.md` §2: 「사면길이 **2~3m마다** 폭 **50~100㎝**」.
|
||||
# 범위의 양 끝(폭 최소·간격 최대)을 골랐다 — 기본값은 되돌리기 쉬운 쪽이어야 하기 때문이다
|
||||
# (더 넣는 것은 폼에서 한 번이지만, 이미 판 것을 되돌리면 전 측점 재계산이다).
|
||||
# 실효 경사 차이도 크다(설계 1:1): 0.5·3 → 1:1.24 / 1.0·3 → 1:1.47 / 1.0·2 → 1:1.71.
|
||||
("berm", "width_m"),
|
||||
("berm", "interval_m"),
|
||||
("ditch_berm", "width_m"),
|
||||
("ditch_berm", "interval_m"),
|
||||
# 안쪽 기울기 기본 **0°** = 「기본으로 아무것도 안 넣음」. 기울이는 것은 실무이나
|
||||
# **법령·교본 근거가 없어** 박지 않고 폼에서 받는다(2026-09-07 사용자 재확정 — 처음 2°
|
||||
# 로 잡았다가 내렸다). 근거 없는 값을 안 넣으려는 값이라 이 원칙의 반대편이 아니다.
|
||||
("berm", "slope_deg"),
|
||||
("ditch_berm", "slope_deg"),
|
||||
# 구간 길이 10m·전·후 5m — 소단만의 값이 아니라 **C군 구간형 폼 공통 출발값**이다
|
||||
# (포장 구간·기슭막이와 같은 값). 도메인 확정값이 아니다.
|
||||
("berm", "length_m"),
|
||||
("berm", "before_m"),
|
||||
("berm", "after_m"),
|
||||
("ditch_berm", "length_m"),
|
||||
("ditch_berm", "before_m"),
|
||||
("ditch_berm", "after_m"),
|
||||
("ditch_side", "depth_cm"),
|
||||
("erosion_check", "length_m"),
|
||||
("erosion_check", "height_m"),
|
||||
("refuge", "length_m"),
|
||||
("work_yard", "width_m"),
|
||||
("work_yard", "length_m"),
|
||||
("turnaround", "width_m"),
|
||||
("etc", "name"),
|
||||
# 별표2 관 지름 1,000㎜ 이상 원칙 + 2026-08-17 사용자 확정 기본값.
|
||||
("pipe", "pipe_diameter_mm"),
|
||||
# 보호공(돌붙임) 계열은 삭제 — 기슭막이는 사면 공정이라 바닥 돌붙임이 붙지
|
||||
# 않는다(2026-08-19 사용자 지시 1·3). 바닥 보호는 바닥막이(bed_sill) 구조물 몫.
|
||||
# 기슭막이 형태 기본 — 유입 돌쌓기(찰) / 유출 돌쌓기(메) (2026-08-17 사용자 확정).
|
||||
("pipe", "inlet_revet_form"),
|
||||
("pipe", "outlet_revet_form"),
|
||||
# 유출구 구조는 현재 기슭막이 하나뿐 — 양식 일원화용 단일 선택지.
|
||||
("pipe", "outlet_type"),
|
||||
# 관종 기본 = 파형강관, 유입구 구조 기본 = 기슭막이 (2026-08-17 사용자 확정).
|
||||
("pipe", "pipe_kind"),
|
||||
("pipe", "inlet_type"),
|
||||
# 집수정 종방향 기본 — 길이 2m·기준측점 전/후 각 1m (2026-08-24 사용자 확정).
|
||||
("pipe", "inlet_basin_before_m"),
|
||||
("pipe", "inlet_basin_after_m"),
|
||||
# 기슭막이 기본 치수 — 길이 10m·높이 2.5m·보호공 면적 10㎡ (2026-08-17 사용자 확정).
|
||||
("pipe", "inlet_revet_length_m"),
|
||||
("pipe", "inlet_revet_height_m"),
|
||||
("pipe", "outlet_revet_length_m"),
|
||||
("pipe", "outlet_revet_height_m"),
|
||||
# 독립 기슭막이(D4) — C군과 같은 범위 계산 방식, 기본 돌쌓기(메)·2.5·10·5·5
|
||||
# (2026-08-19 사용자 지시 3).
|
||||
("revetment", "form"),
|
||||
("revetment", "height_m"),
|
||||
("revetment", "length_m"),
|
||||
("revetment", "before_m"),
|
||||
("revetment", "after_m"),
|
||||
# 바닥막이 부활 — 돌붙임(메)·면적 10㎡·높이 0.3m (2026-08-19 사용자 지시 2·5).
|
||||
("bed_sill", "form"),
|
||||
("bed_sill", "area_m2"),
|
||||
("bed_sill", "height_m"),
|
||||
# 세월교 구체 내 배관도 배수관과 같은 관종·관경을 쓴다 (2026-08-17 사용자 확정).
|
||||
("ford_bridge", "pipe_kind"),
|
||||
("ford_bridge", "pipe_diameter_mm"),
|
||||
# 세월교 날개벽 — BOX암거와 같은 한 벌을 그대로 쓴다 (2026-08-25 사용자 확정).
|
||||
("ford_bridge", "wing_in"),
|
||||
("ford_bridge", "wing_in_height_m"),
|
||||
("ford_bridge", "wing_in_length_m"),
|
||||
("ford_bridge", "wing_in_angle_deg"),
|
||||
("ford_bridge", "wing_out"),
|
||||
("ford_bridge", "wing_out_height_m"),
|
||||
("ford_bridge", "wing_out_length_m"),
|
||||
("ford_bridge", "wing_out_angle_deg"),
|
||||
# 기슭막이 종방향 전/후 각 5m·집수정 길이 2m — 확정값인데 목록에 빠져 있었다
|
||||
# (2026-08-17·08-24 사용자 확정, 2026-08-25 보충).
|
||||
("pipe", "inlet_revet_before_m"),
|
||||
("pipe", "inlet_revet_after_m"),
|
||||
("pipe", "outlet_revet_before_m"),
|
||||
("pipe", "outlet_revet_after_m"),
|
||||
("pipe", "inlet_basin_length_m"),
|
||||
# C군 기본값 — 높이 2.5·길이 10·전길이 5 (2026-08-19 사용자 확정).
|
||||
*(
|
||||
(type_id, key)
|
||||
for type_id in (
|
||||
"retaining_wall",
|
||||
"masonry_wet",
|
||||
"masonry_dry",
|
||||
"soil_guard",
|
||||
"boulder_masonry",
|
||||
)
|
||||
for key in ("height_m", "length_m", "before_m", "after_m")
|
||||
),
|
||||
# BOX암거 본체 2.0×2.0(실무 관측 규격 프리셋의 첫 항목)과 날개벽 기본 제원
|
||||
# — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45° (2026-08-17 사용자 확정).
|
||||
("box_culvert", "body_width_m"),
|
||||
("box_culvert", "body_height_m"),
|
||||
("box_culvert", "wing_in"),
|
||||
("box_culvert", "wing_in_height_m"),
|
||||
("box_culvert", "wing_in_length_m"),
|
||||
("box_culvert", "wing_in_angle_deg"),
|
||||
("box_culvert", "wing_out"),
|
||||
("box_culvert", "wing_out_height_m"),
|
||||
("box_culvert", "wing_out_length_m"),
|
||||
("box_culvert", "wing_out_angle_deg"),
|
||||
}
|
||||
|
||||
# B06 이관 보류 — 정의 보존을 위해 삭제하지 않고 enabled=False로 숨긴다.
|
||||
DISABLED_TYPE_IDS = {
|
||||
"ditch_side",
|
||||
"ditch_ridge",
|
||||
"ditch_berm",
|
||||
"chute",
|
||||
"slope_drain",
|
||||
"underdrain",
|
||||
"wildlife_path",
|
||||
"revegetation",
|
||||
"gravel_surfacing",
|
||||
}
|
||||
|
||||
# 임도 미사용 확정 — 레지스트리에서 완전 삭제. 바닥막이(bed_sill)는 2026-08-19
|
||||
# 사용자 지시로 부활해 D군(계류 사방)에 되살렸다.
|
||||
REMOVED_TYPE_IDS = {"eco_pond"}
|
||||
|
||||
|
||||
def _quick_add_types():
|
||||
"""우클릭 한 번으로 넣을 수 있는 타입 — 정본 관리·비활성 타입만 제외한다."""
|
||||
return [item for item in load_structure_types() if not item.managed_by and item.enabled]
|
||||
|
||||
|
||||
def test_quick_add_types_exclude_pipe_family():
|
||||
"""계곡 통과 시설(배관·BOX암거·물넘이·세월교)은 관 지점 정본 소관."""
|
||||
quick_ids = {item.type_id for item in _quick_add_types()}
|
||||
for type_id in ("pipe", "box_culvert", "ford_pavement", "ford_bridge"):
|
||||
assert type_id not in quick_ids
|
||||
|
||||
|
||||
def test_all_enabled_non_managed_types_are_quick_addable():
|
||||
"""잔존 B05 선택지 = A군 2(노출형·개거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1.
|
||||
|
||||
콘크리트 포장은 2026-08-28 사용자 지시로 되살렸다 — 포장 구간을 기준측점+길이+전/후로
|
||||
받는 정본이 됐다(종단경사 자동 포장 적용을 대체).
|
||||
|
||||
D군이 4에서 3으로 줄었다 — 독립 기슭막이가 관 지점 시설로 옮겨가(2026-08-28,
|
||||
`managed_by: pipe_points`) 우클릭 추가 대상에서 빠졌다.
|
||||
|
||||
C군이 5에서 6으로 늘었다 — **소단(`berm`)** 이 들어왔다(2026-09-07, 계획서 3-9).
|
||||
소단 규격은 법령·교본마다 갈려 프로그램이 판정하지 않고 **사용자가 구간에 놓는다** —
|
||||
그래서 C군 구간형 시설과 같은 자리에 선다. (측구쪽 `ditch_berm` 은 우클릭 추가 대상이
|
||||
아니라 여기 수에 안 잡힌다.)
|
||||
"""
|
||||
assert len(_quick_add_types()) == 23
|
||||
|
||||
|
||||
def test_removed_types_are_absent():
|
||||
"""바닥막이·생태연못 — 임도 미사용 확정으로 레지스트리에서 삭제."""
|
||||
present = {item.type_id for item in load_structure_types()}
|
||||
leftovers = REMOVED_TYPE_IDS & present
|
||||
assert not leftovers, f"삭제 대상 잔존: {leftovers}"
|
||||
|
||||
|
||||
def test_b06_deferred_types_are_disabled_but_preserved():
|
||||
"""B·F·G군은 B05 선택지에서 빠지되 정의는 남는다(B06 횡단도 옵션 재사용)."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
for type_id in DISABLED_TYPE_IDS:
|
||||
assert type_id in by_id, f"{type_id} 정의가 삭제됨 — 보존 대상"
|
||||
assert by_id[type_id].enabled is False, f"{type_id} enabled=False여야 함"
|
||||
for type_id, item in by_id.items():
|
||||
if type_id not in DISABLED_TYPE_IDS:
|
||||
assert item.enabled is True, f"{type_id}는 활성이어야 함"
|
||||
|
||||
|
||||
def test_b05_phase_options_are_never_required():
|
||||
"""B05 배치 시 필수 입력이 남아 있으면 유무 단계가 깨진다 — 필수는 전부 detail.
|
||||
|
||||
C군 높이·길이는 한때 B05 필수였으나 기본값(2.5/10/5)이 사용자 확정되면서
|
||||
필수가 풀렸다(2026-08-19) — 원칙은 다시 예외 없이 성립한다."""
|
||||
offenders = [
|
||||
f"{item.type_id}.{option.key}"
|
||||
for item in load_structure_types()
|
||||
for option in item.options
|
||||
if option.required and option.phase != "detail"
|
||||
]
|
||||
assert offenders == [], f"B05 단계 필수 옵션: {offenders}"
|
||||
|
||||
|
||||
def test_slope_group_computes_range_from_length():
|
||||
"""C군 5종 — 전부 구간형이고 B05 길이·높이 옵션을 갖는다(2026-08-19 지시).
|
||||
|
||||
프론트(hasComputedRange)는 'B05 phase의 length_m 존재'로 범위 계산 UI를 켠다."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
for type_id in (
|
||||
"retaining_wall",
|
||||
"masonry_wet",
|
||||
"masonry_dry",
|
||||
"soil_guard",
|
||||
"boulder_masonry",
|
||||
):
|
||||
item = by_id[type_id]
|
||||
assert item.group == "C" and item.placement == "interval", type_id
|
||||
# 기본값 = 높이 2.5·길이 10·전길이 5 (2026-08-19 사용자 확정 — 기본값이
|
||||
# 생기며 required는 풀렸다). 후길이 = 길이 − 전길이는 화면 계산값이라 옵션이
|
||||
# 아니다. 순서는 높이 → 길이 → 전길이.
|
||||
options = {option.key: option for option in item.options}
|
||||
expected = {"height_m": 2.5, "length_m": 10, "before_m": 5}
|
||||
for key, default in expected.items():
|
||||
assert key in options, f"{type_id}.{key} 옵션 없음"
|
||||
assert options[key].phase == "b05", f"{type_id}.{key}는 b05 phase"
|
||||
assert not options[key].required, f"{type_id}.{key}는 기본값 확정으로 비필수"
|
||||
assert options[key].default == default, f"{type_id}.{key} 기본값"
|
||||
assert options[key].input == "number" and options[key].unit == "m", key
|
||||
keys = [option.key for option in item.options if option.phase == "b05"]
|
||||
assert keys[:3] == ["height_m", "length_m", "before_m"], f"{type_id} 옵션 순서"
|
||||
|
||||
|
||||
def test_retaining_wall_renamed_plain():
|
||||
"""옹벽(철근콘크리트) → 옹벽 — 전역 명칭 단순화(2026-08-19 사용자 지시)."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
assert by_id["retaining_wall"].name == "옹벽"
|
||||
|
||||
|
||||
def test_required_options_moved_to_detail_phase():
|
||||
"""크로스체크 2차의 필수 원칙이 detail phase로 유지되는지 확인."""
|
||||
detail_required = [
|
||||
(item.type_id, option.key)
|
||||
for item in load_structure_types()
|
||||
for option in item.options
|
||||
if option.phase == "detail" and option.required
|
||||
]
|
||||
assert len(detail_required) >= 18
|
||||
|
||||
|
||||
def test_only_confirmed_values_keep_defaults():
|
||||
"""미협의 재료·형식 선택지에 기본값이 남아 있으면 안 된다(지식DB 원칙).
|
||||
|
||||
⚠ 인정하는 길은 둘이다 — 아래 목록에 있거나, **정본에 `default_basis` 로 까닭을 적었거나**.
|
||||
뒤쪽이 2026-09-09 신설이고 **앞으로는 그쪽을 쓴다** — 이 파일은 git 밖이라 목록만
|
||||
고치면 다른 창에서 같은 시험이 깨진다(실제로 두 번 그랬다).
|
||||
"""
|
||||
offenders = [
|
||||
f"{item.type_id}.{option.key}={option.default!r}"
|
||||
for item in load_structure_types()
|
||||
for option in item.options
|
||||
if option.default not in (None, "")
|
||||
and (item.type_id, option.key) not in ALLOWED_DEFAULT_KEYS
|
||||
and not option.default_basis
|
||||
]
|
||||
assert offenders == [], f"근거 없는 기본값: {offenders}"
|
||||
|
||||
|
||||
def test_default_basis_is_a_real_sentence():
|
||||
"""까닭이 한 낱말이면 다음 사람이 못 되짚는다."""
|
||||
thin = [
|
||||
f"{item.type_id}.{option.key}"
|
||||
for item in load_structure_types()
|
||||
for option in item.options
|
||||
if option.default_basis is not None and len(option.default_basis) < 12
|
||||
]
|
||||
assert thin == [], f"까닭이 너무 짧음: {thin}"
|
||||
|
||||
|
||||
def test_options_without_default_are_required():
|
||||
"""기본값도 없고 필수도 아니면 빈 값이 조용히 저장된다 — 어느 phase든 동일.
|
||||
|
||||
managed_by 타입(계곡 통과 시설)은 정본이 pipe_points라 structures.json 저장
|
||||
검증을 거치지 않는다 — 배관 부속·세월교 관 옵션은 B05 유무 단계 입력이라
|
||||
강제하지 않는다.
|
||||
|
||||
⚠⚠ **세 번째 갈래가 있다**(2026-09-09 신설) — **「비워 두는 것이 뜻인 칸」**.
|
||||
비면 계산 쪽이 기준값으로 돌고 그 사실이 화면에 사유로 뜨며, 값을 넣으면 그 값이 이긴다
|
||||
(전면 기울기 · 물빼기 구멍 · 뒷길이 …). 그런 칸은 **정본에 `empty_means` 로 까닭을 적어**
|
||||
둔다.
|
||||
⚠ **예외 목록을 이 파일에 두지 않는다.** 이 파일은 `tmp/tests` 라 **git 밖**이라,
|
||||
정본(추적됨)만 바뀌고 예외(추적 안 됨)는 안 따라가 **다른 창에서만 시험이 깨졌다**
|
||||
(2026-09-09 실제로 두 번). 까닭이 값 옆에 있으면 그 일이 안 생긴다.
|
||||
"""
|
||||
loose = [
|
||||
f"{item.type_id}.{option.key}"
|
||||
for item in load_structure_types()
|
||||
if not item.managed_by
|
||||
for option in item.options
|
||||
if option.default in (None, "")
|
||||
and not option.required
|
||||
and option.input != "text"
|
||||
and not option.empty_means
|
||||
]
|
||||
assert loose == [], f"기본값·필수·`empty_means` 어느 쪽도 아닌 옵션: {loose}"
|
||||
|
||||
|
||||
def test_empty_means_is_a_real_sentence():
|
||||
"""까닭이 한 낱말이면 다음 사람이 못 되짚는다 — 어디서 오는 기준인지 적혀야 한다."""
|
||||
thin = [
|
||||
f"{item.type_id}.{option.key}"
|
||||
for item in load_structure_types()
|
||||
for option in item.options
|
||||
if option.empty_means is not None and len(option.empty_means) < 12
|
||||
]
|
||||
assert thin == [], f"까닭이 너무 짧음: {thin}"
|
||||
|
||||
|
||||
def test_a6_a7_types_exist_for_manual_placement():
|
||||
"""A6 노출형 횡단수로·A7 개거 — 수동 구조물 정본 소속 (2026-08-17 확정)."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
for type_id in ("cross_drain_exposed", "open_ditch"):
|
||||
assert type_id in by_id, f"{type_id} 타입 없음"
|
||||
item = by_id[type_id]
|
||||
assert item.group == "A" and item.managed_by is None
|
||||
assert item.placement == "point"
|
||||
|
||||
|
||||
def test_ford_bridge_shares_pipe_kind_and_diameter():
|
||||
"""세월교 구체 내 배관 = 배수관과 같은 관종·관경 선택지·기본값 (2026-08-17 지시)."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
ford = {option.key: option for option in by_id["ford_bridge"].options}
|
||||
pipe = {option.key: option for option in by_id["pipe"].options}
|
||||
for key in ("pipe_kind", "pipe_diameter_mm"):
|
||||
assert ford[key].choices == pipe[key].choices, key
|
||||
assert ford[key].default == pipe[key].default, key
|
||||
assert "pipe_count" in ford, "세월교 수량(련)은 사용자 지정으로 남는다"
|
||||
|
||||
|
||||
def test_valley_crossing_facilities_exist_as_managed_types():
|
||||
"""BOX암거·물넘이·세월교 — 표시용 레지스트리 항목 (정본은 pipe_points)."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
for type_id in ("box_culvert", "ford_pavement", "ford_bridge"):
|
||||
assert type_id in by_id, f"{type_id} 타입 없음"
|
||||
item = by_id[type_id]
|
||||
assert item.managed_by == "pipe_points"
|
||||
assert item.style.get("abbr"), "종단 마크 약호 필요"
|
||||
|
||||
|
||||
def test_pipe_accessory_options_defined():
|
||||
"""배관 부속 옵션 — 관종·관경(기본 1000) + 유입구/유출구 그룹 (2026-08-17 UI 개편).
|
||||
|
||||
유입구는 집수정 또는 기슭막이 택일, 유출구는 기슭막이만(현재 다른 선택지 없음).
|
||||
재질과 표면처리(찰/메)는 분리된 축이다 — 재질에 따라 표면처리가 달라진다
|
||||
(돌쌓기 §1·§2: 찰=모르타르 채움, 메=건쌓기).
|
||||
"""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
options = {option.key: option for option in by_id["pipe"].options}
|
||||
|
||||
for key in (
|
||||
"pipe_kind",
|
||||
"pipe_diameter_mm",
|
||||
"inlet_type",
|
||||
"inlet_basin_form",
|
||||
"inlet_basin_material",
|
||||
"inlet_revet_form",
|
||||
"inlet_revet_length_m",
|
||||
"inlet_revet_height_m",
|
||||
"outlet_revet_form",
|
||||
"outlet_revet_length_m",
|
||||
"outlet_revet_height_m",
|
||||
):
|
||||
assert key in options, f"pipe.{key} 옵션 없음"
|
||||
# 보호공(돌붙임·도수로) 계열은 삭제 — 기슭막이는 사면 공정(2026-08-19 지시 1).
|
||||
for gone in (
|
||||
"inlet_protection",
|
||||
"inlet_protection_area_m2",
|
||||
"inlet_protection_width_m",
|
||||
"outlet_protection",
|
||||
"outlet_protection_area_m2",
|
||||
"outlet_protection_width_m",
|
||||
):
|
||||
assert gone not in options, f"pipe.{gone}는 삭제됐다"
|
||||
|
||||
# 유형(flow_type)은 삭제 — 유입구 구조 선택으로 대체(2026-08-17 사용자 지시).
|
||||
assert "flow_type" not in options
|
||||
# 재질·표면처리는 하나로 합쳐 "형태"가 됐다(2026-08-17 사용자 지시 1).
|
||||
for gone in ("inlet_revet_material", "inlet_revet_finish", "outlet_revet_finish"):
|
||||
assert gone not in options, f"pipe.{gone}는 형태로 합쳐졌다"
|
||||
assert options["inlet_type"].choices == ["기슭막이", "집수정"]
|
||||
assert options["inlet_type"].default == "기슭막이"
|
||||
assert options["pipe_diameter_mm"].default == "1000"
|
||||
assert options["pipe_diameter_mm"].choices == ["800", "1000", "1200", "1500"]
|
||||
# 돌붙임은 바닥 구조라 형태 목록에서 뺐다 — 별도 항목이 있다(2026-08-17 지시 1).
|
||||
assert options["inlet_revet_form"].choices == [
|
||||
"돌쌓기(찰)",
|
||||
"돌쌓기(메)",
|
||||
"콘크리트",
|
||||
"돌망태",
|
||||
"통나무·목재틀",
|
||||
"바자",
|
||||
]
|
||||
assert options["inlet_revet_form"].default == "돌쌓기(찰)"
|
||||
assert options["outlet_revet_form"].default == "돌쌓기(메)"
|
||||
# 유출구도 구조 항목을 둔다 — 선택지는 기슭막이뿐이지만 양식을 맞춘다(지시 2).
|
||||
assert options["outlet_type"].choices == ["기슭막이"]
|
||||
# 구 두 축(있음/없음 + 표면처리)은 남아 있으면 안 된다.
|
||||
for gone in (
|
||||
"inlet_pitching",
|
||||
"inlet_pitching_finish",
|
||||
"inlet_pitching_area_m2",
|
||||
"outlet_pitching",
|
||||
"outlet_pitching_finish",
|
||||
"outlet_pitching_area_m2",
|
||||
):
|
||||
assert gone not in options, f"pipe.{gone}는 보호공으로 합쳐졌다"
|
||||
# 기본 치수 — 길이 10m·높이 2.5m.
|
||||
for side in ("inlet", "outlet"):
|
||||
assert options[f"{side}_revet_length_m"].default == 10
|
||||
assert options[f"{side}_revet_height_m"].default == 2.5
|
||||
# 유무·길이·관경·유입 구조 = b05, 형태·치수 제원 = detail.
|
||||
for key in ("inlet_type", "inlet_revet_length_m", "outlet_revet_length_m"):
|
||||
assert options[key].phase == "b05", f"pipe.{key}는 b05 phase"
|
||||
assert options["pipe_kind"].default == "파형강관"
|
||||
for key in (
|
||||
"inlet_basin_form",
|
||||
"inlet_basin_material",
|
||||
"inlet_revet_form",
|
||||
"outlet_revet_form",
|
||||
"outlet_revet_height_m",
|
||||
):
|
||||
assert options[key].phase == "detail", f"pipe.{key}는 detail phase"
|
||||
|
||||
|
||||
def test_revetment_range_options_like_slope_group():
|
||||
"""독립 기슭막이(D4) — C군과 같은 범위 계산 방식(2026-08-19 사용자 지시 3).
|
||||
|
||||
옵션 = 형태·높이·길이·기준측점 전/후, 기본 돌쌓기(메)·2.5·10·5·5. 보호공(돌붙임)
|
||||
계열은 삭제 — 기슭막이는 사면 공정이다. 형태 선택지는 배수관 부속과 공유.
|
||||
|
||||
2026-08-28 이관: 정본이 관 지점(`pipe_points`)으로 옮겨가 배치형태가 점형이 됐다.
|
||||
구간은 여기 옵션(전/후)이 만든다 — 관 시설의 start_m/end_m가 그 값이다."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
item = by_id["revetment"]
|
||||
options = {option.key: option for option in item.options}
|
||||
pipe_options = {option.key: option for option in by_id["pipe"].options}
|
||||
|
||||
assert item.placement == "point" and item.managed_by == "pipe_points"
|
||||
expected = {"height_m": 2.5, "length_m": 10, "before_m": 5, "after_m": 5}
|
||||
for key, default in expected.items():
|
||||
assert key in options, f"revetment.{key} 옵션 없음"
|
||||
assert options[key].phase == "b05" and not options[key].required, key
|
||||
assert options[key].default == default, f"revetment.{key} 기본값"
|
||||
assert options["form"].phase == "b05" and options["form"].default == "돌쌓기(메)"
|
||||
for gone in ("protection", "protection_area_m2", "protection_width_m"):
|
||||
assert gone not in options, f"revetment.{gone}는 삭제됐다"
|
||||
# 같은 선택지를 써야 배수관 부속과 수량 산출이 어긋나지 않는다.
|
||||
assert options["form"].choices == pipe_options["inlet_revet_form"].choices
|
||||
|
||||
|
||||
def test_bed_sill_revived_as_point_type():
|
||||
"""바닥막이 부활(2026-08-19 사용자 지시 2·4·5) — D군 점형, 기준측점만.
|
||||
|
||||
옵션 = 형태(돌붙임 찰/메)·면적·높이, 기본 돌붙임(메)·10㎡·0.3m."""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
item = by_id["bed_sill"]
|
||||
assert item.group == "D" and item.placement == "point" and item.enabled
|
||||
options = {option.key: option for option in item.options}
|
||||
assert options["form"].choices == ["돌붙임(찰)", "돌붙임(메)"]
|
||||
assert options["form"].default == "돌붙임(메)" and options["form"].phase == "b05"
|
||||
assert options["area_m2"].default == 10 and options["area_m2"].unit == "㎡"
|
||||
assert options["height_m"].default == 0.3
|
||||
for option in item.options:
|
||||
# 돌 조달·전면 기울기(2026-09-09 신설)는 상세 제원이라 detail phase 다
|
||||
# — B05 는 유무·위치만 받는다.
|
||||
if option.key in {
|
||||
"stone_supply",
|
||||
"face_slope_ratio",
|
||||
"foundation",
|
||||
"stone_coeff_basis",
|
||||
"fill_concrete_mpa",
|
||||
}:
|
||||
continue
|
||||
assert option.phase == "b05" and not option.required, option.key
|
||||
|
||||
|
||||
def test_box_culvert_wing_wall_options_defined():
|
||||
"""BOX암거 날개벽 — 유입·유출 개별: 설치 유무 + 짧은쪽 높이/길이/각도.
|
||||
|
||||
2026-08-17 사용자 확정으로 기본값이 붙었다: 설치 있음·높이 1m·길이 2m·각도 45°.
|
||||
기본값이 있으니 required는 풀고, B05 서브폼이 실제로 받는 값이라 phase도 b05다
|
||||
(이전의 detail 표기는 폼과 어긋난 정본이었다).
|
||||
"""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
options = {option.key: option for option in by_id["box_culvert"].options}
|
||||
|
||||
expected = {"height_m": 1, "length_m": 2, "angle_deg": 45}
|
||||
for side in ("in", "out"):
|
||||
toggle = options[f"wing_{side}"]
|
||||
assert toggle.phase == "b05" and not toggle.required
|
||||
assert toggle.choices == ["있음", "없음"]
|
||||
assert toggle.default == "있음", f"wing_{side} 기본 설치"
|
||||
for dim, value in expected.items():
|
||||
option = options[f"wing_{side}_{dim}"]
|
||||
assert option.phase == "b05" and not option.required, f"wing_{side}_{dim}"
|
||||
assert option.default == value, f"wing_{side}_{dim} 기본값"
|
||||
|
||||
|
||||
def test_box_culvert_body_size_defaults_to_first_preset():
|
||||
"""BOX암거 본체 — 프리셋 2.0×2.0·3.0×3.0 중 첫 항목이 기본값 (2026-08-17 확정).
|
||||
|
||||
프리셋 선택은 폼 전용이라 레지스트리에 별도 키를 두지 않는다 — 저장 키는
|
||||
프리셋이든 사용자 지정이든 body_width_m·body_height_m 숫자 그대로다.
|
||||
"""
|
||||
by_id = {item.type_id: item for item in load_structure_types()}
|
||||
options = {option.key: option for option in by_id["box_culvert"].options}
|
||||
|
||||
for key in ("body_width_m", "body_height_m"):
|
||||
assert options[key].phase == "b05" and not options[key].required
|
||||
assert options[key].default == 2.0, key
|
||||
assert options[key].input == "number"
|
||||
# 규격 프리셋 전용 키를 만들면 하류가 폭·높이 대신 문자열을 읽게 된다.
|
||||
assert "body_size" not in options
|
||||
@@ -0,0 +1,298 @@
|
||||
"""B05 구조물 정본 저장·조회·검증 테스트 (크로스체크 지적 2 반영 강화판)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import (
|
||||
StructureRevisionConflict,
|
||||
load_structures,
|
||||
save_structures,
|
||||
structures_file_path,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project_root(tmp_path):
|
||||
return str(tmp_path / "project")
|
||||
|
||||
|
||||
def _point(chainage=100.0, type_id="erosion_check", **overrides):
|
||||
# 골막이 형식(재료)은 설계자 선택이라 필수 — 길이·높이는 사용자 확정 기본값이 있다.
|
||||
data = {
|
||||
"type_id": type_id,
|
||||
"placement": "point",
|
||||
"chainage_m": chainage,
|
||||
"options": {"form": "돌"},
|
||||
}
|
||||
data.update(overrides)
|
||||
return StructureInstance.model_validate(data)
|
||||
|
||||
|
||||
def _interval(start=10.0, end=40.0, type_id="ditch_side", **overrides):
|
||||
# 측구 형식도 필수 선택 — 깊이는 별표2 단일값이라 기본값이 있다.
|
||||
data = {
|
||||
"type_id": type_id,
|
||||
"placement": "interval",
|
||||
"start_m": start,
|
||||
"end_m": end,
|
||||
"options": {"form": "일반형(제형)"},
|
||||
}
|
||||
data.update(overrides)
|
||||
return StructureInstance.model_validate(data)
|
||||
|
||||
|
||||
# ── 기본 왕복·판번호 (기존 회귀) ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_load_empty_project_returns_revision_zero(project_root):
|
||||
assert load_structures(project_root) == (0, [])
|
||||
|
||||
|
||||
def test_disabled_type_structures_still_save_and_load(project_root):
|
||||
"""B06 이관으로 enabled=False가 된 타입(예: 측구)의 기존 저장분 — 백엔드는
|
||||
전체 레지스트리로 검증하므로 로드·재저장이 계속 통과해야 한다(2026-08-17 재편).
|
||||
화면 노출만 프론트 enabled 필터가 막는다."""
|
||||
revision = save_structures(project_root, [_interval(type_id="ditch_side")], base_revision=0)
|
||||
assert revision == 1
|
||||
_, loaded = load_structures(project_root)
|
||||
assert loaded[0].type_id == "ditch_side"
|
||||
|
||||
|
||||
def test_save_then_load_roundtrip(project_root):
|
||||
revision = save_structures(project_root, [_point(), _interval()], base_revision=0)
|
||||
assert revision == 1
|
||||
loaded_revision, loaded = load_structures(project_root)
|
||||
assert loaded_revision == 1
|
||||
assert len(loaded) == 2
|
||||
|
||||
|
||||
def test_save_assigns_and_keeps_structure_ids(project_root):
|
||||
save_structures(project_root, [_point()], base_revision=0)
|
||||
_, loaded = load_structures(project_root)
|
||||
kept = loaded[0].structure_id
|
||||
assert kept
|
||||
|
||||
loaded[0].chainage_m = 150.0
|
||||
save_structures(project_root, loaded, base_revision=1)
|
||||
_, again = load_structures(project_root)
|
||||
assert again[0].structure_id == kept
|
||||
|
||||
|
||||
def test_stale_base_revision_raises_conflict(project_root):
|
||||
save_structures(project_root, [_point()], base_revision=0)
|
||||
with pytest.raises(StructureRevisionConflict):
|
||||
save_structures(project_root, [_point(300.0)], base_revision=0)
|
||||
|
||||
|
||||
def test_pipe_managed_types_are_rejected(project_root):
|
||||
with pytest.raises(ValueError):
|
||||
save_structures(project_root, [_point(50.0, type_id="pipe")], base_revision=0)
|
||||
|
||||
|
||||
def test_unknown_type_id_is_rejected(project_root):
|
||||
with pytest.raises(ValueError):
|
||||
save_structures(project_root, [_point(50.0, type_id="no_such_type")], base_revision=0)
|
||||
|
||||
|
||||
def test_saved_file_is_valid_json_with_revision(project_root):
|
||||
save_structures(project_root, [_point()], base_revision=0)
|
||||
with open(structures_file_path(project_root), encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
assert payload["revision"] == 1
|
||||
|
||||
|
||||
def test_corrupt_file_does_not_crash_load(project_root):
|
||||
save_structures(project_root, [_point()], base_revision=0)
|
||||
with open(structures_file_path(project_root), "w", encoding="utf-8") as handle:
|
||||
handle.write("{ broken json")
|
||||
assert load_structures(project_root) == (0, [])
|
||||
|
||||
|
||||
# ── 폐지 필드(설치측·이격) 이관 — 2026-08-17 사용자 지시 ────────────────────
|
||||
|
||||
|
||||
def _write_raw(project_root, structures):
|
||||
save_structures(project_root, [_point()], base_revision=0)
|
||||
with open(structures_file_path(project_root), "w", encoding="utf-8") as handle:
|
||||
json.dump({"revision": 3, "structures": structures}, handle, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_legacy_side_and_offset_keys_are_dropped_on_load(project_root):
|
||||
# 구 저장분을 그대로 넘기면 extra="forbid"에 걸려 정본이 통째로 버려진다.
|
||||
_write_raw(
|
||||
project_root,
|
||||
[
|
||||
{
|
||||
"structure_id": "abc",
|
||||
"type_id": "erosion_check",
|
||||
"placement": "point",
|
||||
"chainage_m": 100.0,
|
||||
"side": "left",
|
||||
"offset_m": 2.5,
|
||||
"options": {"form": "돌"},
|
||||
}
|
||||
],
|
||||
)
|
||||
revision, loaded = load_structures(project_root)
|
||||
assert revision == 3
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].structure_id == "abc"
|
||||
assert not hasattr(loaded[0], "side")
|
||||
assert not hasattr(loaded[0], "offset_m")
|
||||
|
||||
|
||||
def test_legacy_keys_disappear_from_file_after_next_save(project_root):
|
||||
_write_raw(
|
||||
project_root,
|
||||
[
|
||||
{
|
||||
"type_id": "erosion_check",
|
||||
"placement": "point",
|
||||
"chainage_m": 100.0,
|
||||
"side": "left",
|
||||
"offset_m": 2.5,
|
||||
"options": {"form": "돌"},
|
||||
}
|
||||
],
|
||||
)
|
||||
_, loaded = load_structures(project_root)
|
||||
save_structures(project_root, loaded, base_revision=3)
|
||||
with open(structures_file_path(project_root), encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
assert "side" not in payload["structures"][0]
|
||||
assert "offset_m" not in payload["structures"][0]
|
||||
|
||||
|
||||
def test_other_unknown_keys_are_still_rejected(project_root):
|
||||
# 두 키만 떨어낸다 — 오타 키까지 통과시키면 정본이 조용히 썩는다.
|
||||
_write_raw(
|
||||
project_root,
|
||||
[
|
||||
{
|
||||
"type_id": "erosion_check",
|
||||
"placement": "point",
|
||||
"chainage_m": 100.0,
|
||||
"sidee": "left",
|
||||
"options": {"form": "돌"},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert load_structures(project_root) == (0, [])
|
||||
|
||||
|
||||
# ── 크로스체크 지적 2: 서버 검증 강화 ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_placement_mismatching_registry_is_rejected(project_root):
|
||||
"""옹벽(레지스트리 interval)을 point로 보내면 거절해야 한다."""
|
||||
wall_as_point = _point(50.0, type_id="retaining_wall", options={"height_m": 2.0})
|
||||
with pytest.raises(ValueError, match="배치형태"):
|
||||
save_structures(project_root, [wall_as_point], base_revision=0)
|
||||
|
||||
|
||||
def test_undefined_option_key_is_rejected(project_root):
|
||||
bad = _point(options={"form": "돌", "no_such_option": 1})
|
||||
with pytest.raises(ValueError, match="옵션"):
|
||||
save_structures(project_root, [bad], base_revision=0)
|
||||
|
||||
|
||||
def test_negative_number_option_is_rejected(project_root):
|
||||
bad = _point(options={"form": "돌", "height_m": -999})
|
||||
with pytest.raises(ValueError, match="0 이상"):
|
||||
save_structures(project_root, [bad], base_revision=0)
|
||||
|
||||
|
||||
def test_non_numeric_number_option_is_rejected(project_root):
|
||||
bad = _point(options={"form": "돌", "height_m": "높음"})
|
||||
with pytest.raises(ValueError, match="숫자"):
|
||||
save_structures(project_root, [bad], base_revision=0)
|
||||
|
||||
|
||||
def test_select_option_outside_choices_is_rejected(project_root):
|
||||
bad = _interval(options={"form": "없는형식"})
|
||||
with pytest.raises(ValueError, match="선택지"):
|
||||
save_structures(project_root, [bad], base_revision=0)
|
||||
|
||||
|
||||
def test_detail_required_option_missing_is_accepted_at_b05(project_root):
|
||||
"""옹벽 형식은 phase=detail — B05는 유무·위치 단계라 비워도 저장된다.
|
||||
|
||||
필수 원칙(미확정 기본값 = 필수)은 유지되되 강제 시점이 B06/B07로 옮겨졌다
|
||||
(2026-08-17 사용자 확정). 길이·높이는 2026-08-19 지시로 B05 필수가 됐으므로
|
||||
채워서 보낸다 — 비운 것은 detail(형식)뿐이다.
|
||||
"""
|
||||
wall = _interval(type_id="retaining_wall", options={"length_m": 30.0, "height_m": 2.5})
|
||||
assert save_structures(project_root, [wall], base_revision=0) == 1
|
||||
|
||||
|
||||
def test_b05_phase_required_option_is_still_enforced(project_root):
|
||||
"""phase=b05인 필수 옵션이 생기면 여전히 강제된다 — 완화는 detail에 한한다."""
|
||||
from B05_Profile import B05_Profile_Structures_Repository as repo_module
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureOptionField,
|
||||
StructureType,
|
||||
)
|
||||
|
||||
fake = StructureType(
|
||||
type_id="fake_b05_required",
|
||||
group="Z",
|
||||
name="테스트",
|
||||
placement="point",
|
||||
options=[
|
||||
StructureOptionField(
|
||||
key="must", label="필수", input="number", required=True, phase="b05"
|
||||
)
|
||||
],
|
||||
)
|
||||
original = repo_module.structure_type_map
|
||||
|
||||
def patched():
|
||||
mapping = dict(original())
|
||||
mapping[fake.type_id] = fake
|
||||
return mapping
|
||||
|
||||
repo_module.structure_type_map = patched
|
||||
try:
|
||||
bad = _point(type_id="fake_b05_required", options={})
|
||||
with pytest.raises(ValueError, match="필수"):
|
||||
save_structures(project_root, [bad], base_revision=0)
|
||||
finally:
|
||||
repo_module.structure_type_map = original
|
||||
|
||||
|
||||
def test_required_option_present_is_accepted(project_root):
|
||||
wall = _interval(
|
||||
type_id="retaining_wall",
|
||||
options={"form": "반중력식", "length_m": 30.0, "height_m": 2.5},
|
||||
)
|
||||
assert save_structures(project_root, [wall], base_revision=0) == 1
|
||||
|
||||
|
||||
def test_duplicate_structure_ids_are_rejected(project_root):
|
||||
first = _point()
|
||||
second = _point(200.0)
|
||||
first.structure_id = second.structure_id = "dup"
|
||||
with pytest.raises(ValueError, match="중복"):
|
||||
save_structures(project_root, [first, second], base_revision=0)
|
||||
|
||||
|
||||
def test_position_beyond_route_length_is_rejected(project_root):
|
||||
with pytest.raises(ValueError, match="연장"):
|
||||
save_structures(project_root, [_point(999.0)], base_revision=0, max_chainage_m=500.0)
|
||||
with pytest.raises(ValueError, match="연장"):
|
||||
save_structures(
|
||||
project_root, [_interval(490.0, 600.0)], base_revision=0, max_chainage_m=500.0
|
||||
)
|
||||
|
||||
|
||||
def test_position_within_route_length_is_accepted(project_root):
|
||||
assert (
|
||||
save_structures(project_root, [_point(499.0)], base_revision=0, max_chainage_m=500.0) == 1
|
||||
)
|
||||
|
||||
|
||||
def test_no_route_length_skips_range_check(project_root):
|
||||
"""노선 연장을 모르면(None) 범위 검증은 건너뛴다 — 저장 자체는 허용."""
|
||||
assert save_structures(project_root, [_point(9999.0)], base_revision=0) == 1
|
||||
@@ -0,0 +1,293 @@
|
||||
"""B05 구조물 라우터 테스트 (지적 5 반영: 404·STALE 실패 정합 포함)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import B05_Profile.B05_Profile_Structures_Router as router_module
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in
|
||||
|
||||
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def invalidated_calls():
|
||||
return []
|
||||
|
||||
|
||||
def _make_client(tmp_path, monkeypatch, invalidated_calls, *, stale_ok=True, route_length=None):
|
||||
async def fake_project_root(project_id):
|
||||
return str(tmp_path / "project")
|
||||
|
||||
async def fake_invalidate(project_id):
|
||||
invalidated_calls.append(str(project_id))
|
||||
return stale_ok
|
||||
|
||||
async def fake_route_length(project_id):
|
||||
return route_length
|
||||
|
||||
monkeypatch.setattr(router_module, "_project_root", fake_project_root)
|
||||
monkeypatch.setattr(router_module, "_invalidate_downstream", fake_invalidate)
|
||||
monkeypatch.setattr(router_module, "_route_length", fake_route_length)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch, invalidated_calls):
|
||||
return _make_client(tmp_path, monkeypatch, invalidated_calls)
|
||||
|
||||
|
||||
def _point_payload(base_revision=0, chainage=100.0, type_id="erosion_check"):
|
||||
return {
|
||||
"base_revision": base_revision,
|
||||
"structures": [
|
||||
{
|
||||
"type_id": type_id,
|
||||
"placement": "point",
|
||||
"chainage_m": chainage,
|
||||
"options": {"form": "돌"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_structure_types_endpoint_returns_registry(client):
|
||||
response = client.get("/api/projects/structure-types")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["schema_version"] >= 1
|
||||
assert len(body["types"]) >= 30
|
||||
# 2026-08-19 C군 개편: 높이는 사용자 확정 기본값 2.5를 갖고 필수 강제는 풀렸다
|
||||
# (기본값 원칙 ② — 구 저장분 저장 거부 재발 방지). 상세 정책은 registry_policy 테스트.
|
||||
wall = next(item for item in body["types"] if item["type_id"] == "retaining_wall")
|
||||
height = next(option for option in wall["options"] if option["key"] == "height_m")
|
||||
assert height["required"] is False and height["default"] == 2.5
|
||||
|
||||
|
||||
def test_read_structures_empty_project(client):
|
||||
response = client.get(f"/api/projects/{PROJECT_ID}/route/structures")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["revision"] == 0
|
||||
|
||||
|
||||
def test_save_then_read_roundtrip(client):
|
||||
saved = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["revision"] == 1
|
||||
read = client.get(f"/api/projects/{PROJECT_ID}/route/structures")
|
||||
assert read.json()["structures"][0]["structure_id"]
|
||||
|
||||
|
||||
def test_stale_revision_returns_409(client):
|
||||
client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
|
||||
conflict = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload(base_revision=0)
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["revision"] == 1
|
||||
|
||||
|
||||
def test_pipe_type_returns_400(client):
|
||||
response = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload(type_id="pipe")
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_invalid_placement_returns_422(client):
|
||||
response = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures",
|
||||
json={
|
||||
"base_revision": 0,
|
||||
"structures": [
|
||||
{
|
||||
"type_id": "ditch_side",
|
||||
"placement": "interval",
|
||||
"start_m": 50.0,
|
||||
"end_m": 10.0,
|
||||
"options": {"form": "L형"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_registry_placement_mismatch_returns_400(client):
|
||||
"""레지스트리와 다른 배치형태(옹벽을 point로)는 400."""
|
||||
response = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures",
|
||||
json=_point_payload(type_id="retaining_wall"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_missing_project_returns_404(tmp_path, monkeypatch, invalidated_calls):
|
||||
"""없는 프로젝트 — 저장 경로 조회가 LookupError를 던져도 404로 응답해야 한다."""
|
||||
client = _make_client(tmp_path, monkeypatch, invalidated_calls)
|
||||
|
||||
async def raising_project_root(project_id):
|
||||
raise LookupError("프로젝트 또는 프로젝트 저장 경로를 찾을 수 없습니다.")
|
||||
|
||||
monkeypatch.setattr(router_module, "_project_root", raising_project_root)
|
||||
assert client.get(f"/api/projects/{PROJECT_ID}/route/structures").status_code == 404
|
||||
assert (
|
||||
client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload()
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_design_change_invalidates_downstream(client, invalidated_calls):
|
||||
client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
|
||||
moved = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures",
|
||||
json=_point_payload(base_revision=1, chainage=180.0),
|
||||
)
|
||||
assert moved.json()["invalidated_downstream"] is True
|
||||
assert invalidated_calls == [PROJECT_ID, PROJECT_ID]
|
||||
|
||||
|
||||
def test_unchanged_save_does_not_invalidate_downstream(client, invalidated_calls):
|
||||
saved = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
invalidated_calls.clear()
|
||||
again = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures",
|
||||
json={"base_revision": saved.json()["revision"], "structures": stored},
|
||||
)
|
||||
assert again.json()["invalidated_downstream"] is False
|
||||
assert invalidated_calls == []
|
||||
|
||||
|
||||
def test_stale_failure_is_not_reported_as_success(tmp_path, monkeypatch, invalidated_calls):
|
||||
"""STALE 갱신이 실패하면 invalidated_downstream=false — 성공한 척 금지(지적 5).
|
||||
|
||||
화면이 "필요했는데 못 했다"를 구분해 알릴 수 있도록 needs 플래그도 함께 준다
|
||||
(2차 크로스체크 지적 3).
|
||||
"""
|
||||
client = _make_client(tmp_path, monkeypatch, invalidated_calls, stale_ok=False)
|
||||
response = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
|
||||
body = response.json()
|
||||
assert response.status_code == 200 # 저장 자체는 성공
|
||||
assert body["invalidated_downstream"] is False
|
||||
assert body["needs_downstream_invalidation"] is True
|
||||
|
||||
|
||||
def test_unchanged_save_needs_no_invalidation_flag(tmp_path, monkeypatch, invalidated_calls):
|
||||
"""설계 영향이 없으면 needs 플래그도 false — 실패 안내가 뜨지 않아야 한다."""
|
||||
client = _make_client(tmp_path, monkeypatch, invalidated_calls, stale_ok=False)
|
||||
saved = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
again = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures",
|
||||
json={"base_revision": saved.json()["revision"], "structures": stored},
|
||||
)
|
||||
assert again.json()["needs_downstream_invalidation"] is False
|
||||
|
||||
|
||||
def test_route_length_limits_position(tmp_path, monkeypatch, invalidated_calls):
|
||||
"""노선 연장이 있으면 범위 밖 배치는 400."""
|
||||
client = _make_client(tmp_path, monkeypatch, invalidated_calls, route_length=500.0)
|
||||
ok = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload(chainage=499.0)
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
bad = client.put(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures",
|
||||
json=_point_payload(base_revision=1, chainage=999.0),
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
# ── 구 비정규 측점 이관 (2026-08-17 컨테이너 병합 3단계) ────────────────────
|
||||
|
||||
|
||||
def _migrate_payload():
|
||||
return {
|
||||
"stations": [
|
||||
{"chainage_m": 60.0, "structure": "파형강관 D800"}, # 배관 — 제외돼야 한다
|
||||
{"chainage_m": 120.0, "structure": "기성막이"},
|
||||
{"chainage_m": 200.0, "structure": "대피로 2.0m"},
|
||||
{"chainage_m": 260.0, "structure": "낙석방지책"}, # 기타
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _pipe_points_file(tmp_path, points=()):
|
||||
"""관 지점 정본을 미리 깔아 둔다 — 기슭막이 이관은 이 파일이 있어야 나간다."""
|
||||
path = pipe_points_path_in(tmp_path / "project")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"route_signature": "test-signature", "points": list(points)}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_migrate_converts_legacy_stations(client, tmp_path):
|
||||
"""기슭막이는 관 지점 정본으로, 나머지는 구조물 정본으로 갈린다(2026-09-01 확정)."""
|
||||
path = _pipe_points_file(tmp_path)
|
||||
response = client.post(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["migrated"] == 3 # 배관 제외 (구조물 2 + 관 시설 1)
|
||||
assert body["pipe_facilities"] == 1
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
type_ids = sorted(item["type_id"] for item in stored)
|
||||
assert type_ids == ["etc", "refuge"] # 기슭막이는 구조물 목록에 없다
|
||||
(pipe,) = json.loads(path.read_text(encoding="utf-8"))["points"]
|
||||
assert pipe["facility"] == "revetment" and pipe["chainage_m"] == 120.0
|
||||
# 구간·제원은 레지스트리 기본값 승계 — 기준측점 전/후 5m.
|
||||
assert (pipe["start_m"], pipe["end_m"]) == (115.0, 125.0)
|
||||
assert pipe["options"]["form"] == "돌쌓기(메)" and pipe["options"]["height_m"] == 2.5
|
||||
|
||||
|
||||
def test_migrate_without_pipe_points_defers_revetment(client):
|
||||
"""관 지점 정본이 없으면 기슭막이만 미룬다 — 원천 측점이 남아 다음에 다시 이관된다."""
|
||||
response = client.post(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["migrated"] == 2 and response.json()["pipe_facilities"] == 0
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
assert sorted(item["type_id"] for item in stored) == ["etc", "refuge"]
|
||||
|
||||
|
||||
def test_migrate_is_idempotent(client, tmp_path):
|
||||
path = _pipe_points_file(tmp_path)
|
||||
client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload())
|
||||
again = client.post(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload()
|
||||
)
|
||||
assert again.status_code == 200
|
||||
assert again.json()["migrated"] == 0
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
assert len(stored) == 2
|
||||
assert len(json.loads(path.read_text(encoding="utf-8"))["points"]) == 1
|
||||
|
||||
|
||||
def test_migrate_keeps_existing_structures(client, tmp_path):
|
||||
_pipe_points_file(tmp_path)
|
||||
client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
|
||||
response = client.post(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=_migrate_payload()
|
||||
)
|
||||
assert response.json()["migrated"] == 3
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
assert len(stored) == 3 # 기존 1 + 이관 2(기슭막이는 관 지점으로)
|
||||
|
||||
|
||||
def test_migrate_empty_list_is_noop(client):
|
||||
response = client.post(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"stations": []}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["migrated"] == 0
|
||||
@@ -0,0 +1,115 @@
|
||||
"""구간형 기준점·옵션 phase — 스키마 확장 (PLAN 2026-08-17 컨테이너 병합 1단계).
|
||||
|
||||
사용자 확정: 점형 = 기준 측점이 마킹 위치. 구간형 = **기준점에 마킹** + 시작·종료 측점.
|
||||
기존 스키마는 구간형에 chainage_m을 금지하고 start_m에 마킹했다 — 기준점을 허용하고
|
||||
시작≤기준≤종료를 검증한다. 기존 저장분(기준점 없음)은 start_m으로 채운다(하위 호환).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureInstance,
|
||||
StructureOptionField,
|
||||
)
|
||||
|
||||
|
||||
def _interval(**overrides):
|
||||
data = {
|
||||
"type_id": "ditch_side",
|
||||
"placement": "interval",
|
||||
"start_m": 100.0,
|
||||
"end_m": 140.0,
|
||||
"options": {},
|
||||
}
|
||||
data.update(overrides)
|
||||
return StructureInstance.model_validate(data)
|
||||
|
||||
|
||||
# ── 구간형 기준점 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_interval_accepts_anchor_within_span():
|
||||
item = _interval(chainage_m=115.0)
|
||||
assert item.chainage_m == 115.0
|
||||
assert item.anchor_m() == 115.0
|
||||
|
||||
|
||||
def test_interval_without_anchor_defaults_to_start():
|
||||
"""기존 저장분(기준점 없음) — start_m이 기준점이 된다 (하위 호환)."""
|
||||
item = _interval()
|
||||
assert item.chainage_m == 100.0
|
||||
assert item.anchor_m() == 100.0
|
||||
|
||||
|
||||
def test_interval_anchor_outside_span_is_rejected():
|
||||
with pytest.raises(ValidationError, match="기준점"):
|
||||
_interval(chainage_m=90.0)
|
||||
with pytest.raises(ValidationError, match="기준점"):
|
||||
_interval(chainage_m=141.0)
|
||||
|
||||
|
||||
def test_interval_anchor_at_boundaries_is_accepted():
|
||||
assert _interval(chainage_m=100.0).anchor_m() == 100.0
|
||||
assert _interval(chainage_m=140.0).anchor_m() == 140.0
|
||||
|
||||
|
||||
def test_interval_still_requires_ordered_span():
|
||||
with pytest.raises(ValidationError):
|
||||
_interval(start_m=140.0, end_m=100.0)
|
||||
with pytest.raises(ValidationError):
|
||||
_interval(start_m=100.0, end_m=None)
|
||||
|
||||
|
||||
# ── 점형·부지형은 기존 규칙 유지 ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_point_still_rejects_span_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
StructureInstance.model_validate(
|
||||
{
|
||||
"type_id": "erosion_check",
|
||||
"placement": "point",
|
||||
"chainage_m": 50.0,
|
||||
"start_m": 40.0,
|
||||
"end_m": 60.0,
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_point_anchor_is_chainage():
|
||||
item = StructureInstance.model_validate(
|
||||
{"type_id": "erosion_check", "placement": "point", "chainage_m": 50.0, "options": {}}
|
||||
)
|
||||
assert item.anchor_m() == 50.0
|
||||
|
||||
|
||||
# ── 옵션 phase — B05(배치) / detail(B06·B07 상세) ──────────────────────────
|
||||
|
||||
|
||||
def test_option_phase_defaults_to_b05():
|
||||
field = StructureOptionField.model_validate(
|
||||
{"key": "form", "label": "형식", "input": "select", "choices": ["A"]}
|
||||
)
|
||||
assert field.phase == "b05"
|
||||
|
||||
|
||||
def test_option_phase_detail_roundtrip():
|
||||
field = StructureOptionField.model_validate(
|
||||
{
|
||||
"key": "height_m",
|
||||
"label": "높이",
|
||||
"input": "number",
|
||||
"required": True,
|
||||
"phase": "detail",
|
||||
}
|
||||
)
|
||||
assert field.phase == "detail" and field.required is True
|
||||
|
||||
|
||||
def test_option_phase_rejects_unknown_value():
|
||||
with pytest.raises(ValidationError):
|
||||
StructureOptionField.model_validate(
|
||||
{"key": "x", "label": "x", "input": "text", "phase": "b07"}
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""층따기 밑수 — 성토부 아래 원지반의 지표면 길이(m).
|
||||
|
||||
근거(2026-09-09 사용자 확정: 단위 ㎡) — 별표2 · 임도기술교본 6장 4절
|
||||
「경사가 1:4보다 급한 지반 위에 성토를 하는 경우 원지반 표면에 층따기」.
|
||||
면적은 측점 사이를 이어 B08 이 내고, 여기서는 **측점 하나의 밑수 길이**만 낸다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Areas import ( # noqa: E402
|
||||
_BENCH_CUT_MIN_GROUND_SLOPE,
|
||||
_bench_cut_length,
|
||||
)
|
||||
|
||||
|
||||
def test_완만한_지반은_층따기를_안_한다() -> None:
|
||||
"""1:4(25%)보다 완만하면 대상이 아니다 — 성토가 있어도 0."""
|
||||
offsets = [0.0, 1.0, 2.0]
|
||||
grounds = [0.0, 0.2, 0.4] # 20 % 경사
|
||||
diffs = [-1.0, -1.0, -1.0] # 전 구간 성토
|
||||
assert _bench_cut_length(offsets, grounds, diffs) == 0.0
|
||||
|
||||
|
||||
def test_급한_지반의_성토부만_빗변으로_센다() -> None:
|
||||
offsets = [0.0, 1.0, 2.0]
|
||||
grounds = [0.0, 0.5, 1.0] # 50 % 경사 — 대상
|
||||
diffs = [-1.0, -1.0, -1.0]
|
||||
expected = 2 * math.hypot(1.0, 0.5)
|
||||
assert _bench_cut_length(offsets, grounds, diffs) == expected
|
||||
|
||||
|
||||
def test_절토부는_안_센다() -> None:
|
||||
"""층따기는 성토부 아래 원지반에만 한다."""
|
||||
offsets = [0.0, 1.0]
|
||||
grounds = [0.0, 0.5]
|
||||
diffs = [1.0, 1.0] # 절토
|
||||
assert _bench_cut_length(offsets, grounds, diffs) == 0.0
|
||||
|
||||
|
||||
def test_절성토_경계는_영교점까지만_센다() -> None:
|
||||
offsets = [0.0, 1.0]
|
||||
grounds = [0.0, 0.5]
|
||||
diffs = [1.0, -1.0] # 가운데에서 절토→성토
|
||||
expected = math.hypot(1.0, 0.5) * 0.5
|
||||
assert math.isclose(_bench_cut_length(offsets, grounds, diffs), expected, rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_내리막도_오르막과_같이_센다() -> None:
|
||||
"""기울기의 방향이 아니라 급한지만 본다."""
|
||||
up = _bench_cut_length([0.0, 1.0], [0.0, 0.5], [-1.0, -1.0])
|
||||
down = _bench_cut_length([0.0, 1.0], [0.5, 0.0], [-1.0, -1.0])
|
||||
assert math.isclose(up, down, rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_문턱값이_법정_1대4_이다() -> None:
|
||||
assert _BENCH_CUT_MIN_GROUND_SLOPE == 0.25
|
||||
|
||||
|
||||
def test_설계_결과에_밑수가_실린다() -> None:
|
||||
"""엔진 결과 dict 에 키가 있어야 B08 이 읽는다."""
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
|
||||
ground = [
|
||||
{"offset_m": offset / 2.0, "elevation_m": 100.0 - (offset / 2.0) * 0.5}
|
||||
for offset in range(-24, 25)
|
||||
]
|
||||
result = compute_cross_design(
|
||||
ground,
|
||||
100.0,
|
||||
ground_type="soil",
|
||||
section_mode="left_cut",
|
||||
)
|
||||
assert "bench_cut_length_m" in result
|
||||
assert result["bench_cut_length_m"] >= 0.0
|
||||
@@ -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)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""측점 설계 묶음 저장 — 한 행짜리 함수와 **같은 결과**를 내는지 본다.
|
||||
|
||||
왜 (2026-09-06) — [저장]이 측점마다 `SELECT`+`UPDATE` 두 왕복을 냈고, 원격 DB 라
|
||||
22행이면 670ms 였다. 세 문장으로 묶었는데 값이 달라지면 안 된다.
|
||||
DB 는 가짜 커서로 대신한다 — 검사 대상은 **어떤 SQL 을 몇 번 내고, 어떤 JSON 이 되는가**다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
self.calls = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, params=None):
|
||||
self.calls.append((" ".join(sql.split()), list(params) if params else []))
|
||||
|
||||
async def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, rows):
|
||||
self.cursor_obj = FakeCursor(rows)
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_obj
|
||||
|
||||
|
||||
def _rows():
|
||||
"""측점 셋 — 하나는 design 이 이미 있고, 하나는 비었고, 하나는 문자열 JSON."""
|
||||
return [
|
||||
(11, 0.0, {"design": {"ground_type": "soil"}, "summary": "keep"}),
|
||||
(12, 20.0, {}),
|
||||
(13, 40.0, json.dumps({"design": {"paved": True}})),
|
||||
]
|
||||
|
||||
|
||||
def _written(connection):
|
||||
"""UPDATE 문에 실린 (id, data) 짝을 돌려준다."""
|
||||
for sql, params in connection.cursor_obj.calls:
|
||||
if sql.startswith("UPDATE cross_sections SET data = CASE"):
|
||||
pairs = {}
|
||||
# params = [id, blob, id, blob, …, id, id, …]
|
||||
half = len(params) // 3 * 2
|
||||
for index in range(0, half, 2):
|
||||
pairs[params[index]] = json.loads(params[index + 1])
|
||||
return pairs
|
||||
return {}
|
||||
|
||||
|
||||
def test_한_문장으로_여러_행을_쓴다():
|
||||
connection = FakeConnection(_rows())
|
||||
written = asyncio.run(
|
||||
merge_cross_section_designs(
|
||||
connection,
|
||||
route_id=7,
|
||||
entries=[(0.0, {"a": 1}), (20.0, {"b": 2}), (40.0, {"c": 3})],
|
||||
replace=True,
|
||||
)
|
||||
)
|
||||
assert written == 3
|
||||
sqls = [sql for sql, _ in connection.cursor_obj.calls]
|
||||
# SELECT 한 번 + UPDATE 한 번 = 왕복 두 번. 행 수와 무관해야 한다.
|
||||
assert len(sqls) == 2, sqls
|
||||
assert sqls[0].startswith("SELECT id, chainage_m, data")
|
||||
assert _written(connection) == {
|
||||
11: {"design": {"a": 1}, "summary": "keep"},
|
||||
12: {"design": {"b": 2}},
|
||||
13: {"design": {"c": 3}},
|
||||
}
|
||||
|
||||
|
||||
def test_patch_는_기존_design_을_보존한다():
|
||||
connection = FakeConnection(_rows())
|
||||
asyncio.run(
|
||||
merge_cross_section_designs(
|
||||
connection,
|
||||
route_id=7,
|
||||
entries=[(0.0, {"cut_area_m2": 1.5}), (40.0, {"cut_area_m2": 2.5})],
|
||||
replace=False,
|
||||
)
|
||||
)
|
||||
written = _written(connection)
|
||||
# replace=False 는 키만 얹는다 — 옛 design 과 형제 키(summary)가 남아야 한다.
|
||||
assert written[11] == {
|
||||
"design": {"ground_type": "soil", "cut_area_m2": 1.5},
|
||||
"summary": "keep",
|
||||
}
|
||||
assert written[13] == {"design": {"paved": True, "cut_area_m2": 2.5}}
|
||||
|
||||
|
||||
def test_측점_허용오차는_1cm():
|
||||
connection = FakeConnection(_rows())
|
||||
asyncio.run(
|
||||
merge_cross_section_designs(
|
||||
connection, route_id=7, entries=[(20.005, {"x": 1})], replace=True
|
||||
)
|
||||
)
|
||||
assert list(_written(connection)) == [12]
|
||||
|
||||
far = FakeConnection(_rows())
|
||||
asyncio.run(
|
||||
merge_cross_section_designs(far, route_id=7, entries=[(20.5, {"x": 1})], replace=True)
|
||||
)
|
||||
# 1cm 밖이면 붙일 행이 없다 — project_id 가 없으므로 아무 것도 안 쓴다.
|
||||
assert not [sql for sql, _ in far.cursor_obj.calls if sql.startswith("UPDATE")]
|
||||
|
||||
|
||||
def test_행이_없으면_project_id_로_새로_만든다():
|
||||
connection = FakeConnection(_rows())
|
||||
written = asyncio.run(
|
||||
merge_cross_section_designs(
|
||||
connection,
|
||||
route_id=7,
|
||||
entries=[(999.0, {"x": 1})],
|
||||
replace=True,
|
||||
project_id="11111111-2222-3333-4444-555555555555",
|
||||
)
|
||||
)
|
||||
assert written == 1
|
||||
inserts = [sql for sql, _ in connection.cursor_obj.calls if sql.startswith("INSERT")]
|
||||
assert len(inserts) == 1
|
||||
|
||||
|
||||
def test_빈_목록은_왕복을_내지_않는다():
|
||||
connection = FakeConnection(_rows())
|
||||
assert (
|
||||
asyncio.run(merge_cross_section_designs(connection, route_id=7, entries=[], replace=True))
|
||||
== 0
|
||||
)
|
||||
assert connection.cursor_obj.calls == []
|
||||
@@ -0,0 +1,355 @@
|
||||
"""횡단 설계선·단면적 거울 테스트 — 파이썬 엔진과 TS 짝이 **같은 값**을 내는지 대조한다.
|
||||
|
||||
짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`)
|
||||
↔ `common_util/common_util_cross_design.ts` (`computeCrossDesign`)
|
||||
(단면적은 그 안에서 `..._Engine_Areas.py` ↔ `..._cross_design_areas.ts` 를 탄다)
|
||||
|
||||
**계산 자리 규칙**(CLAUDE.md 5장)이 「초기값은 서버, 조작 중은 브라우저, 코드는 한 벌」이라
|
||||
이 짝은 반드시 같은 값을 내야 한다. 한쪽만 고치면 **화면에 보이는 수량과 저장되는 수량이
|
||||
갈린다** — 그런데 이 짝에는 거울 테스트가 없었다(2026-09-07 신설).
|
||||
|
||||
표준단면 수치는 파이썬 config 를 그대로 TS 에 넘긴다 — 출처가 같아야 기본값 차이가 아니라
|
||||
**산식 차이**만 잡힌다(배수관 세트 거울 테스트와 같은 방식).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
|
||||
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
# 값이 소수점 아래에서 갈리는지 보려면 자릿수를 그대로 비교해야 한다. 두 쪽 다 반올림해
|
||||
# 내보내므로 완전 일치를 기대하되, 부동소수 마지막 자리만 어긋나는 것은 허용한다.
|
||||
_TOLERANCE = 1e-9
|
||||
|
||||
|
||||
def _ground(slope: float, *, gap: tuple[float, float] | None = None) -> list[dict]:
|
||||
"""가로 -12m ~ +12m 를 0.5m 간격으로 훑은 지반선. `gap` 구간은 결측으로 둔다."""
|
||||
samples = []
|
||||
offset = -12.0
|
||||
while offset <= 12.0001:
|
||||
invalid = gap is not None and gap[0] <= offset <= gap[1]
|
||||
samples.append(
|
||||
{
|
||||
"offset_m": round(offset, 3),
|
||||
"elevation_m": None if invalid else round(100.0 + slope * offset, 4),
|
||||
"valid": not invalid,
|
||||
}
|
||||
)
|
||||
offset += 0.5
|
||||
return samples
|
||||
|
||||
|
||||
# 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교
|
||||
# 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간.
|
||||
CASES = [
|
||||
{
|
||||
"name": "토사 좌절토 일반측구",
|
||||
"samples": _ground(0.35),
|
||||
"design_elevation_m": 100.4,
|
||||
"options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"},
|
||||
},
|
||||
{
|
||||
"name": "리핑암 우절토 L형측구 2단경사",
|
||||
"samples": _ground(-0.42),
|
||||
"design_elevation_m": 99.6,
|
||||
"options": {
|
||||
"ground_type": "ripping_rock",
|
||||
"section_mode": "right_cut",
|
||||
"ditch_side": "right",
|
||||
"ditch_type": "l_type",
|
||||
"rock_boundary_offset_m": -0.8,
|
||||
"two_stage_slope": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "발파암 2단경사 해제",
|
||||
"samples": _ground(0.55),
|
||||
"design_elevation_m": 101.2,
|
||||
"options": {
|
||||
"ground_type": "blasting_rock",
|
||||
"section_mode": "left_cut",
|
||||
"ditch_side": "left",
|
||||
"rock_boundary_offset_m": -1.4,
|
||||
"two_stage_slope": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "포장 + 측구 없음",
|
||||
"samples": _ground(0.12),
|
||||
"design_elevation_m": 100.05,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "left_cut",
|
||||
"ditch_side": "left",
|
||||
"paved": True,
|
||||
"ditch_enabled": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "세월교 노면 하강",
|
||||
"samples": _ground(0.2),
|
||||
"design_elevation_m": 100.6,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "right_cut",
|
||||
"ditch_side": "right",
|
||||
"surface_drop_m": 0.45,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "곡선부 확폭 — 바깥쪽 좌",
|
||||
"samples": _ground(0.28),
|
||||
"design_elevation_m": 100.3,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "left_cut",
|
||||
"ditch_side": "left",
|
||||
"plan_radius_m": 18.0,
|
||||
"curve_outer_side": "left",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "곡선부 확폭 — 저장된 확폭량 우",
|
||||
"samples": _ground(-0.3),
|
||||
"design_elevation_m": 100.9,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "right_cut",
|
||||
"ditch_side": "right",
|
||||
"plan_radius_m": 25.0,
|
||||
"curve_outer_side": "right",
|
||||
"curve_widening_m": 0.62,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "지반선 결측 구간(사면이 안 닫힐 수 있음)",
|
||||
"samples": _ground(0.9, gap=(6.0, 12.0)),
|
||||
"design_elevation_m": 100.0,
|
||||
"options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"},
|
||||
},
|
||||
{
|
||||
# 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 1:0.4 를 1:1.0 으로 눕힘.
|
||||
# 무릎 아래(암)만 바뀌고 위(토사)는 표준 그대로라, 두 쪽이 같은 지점에서 꺾여야 한다.
|
||||
"name": "암 절토 경사를 측점에서 바꿈",
|
||||
"samples": _ground(-0.5),
|
||||
"design_elevation_m": 99.8,
|
||||
"options": {
|
||||
"ground_type": "ripping_rock",
|
||||
"section_mode": "left_cut",
|
||||
"ditch_side": "left",
|
||||
"rock_boundary_offset_m": -1.0,
|
||||
"two_stage_slope": True,
|
||||
"cut_slope_ratio": 1.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
# 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토(2026-09-09 사용자 확정).
|
||||
# ⚠ 그 바깥 성토는 노선 몫이 아니라 사토장 몫이라 `fill_area_m2` 에서 빠진다.
|
||||
"name": "사토장이 선 측점",
|
||||
"samples": _ground(-0.5),
|
||||
"design_elevation_m": 99.8,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "left_cut",
|
||||
"ditch_side": "left",
|
||||
"spoil_fill": {"side": "left", "width_m": 4.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "사토장 — 기울기를 따로 준 측점",
|
||||
"samples": _ground(-0.5),
|
||||
"design_elevation_m": 99.8,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "left_cut",
|
||||
"ditch_side": "left",
|
||||
"spoil_fill": {"side": "left", "width_m": 8.0, "slope_ratio_n": 2.0},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
_TS_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeCrossDesign } from "./common_util_cross_design.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const camel = {
|
||||
ground_type: "groundType",
|
||||
section_mode: "sectionMode",
|
||||
ditch_side: "ditchSide",
|
||||
ditch_type: "ditchType",
|
||||
paved: "paved",
|
||||
rock_boundary_offset_m: "rockBoundaryOffsetM",
|
||||
two_stage_slope: "twoStageSlope",
|
||||
cut_slope_ratio: "cutSlopeRatio",
|
||||
ditch_enabled: "ditchEnabled",
|
||||
surface_drop_m: "surfaceDropM",
|
||||
plan_radius_m: "planRadiusM",
|
||||
curve_outer_side: "curveOuterSide",
|
||||
curve_widening_m: "curveWideningM",
|
||||
};
|
||||
// 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다.
|
||||
const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" };
|
||||
const results = input.cases.map((item) => {
|
||||
const options = { standard: input.standard };
|
||||
for (const [key, value] of Object.entries(item.options)) {
|
||||
if (key === "spoil_fill") {
|
||||
const spoil = {};
|
||||
for (const [k, v] of Object.entries(value)) spoil[spoilCamel[k]] = v;
|
||||
options.spoilFill = spoil;
|
||||
continue;
|
||||
}
|
||||
options[camel[key]] = value;
|
||||
}
|
||||
return computeCrossDesign(item.samples, item.design_elevation_m, options);
|
||||
});
|
||||
writeFileSync(process.argv[3], JSON.stringify(results));
|
||||
"""
|
||||
|
||||
|
||||
def _python_results() -> list[dict]:
|
||||
results = []
|
||||
for case in CASES:
|
||||
results.append(
|
||||
compute_cross_design(
|
||||
case["samples"],
|
||||
case["design_elevation_m"],
|
||||
standard=STANDARD_CROSS_SECTION,
|
||||
**case["options"],
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _ts_results(tmp_path: Path) -> list[dict]:
|
||||
"""TS 짝을 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다."""
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_cross_design.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# tsc 가 낸 상대 import 에는 확장자가 없어 Node ESM 이 못 읽는다(번들러가 붙여 주던 몫).
|
||||
# 짝 파일이 서로를 부르므로 여기서 `.js` 를 채워 준다.
|
||||
for emitted in out.glob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_TS_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"samples": case["samples"],
|
||||
"design_elevation_m": case["design_elevation_m"],
|
||||
"options": case["options"],
|
||||
}
|
||||
for case in CASES
|
||||
],
|
||||
"standard": STANDARD_CROSS_SECTION,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _diff(left, right, path: str = "") -> list[str]:
|
||||
"""두 값이 갈린 자리를 경로와 함께 모은다 — 어느 항목이 틀렸는지 바로 보이게."""
|
||||
if isinstance(left, dict) and isinstance(right, dict):
|
||||
problems = []
|
||||
for key in sorted(set(left) | set(right)):
|
||||
if key not in left:
|
||||
problems.append(f"{path}.{key}: 파이썬에 없음(TS={right[key]!r})")
|
||||
elif key not in right:
|
||||
problems.append(f"{path}.{key}: TS 에 없음(파이썬={left[key]!r})")
|
||||
else:
|
||||
problems.extend(_diff(left[key], right[key], f"{path}.{key}"))
|
||||
return problems
|
||||
if isinstance(left, list) and isinstance(right, list):
|
||||
if len(left) != len(right):
|
||||
return [f"{path}: 길이 {len(left)} vs {len(right)}"]
|
||||
problems = []
|
||||
for index, (one, other) in enumerate(zip(left, right, strict=True)):
|
||||
problems.extend(_diff(one, other, f"{path}[{index}]"))
|
||||
return problems
|
||||
if isinstance(left, bool) or isinstance(right, bool):
|
||||
return [] if left == right else [f"{path}: {left!r} vs {right!r}"]
|
||||
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
|
||||
if abs(float(left) - float(right)) <= _TOLERANCE:
|
||||
return []
|
||||
return [f"{path}: {left!r} vs {right!r}"]
|
||||
return [] if left == right else [f"{path}: {left!r} vs {right!r}"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_횡단_설계선과_단면적이_두_쪽에서_같다(tmp_path: Path) -> None:
|
||||
expected = _python_results()
|
||||
actual = _ts_results(tmp_path)
|
||||
assert len(actual) == len(expected)
|
||||
problems: list[str] = []
|
||||
for case, left, right in zip(CASES, expected, actual, strict=True):
|
||||
problems.extend(_diff(left, right, case["name"]))
|
||||
assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
|
||||
|
||||
|
||||
def test_사토장_갈래가_빈_값으로_통과하지_않는다() -> None:
|
||||
"""사토장 측점이 0 ㎡ 면 대조가 아무것도 안 잠근다 — 값이 실제로 서는지 본다."""
|
||||
spoil = [
|
||||
result
|
||||
for case, result in zip(CASES, _python_results(), strict=True)
|
||||
if case["name"].startswith("사토장")
|
||||
]
|
||||
assert spoil, "사토장 사례가 있어야 한다"
|
||||
for result in spoil:
|
||||
assert result["spoil_fill_area_m2"] > 0
|
||||
assert result["spoil_fill_replaced_fill_m2"] > 0
|
||||
assert len(result["spoil_fill_line"]) >= 3
|
||||
# 기울기를 눕히고 넓히면 더 담긴다.
|
||||
assert spoil[1]["spoil_fill_area_m2"] > spoil[0]["spoil_fill_area_m2"]
|
||||
|
||||
|
||||
def test_시험이_빈_결과로_통과하지_않는다() -> None:
|
||||
"""면적이 실제로 나오는 경우가 섞여 있어야 대조가 뜻을 가진다."""
|
||||
results = _python_results()
|
||||
assert len(results) == len(CASES)
|
||||
assert sum(1 for item in results if item["cut_area_m2"] > 0) >= 4
|
||||
assert sum(1 for item in results if item["fill_area_m2"] > 0) >= 1
|
||||
assert any(item.get("widening_left_m") or item.get("widening_right_m") for item in results)
|
||||
assert any(item["cut_rock_area_m2"] > 0 for item in results)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""B06 배수관 세트(배관·기슭막이·보호공) 엔진 검증.
|
||||
|
||||
정본(`pipe_points.json`) + 레지스트리 기본값 조합이 계획서(3-0 근거 표)대로
|
||||
나오는지, 배관 외 시설(BOX암거 등)이 이번 범위에서 빠지는지 확인한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
|
||||
APRON_LENGTH_FACTOR,
|
||||
APRON_THICKNESS_M,
|
||||
MIN_PIPE_COVER_M,
|
||||
REVET_FACE_SLOPE,
|
||||
_culvert_set,
|
||||
attach_culvert_sets,
|
||||
pipe_points_file,
|
||||
)
|
||||
|
||||
|
||||
def test_cross_reference_constants_match_plan():
|
||||
"""계획서 3-0 표 값 — 토피 0.5m·보호공 길이 2배·두께 0.45m(사용자 확정)·전면 1:0.3."""
|
||||
assert MIN_PIPE_COVER_M == 0.5
|
||||
assert APRON_LENGTH_FACTOR == 2.0
|
||||
assert APRON_THICKNESS_M == 0.45
|
||||
assert REVET_FACE_SLOPE == 0.3
|
||||
# 배관은 수평도 가능 — 세트에 경사 강제값을 싣지 않는다(2026-08-20 사용자 확정).
|
||||
assert "min_slope" not in _culvert_set(None)
|
||||
|
||||
|
||||
def test_empty_options_fall_back_to_registry_defaults():
|
||||
"""옵션이 빈 저장분은 레지스트리 기본값(Ø1000·기슭막이 H2.5/L10)으로 채운다."""
|
||||
spec = _culvert_set(None)
|
||||
assert spec["type"] == "pipe"
|
||||
assert spec["diameter_m"] == 1.0
|
||||
assert spec["min_cover_m"] == MIN_PIPE_COVER_M
|
||||
for side in ("inlet", "outlet"):
|
||||
part = spec[side]
|
||||
assert part["structure"] == "기슭막이"
|
||||
assert part["revet_height_m"] == 2.5
|
||||
assert part["revet_length_m"] == 10
|
||||
assert part["face_slope"] == REVET_FACE_SLOPE
|
||||
# 보호공 = 낙차고(2.5) × 2 = 5.0m, 두께 0.45m(사용자 확정).
|
||||
assert part["apron_length_m"] == 5.0
|
||||
assert part["apron_thickness_m"] == 0.45
|
||||
# 레지스트리 기본 형태: 유입 찰 / 유출 메.
|
||||
assert spec["inlet"]["revet_form"] == "돌쌓기(찰)"
|
||||
assert spec["outlet"]["revet_form"] == "돌쌓기(메)"
|
||||
|
||||
|
||||
def test_basin_inlet_skips_revet_and_apron():
|
||||
"""유입구 = 집수정이면 그쪽 기슭막이·보호공을 만들지 않는다(라벨만)."""
|
||||
spec = _culvert_set({"inlet_type": "집수정"})
|
||||
assert spec["inlet"]["structure"] == "집수정"
|
||||
assert "revet_height_m" not in spec["inlet"]
|
||||
assert "apron_length_m" not in spec["inlet"]
|
||||
# 유출측은 그대로 세트를 갖춘다.
|
||||
assert spec["outlet"]["structure"] == "기슭막이"
|
||||
assert spec["outlet"]["apron_length_m"] == 5.0
|
||||
|
||||
|
||||
def test_string_diameter_and_custom_height():
|
||||
"""관경 문자열 저장분("800")과 사용자 높이 변경이 그대로 반영된다."""
|
||||
spec = _culvert_set({"pipe_diameter_mm": "800", "outlet_revet_height_m": 1.5})
|
||||
assert spec["diameter_m"] == 0.8
|
||||
assert spec["outlet"]["revet_height_m"] == 1.5
|
||||
assert spec["outlet"]["apron_length_m"] == 3.0 # 1.5 × 2
|
||||
|
||||
|
||||
def test_attach_only_pipe_facilities(tmp_path):
|
||||
"""배관은 `culvert`, 세월교는 `ford`, BOX암거는 `box` 키로 얹는다.
|
||||
|
||||
배관 매칭은 chainage ±0.02m 그대로다. 세월교·BOX암거는 구체 폭만큼 이어져
|
||||
기준 측점 전후 절반까지 붙는다(2026-08-25 사용자 확정) — 아래 100.0m은
|
||||
어느 쪽 범위에도 들지 않는다.
|
||||
"""
|
||||
target = pipe_points_file(tmp_path)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"route_signature": "x",
|
||||
"points": [
|
||||
{
|
||||
"chainage_m": 84.3,
|
||||
"source": "spacing",
|
||||
"options": {"pipe_diameter_mm": 1000},
|
||||
},
|
||||
{"chainage_m": 149.73, "source": "stream", "facility": "ford_bridge"},
|
||||
{"chainage_m": 200.92, "source": "spacing", "facility": "box_culvert"},
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cross_sections = [
|
||||
{"chainage_m": 84.3, "samples": []},
|
||||
{"chainage_m": 149.73, "samples": []},
|
||||
{"chainage_m": 200.92, "samples": []},
|
||||
{"chainage_m": 100.0, "samples": []},
|
||||
]
|
||||
attached = attach_culvert_sets(tmp_path, cross_sections)
|
||||
assert attached == 3
|
||||
assert "culvert" in cross_sections[0]
|
||||
assert cross_sections[0]["culvert"]["diameter_m"] == 1.0
|
||||
# 세월교·BOX암거는 그림이 달라 키를 나눈다 — 배수관 소비처가 집어가면 안 된다.
|
||||
assert cross_sections[1]["ford"]["type"] == "ford"
|
||||
assert cross_sections[2]["box"]["type"] == "box"
|
||||
assert all("culvert" not in section for section in cross_sections[1:])
|
||||
assert all("ford" not in section for section in (cross_sections[0], *cross_sections[2:]))
|
||||
|
||||
|
||||
def test_attach_without_file_is_noop(tmp_path):
|
||||
"""정본 파일이 없으면 아무 것도 얹지 않고 0을 돌려준다."""
|
||||
sections = [{"chainage_m": 10.0}]
|
||||
assert attach_culvert_sets(tmp_path, sections) == 0
|
||||
assert "culvert" not in sections[0]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""배수관 세트 거울 테스트 — 파이썬 엔진과 TS 짝이 **같은 값**을 내는지 대조한다.
|
||||
|
||||
짝: `B06_Section/B06_Section_Engine_Culvert.py` ↔ `common_util/common_util_culvert_sets.ts`
|
||||
한쪽만 고치면 화면과 저장본이 갈리므로, 다섯 시설을 모두 태워 딕셔너리째 비교한다.
|
||||
|
||||
레지스트리 기본값은 파이썬이 읽은 것을 그대로 TS 에 넘긴다 — 출처가 같아야 기본값
|
||||
차이가 아니라 **산식 차이**만 잡힌다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
|
||||
attach_culvert_sets,
|
||||
pipe_points_file,
|
||||
)
|
||||
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
# 다섯 시설 × 기본값 폴백/명시값 섞기. 물넘이포장은 폭 절반이 옆 측점까지 걸친다.
|
||||
POINTS = [
|
||||
{"chainage_m": 20.0, "facility": "pipe", "options": None},
|
||||
{
|
||||
"chainage_m": 40.0,
|
||||
"facility": "pipe",
|
||||
"options": {
|
||||
"pipe_diameter_mm": "800",
|
||||
"pipe_kind": "흄관",
|
||||
"inlet_type": "집수정",
|
||||
"inlet_basin_length_m": 2.5,
|
||||
"outlet_revet_height_m": 1.8,
|
||||
"outlet_revet_length_m": 7.5,
|
||||
"outlet_revet_form": "돌쌓기",
|
||||
},
|
||||
},
|
||||
{
|
||||
"chainage_m": 60.0,
|
||||
"facility": "ford_bridge",
|
||||
"options": {
|
||||
"ford_width_m": 12.0,
|
||||
"ford_height_m": 0.4,
|
||||
"pipe_count": 2,
|
||||
"wing_in": "있음",
|
||||
"wing_in_length_m": 3.0,
|
||||
"wing_in_angle_deg": 30.0,
|
||||
"wing_out": "없음",
|
||||
},
|
||||
},
|
||||
{
|
||||
"chainage_m": 80.0,
|
||||
"facility": "box_culvert",
|
||||
"options": {"body_width_m": 3.0, "body_height_m": 2.5, "wing_out_length_m": 2.0},
|
||||
},
|
||||
{
|
||||
"chainage_m": 100.0,
|
||||
"facility": "ford_pavement",
|
||||
"options": {"ford_width_m": 9.0, "ford_height_m": 0.25, "ford_slope_pct": 3.0},
|
||||
},
|
||||
{
|
||||
"chainage_m": 120.0,
|
||||
"facility": "revetment",
|
||||
"options": {
|
||||
"side": "좌",
|
||||
"tiers": 2,
|
||||
"inlet_revet_height_m": 1.2,
|
||||
"outlet_revet_length_m": 6.0,
|
||||
"form": "메쌓기",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# 물넘이포장(폭 9m)이 ±4.5m 까지 걸치는지 보려고 95·105 측점을 함께 둔다.
|
||||
CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 95.0, 100.0, 105.0, 120.0, 140.0]
|
||||
|
||||
_TS_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { attachCulvertSets, buildCulvertSets } from "./common_util_culvert_sets.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const sets = buildCulvertSets(input.points, input.registry);
|
||||
const sections = input.chainages.map((chainage) => ({ chainage_m: chainage }));
|
||||
attachCulvertSets(sections, sets);
|
||||
writeFileSync(process.argv[3], JSON.stringify(sections));
|
||||
"""
|
||||
|
||||
|
||||
def _python_sections() -> list[dict]:
|
||||
"""파이썬 엔진이 얹은 결과 — 정본 파일을 거쳐 공개 경로로 부른다."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="culvert_mirror_") as workdir:
|
||||
root = Path(workdir)
|
||||
target = pipe_points_file(root)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps({"points": POINTS}, ensure_ascii=False), encoding="utf-8")
|
||||
sections = [{"chainage_m": value} for value in CHAINAGES]
|
||||
attach_culvert_sets(root, sections)
|
||||
return sections
|
||||
|
||||
|
||||
def _registry() -> dict:
|
||||
types = structure_type_map()
|
||||
table = {}
|
||||
for type_id in ("pipe", "ford_bridge", "ford_pavement", "box_culvert"):
|
||||
entry = types.get(type_id)
|
||||
table[type_id] = {option.key: option.default for option in entry.options} if entry else {}
|
||||
return table
|
||||
|
||||
|
||||
def _ts_sections(tmp_path: Path) -> list[dict]:
|
||||
"""TS 짝을 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다."""
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_culvert_sets.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_TS_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(
|
||||
json.dumps({"points": POINTS, "registry": _registry(), "chainages": CHAINAGES}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_culvert_sets_match_between_python_and_ts(tmp_path: Path) -> None:
|
||||
expected = _python_sections()
|
||||
actual = _ts_sections(tmp_path)
|
||||
assert len(actual) == len(expected)
|
||||
for index, (left, right) in enumerate(zip(expected, actual, strict=True)):
|
||||
assert left == right, f"{CHAINAGES[index]}m 측점 세트가 갈렸다"
|
||||
# 시설이 실제로 붙었는지 — 빈 결과끼리 같아서 통과하는 것을 막는다.
|
||||
assert sum(1 for item in expected if len(item) > 1) >= 6
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 곡선부 확폭 표 — 브라우저 짝이 파이썬과 같은 값을 내는지 확인(2026-09-06).
|
||||
기대값은 `test_b06_curve_widening.py` 의 TABLE_CASES 와 같아야 한다. */
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// TS 를 그대로 못 읽으므로 표·함수 부분만 떼어 평가한다(빌드 없이 도는 확인).
|
||||
const source = readFileSync("common_util/common_util_cross_design_geometry.ts", "utf8");
|
||||
const table = source.slice(
|
||||
source.indexOf("const CURVE_WIDENING_TABLE_M"),
|
||||
source.indexOf("/** 짝: `_SectionGeometry`"),
|
||||
);
|
||||
const js = table
|
||||
.replace(/: ReadonlyArray<readonly \[number, number, number\]>/, "")
|
||||
.replace(/export /g, "")
|
||||
.replace(/: number \| null \| undefined/g, "")
|
||||
.replace(/: number/g, "")
|
||||
.replace(/planRadiusM\)/g, "planRadiusM)");
|
||||
const { curveWideningM } = await import(
|
||||
`data:text/javascript,${encodeURIComponent(js + "\nexport { curveWideningM, CURVE_WIDENING_MAX_WIDTH_M };")}`
|
||||
);
|
||||
|
||||
const cases = [
|
||||
[9.9, 0], [10, 2.25], [12.999, 2.25], [13, 2], [14, 1.75], [15, 1.5],
|
||||
[18, 1.25], [20, 1], [25, 0.75], [30, 0.5], [40, 0.25], [45, 0], [200, 0],
|
||||
];
|
||||
for (const [radius, expected] of cases) {
|
||||
assert.equal(curveWideningM(radius), expected, `R=${radius}`);
|
||||
}
|
||||
assert.equal(curveWideningM(null), 0);
|
||||
assert.equal(curveWideningM(undefined), 0);
|
||||
console.log("확폭표 브라우저 짝 일치 —", cases.length + 2, "건 확인");
|
||||
@@ -0,0 +1,150 @@
|
||||
"""곡선부 확폭 — 표·경계·편측 적용·상한을 확인한다(2026-09-06).
|
||||
|
||||
파이썬과 브라우저가 한 세트이므로 같은 기대값을 `test_b06_curve_widening.mjs` 도 쓴다.
|
||||
값이 갈리면 두 화면이 다른 단면을 그린다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import _plan_radii # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
from config.config_system_design import curve_widening_m # noqa: E402
|
||||
|
||||
# (반경 m, 기대 확폭 m) — 별표2 Ⅰ.2.나.(4). 경계는 "이상 ~ 미만".
|
||||
TABLE_CASES = [
|
||||
(9.9, 0.0),
|
||||
(10.0, 2.25),
|
||||
(12.999, 2.25),
|
||||
(13.0, 2.0),
|
||||
(14.0, 1.75),
|
||||
(15.0, 1.5),
|
||||
(18.0, 1.25),
|
||||
(20.0, 1.0),
|
||||
(25.0, 0.75),
|
||||
(30.0, 0.5),
|
||||
(40.0, 0.25),
|
||||
(45.0, 0.0),
|
||||
(200.0, 0.0),
|
||||
]
|
||||
|
||||
|
||||
def _flat_samples() -> list[dict]:
|
||||
return [{"offset_m": float(o), "elevation_m": 100.0, "valid": True} for o in range(-25, 26)]
|
||||
|
||||
|
||||
def test_widening_table_boundaries() -> None:
|
||||
for radius, expected in TABLE_CASES:
|
||||
assert curve_widening_m(radius) == expected, radius
|
||||
assert curve_widening_m(None) == 0.0
|
||||
|
||||
|
||||
def test_widening_applies_to_outer_side_only() -> None:
|
||||
samples = _flat_samples()
|
||||
base = compute_cross_design(samples, 100.0, ground_type="soil", section_mode="both_fill")
|
||||
right = compute_cross_design(
|
||||
samples,
|
||||
100.0,
|
||||
ground_type="soil",
|
||||
section_mode="both_fill",
|
||||
plan_radius_m=16.0,
|
||||
curve_outer_side="right",
|
||||
)
|
||||
left = compute_cross_design(
|
||||
samples,
|
||||
100.0,
|
||||
ground_type="soil",
|
||||
section_mode="both_fill",
|
||||
plan_radius_m=16.0,
|
||||
curve_outer_side="left",
|
||||
)
|
||||
assert base["widening_left_m"] == 0.0 and base["widening_right_m"] == 0.0
|
||||
assert right["widening_right_m"] == 1.5 and right["widening_left_m"] == 0.0
|
||||
assert left["widening_left_m"] == 1.5 and left["widening_right_m"] == 0.0
|
||||
# 확폭은 붙은 쪽 차도 끝만 밖으로 민다.
|
||||
assert right["carriageway_edges"]["left"] == base["carriageway_edges"]["left"]
|
||||
assert right["carriageway_edges"]["right"]["offset_m"] < base["carriageway_edges"]["right"]["offset_m"]
|
||||
assert right["carriageway_width_m"] == base["carriageway_width_m"] + 1.5
|
||||
|
||||
|
||||
def test_widening_capped_by_legal_max_width() -> None:
|
||||
"""확폭을 더한 유효너비는 5m 를 넘지 않는다(별표2 — 최대 5미터까지)."""
|
||||
samples = _flat_samples()
|
||||
design = compute_cross_design(
|
||||
samples,
|
||||
100.0,
|
||||
ground_type="soil",
|
||||
section_mode="both_fill",
|
||||
plan_radius_m=11.0, # 표값 2.25m
|
||||
curve_outer_side="right",
|
||||
)
|
||||
assert design["carriageway_width_m"] <= 5.0 + 1e-9
|
||||
# 규격 3.0m 이면 2.0m 까지만 붙는다.
|
||||
assert design["widening_right_m"] == 2.0
|
||||
|
||||
|
||||
def test_plan_radius_and_outer_side_from_polyline() -> None:
|
||||
"""반지름 50m 원호에서 반경 50m 가 나오고, 좌회전이면 바깥은 우측이다."""
|
||||
angles = np.linspace(0.0, np.pi / 2, 400)
|
||||
radius = 50.0
|
||||
left_turn = np.column_stack([radius * np.cos(angles), radius * np.sin(angles)])
|
||||
chainage = np.r_[
|
||||
0.0, np.cumsum(np.hypot(np.diff(left_turn[:, 0]), np.diff(left_turn[:, 1])))
|
||||
]
|
||||
radii, sides = _plan_radii(left_turn, chainage, np.array([40.0]), float(chainage[-1]))
|
||||
assert abs(radii[0] - radius) < 0.1
|
||||
assert sides[0] == "right"
|
||||
|
||||
right_turn = np.column_stack([radius * np.cos(-angles), radius * np.sin(-angles)])
|
||||
chainage2 = np.r_[
|
||||
0.0, np.cumsum(np.hypot(np.diff(right_turn[:, 0]), np.diff(right_turn[:, 1])))
|
||||
]
|
||||
_, sides2 = _plan_radii(right_turn, chainage2, np.array([40.0]), float(chainage2[-1]))
|
||||
assert sides2[0] == "left"
|
||||
|
||||
|
||||
def test_straight_route_has_no_radius() -> None:
|
||||
line = np.array([[0.0, 0.0], [300.0, 0.0]])
|
||||
radii, sides = _plan_radii(line, np.array([0.0, 300.0]), np.array([50.0, 150.0]), 300.0)
|
||||
assert radii == [None, None]
|
||||
assert sides == [None, None]
|
||||
|
||||
|
||||
def test_widening_taper_runs_before_and_after_curve() -> None:
|
||||
"""곡선 앞뒤 10m 안 측점은 확폭이 0 → W 로 이어진다(2026-09-06)."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import _curve_widenings
|
||||
|
||||
# 5m 간격 측점: 20~30m 구간만 곡선(R=16 → 1.5m), 나머지는 직선.
|
||||
chainage = np.arange(0.0, 55.0, 5.0)
|
||||
radii: list[float | None] = [None] * len(chainage)
|
||||
sides: list[str | None] = [None] * len(chainage)
|
||||
for index, value in enumerate(chainage):
|
||||
if 20.0 <= value <= 30.0:
|
||||
radii[index] = 16.0
|
||||
sides[index] = "right"
|
||||
widenings, out_sides = _curve_widenings(chainage, radii, sides)
|
||||
by_chainage = dict(zip(chainage.tolist(), widenings, strict=True))
|
||||
# 곡선 안은 표값 그대로.
|
||||
assert by_chainage[20.0] == 1.5 and by_chainage[25.0] == 1.5 and by_chainage[30.0] == 1.5
|
||||
# 앞뒤 10m 는 선형으로 줄어든다 — 5m 지점에서 절반.
|
||||
assert by_chainage[15.0] == 0.75 and by_chainage[35.0] == 0.75
|
||||
assert by_chainage[10.0] == 0.0 and by_chainage[40.0] == 0.0
|
||||
# 테이퍼 측점도 확폭이 붙는 쪽을 물려받는다.
|
||||
assert out_sides[chainage.tolist().index(15.0)] == "right"
|
||||
# 테이퍼 밖은 방향이 없다.
|
||||
assert out_sides[chainage.tolist().index(5.0)] is None
|
||||
|
||||
|
||||
def test_widening_without_curve_stays_zero() -> None:
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import _curve_widenings
|
||||
|
||||
chainage = np.arange(0.0, 60.0, 20.0)
|
||||
widenings, sides = _curve_widenings(chainage, [None] * 3, [None] * 3)
|
||||
assert widenings == [0.0, 0.0, 0.0]
|
||||
assert sides == [None, None, None]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""절토 경사 — **법정 기울기 판정을 두지 않는다**(2026-09-07 사용자 확정으로 폐기).
|
||||
|
||||
원래 이 시험은 별표2(경암 1:0.3~0.8 …) 범위 밖이면 카드에 경고를 띄우는 기능을 지켰다.
|
||||
그 기능은 폐기됐다. 사용자 원문:
|
||||
|
||||
「연암과 경암 / 발파암과 리핑암 선택은 횡단도에서 선택 안함. 사유는 향후 설계내역에서
|
||||
설계자가 직접 비율로 지정하기로 함. 암반 지정과 범위 애매모호한 경우가 있어.
|
||||
실무자는 그렇게 하기로 판단함. 대신 경고부분은 삭제해주고 대신 각도를 사용자가
|
||||
넣을수 있게 반영.」
|
||||
|
||||
왜 폐기인가 — **암반 지정·범위가 실무에서 애매하다.** 측점마다 암질(연암·경암)을 못 박아야
|
||||
판정이 서는데, 그 못 박음 자체가 틀린 전제다. 암 비율은 **설계내역 단계에서 설계자가
|
||||
비율로** 넣는다. 판정 기준이 없으니 경고도 없다.
|
||||
|
||||
그래서 이 파일은 **되살아나지 않게 막는 시험**으로 남긴다. 하루 사이에
|
||||
「굴착 공법으로 암질 유추」 → 「암질 선택 버튼」 → 「아예 안 고름」으로 좁혀진 건이라,
|
||||
누가 「왜 만들다 말았나」로 다시 꺼내면 여기서 걸린다.
|
||||
|
||||
⚠ `cut_slope_segments`(구간별 경사)는 **지우지 않는다** — 소단 기하가 그 위에 서 있고
|
||||
구간 경사를 아는 값이라 뒤에 쓸 자리가 있다(2026-09-07 계획 창 지시).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
_B06 = PROJECT_ROOT / "B06_Section"
|
||||
_CONFIG = PROJECT_ROOT / "config" / "config_system_design.py"
|
||||
_CHROME = _B06 / "B06_Section_UI_Cross_Card_Chrome.ts"
|
||||
_PANEL = _B06 / "B06_Section_UI_Standard_Panel.ts"
|
||||
|
||||
|
||||
def _sources() -> dict[str, str]:
|
||||
files = [_CONFIG, _CHROME, _PANEL, _B06 / "B06_Section_Schema.py", _B06 / "B06_Section_Router.py"]
|
||||
return {path.name: path.read_text(encoding="utf-8") for path in files}
|
||||
|
||||
|
||||
def test_법정_기울기_검사가_없다():
|
||||
"""판정 모듈 자체가 없어야 한다 — 파일이 되살아나면 여기서 걸린다."""
|
||||
assert not (_B06 / "B06_Section_Cut_Slope_Check.ts").exists(), "폐기한 판정 모듈이 되살아났음"
|
||||
|
||||
|
||||
def test_카드에_절토_경사_경고가_없다():
|
||||
"""카드 경고는 성토사면 길이·미폐합만 남는다."""
|
||||
chrome = _CHROME.read_text(encoding="utf-8")
|
||||
assert "violationsOf" not in chrome, "폐기한 경고 배지가 카드에 다시 붙었음"
|
||||
|
||||
|
||||
def test_암질을_고르는_자리가_없다():
|
||||
"""암질(연암·경암)은 횡단도에서 고르지 않는다 — 설계내역에서 비율로 넣는다."""
|
||||
for name, source in _sources().items():
|
||||
assert "rock_quality" not in source, f"{name} 에 암질 선택이 남아 있음"
|
||||
assert "soft_rock" not in source, f"{name} 에 별표2 암질 구분이 남아 있음"
|
||||
|
||||
|
||||
def test_구간별_경사값은_남긴다():
|
||||
"""⚠ 판정은 지웠지만 `cut_slope_segments` 는 지우지 않는다 — 소단이 그 위에 선다."""
|
||||
geometry = (PROJECT_ROOT / "common_util" / "common_util_cross_design_geometry.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
engine = (_B06 / "B06_Section_Engine_Design.py").read_text(encoding="utf-8")
|
||||
assert "cutSlopeSegments" in geometry, "구간별 경사 계산이 사라졌음"
|
||||
assert "cut_slope_segments" in engine, "구간별 경사가 서버 결과에서 사라졌음"
|
||||
@@ -0,0 +1,150 @@
|
||||
"""절토 경사를 **사용자가 직접 넣는** 길이 끊기지 않는지 (2026-09-07 사용자 지시).
|
||||
|
||||
사용자 원문 — 「대신 경고부분은 삭제해주고 대신 각도를 사용자가 넣을수 있게 반영. 전체
|
||||
공통으로 변경하는 경우에는 기본값 지정으로 하면 되지만 횡단도 하나만 변경하는 폼은 가져야함」
|
||||
및 「개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함 / 전체 변경을 위해서는 좌측 패널의
|
||||
표준 횡단면 설정을 이용. (표준 횡단면 설정으로 변경시 사용자가 기본값을 사용하지 않는 값들은
|
||||
변경되면 안됨.)」
|
||||
|
||||
⚠ **경사는 「나르는 값」이 아니라 「기하 입력」이다.** 계산이 끝난 결과에 키만 베껴 붙이면
|
||||
설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다 — 소단에서 겪은 자리와 같다.
|
||||
그래서 여기서는 ① 값이 실제로 **계산에 들어가** 절토 면적이 달라지는지 ② 부르는 자리마다
|
||||
계산 **전에** 넘어가는지를 함께 지킨다.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
from B06_Section.B06_Section_Router_Design import ( # noqa: E402
|
||||
USER_TOUCHED_KEYS,
|
||||
stored_cut_slope,
|
||||
)
|
||||
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
|
||||
|
||||
_B06 = PROJECT_ROOT / "B06_Section"
|
||||
_REFRESH = _B06 / "B06_Section_Cross_Refresh.ts"
|
||||
_CARD = _B06 / "B06_Section_UI_Cross_CutSlope.ts"
|
||||
_CHROME = _B06 / "B06_Section_UI_Cross_Card_Chrome.ts"
|
||||
_ROUTER_DESIGN = _B06 / "B06_Section_Router_Design.py"
|
||||
|
||||
|
||||
def _ground(slope: float) -> list[dict]:
|
||||
"""가로 -12~+12m 를 0.5m 간격으로 훑은 비탈 지반선."""
|
||||
samples = []
|
||||
offset = -12.0
|
||||
while offset <= 12.0001:
|
||||
samples.append(
|
||||
{
|
||||
"offset_m": round(offset, 3),
|
||||
"elevation_m": round(100.0 + slope * offset, 4),
|
||||
"valid": True,
|
||||
}
|
||||
)
|
||||
offset += 0.5
|
||||
return samples
|
||||
|
||||
|
||||
def _design(**options):
|
||||
return compute_cross_design(
|
||||
_ground(-0.5),
|
||||
99.8,
|
||||
ground_type="ripping_rock",
|
||||
section_mode="left_cut",
|
||||
ditch_side="left",
|
||||
standard=STANDARD_CROSS_SECTION,
|
||||
rock_boundary_offset_m=-1.0,
|
||||
**options,
|
||||
)
|
||||
|
||||
|
||||
def test_넣은_경사가_실제로_그려진다():
|
||||
"""표준 1:0.4 → 사용자 1:1.0. 경사비도 절토 면적도 함께 달라져야 한다."""
|
||||
base = _design()
|
||||
changed = _design(cut_slope_ratio=1.0)
|
||||
assert base["cut_slope_ratio"] == 0.4, "표준 절토 경사가 바뀐 듯 — 전제 확인 필요"
|
||||
assert changed["cut_slope_ratio"] == 1.0, "넣은 경사가 설계에 안 들어감"
|
||||
# 사면이 눕는 만큼 더 파므로 절토 면적이 커진다 — 숫자가 그대로면 그림만 바뀐 것이다.
|
||||
assert changed["cut_area_m2"] > base["cut_area_m2"], "경사를 눕혔는데 절토량이 그대로임"
|
||||
|
||||
|
||||
def test_무릎_위_토사_경사는_그대로다():
|
||||
"""사용자가 넣는 것은 **암 절토각**이다 — 무릎 위(토사)는 표준값 그대로 간다."""
|
||||
changed = _design(cut_slope_ratio=1.0, two_stage_slope=True)
|
||||
assert changed["soil_cut_slope_ratio"] == STANDARD_CROSS_SECTION["soil"]["cut_slope_ratio"]
|
||||
|
||||
|
||||
def test_0_은_표준값으로_되돌림이다():
|
||||
"""되돌리기(↺)는 세션에서 지우는 게 아니라 **0 을 남긴다** — 지우면 정본의 옛 값이
|
||||
되살아나 표준으로 못 돌아간다."""
|
||||
assert stored_cut_slope({"cut_slope_ratio_user": 0}) is None
|
||||
assert stored_cut_slope({"cut_slope_ratio_user": 0.8}) == 0.8
|
||||
assert stored_cut_slope({}) is None
|
||||
|
||||
|
||||
def test_다시_계산해도_사용자_값이_남는다():
|
||||
"""표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 (사용자 원문 끝줄)."""
|
||||
assert "cut_slope_ratio_user" in USER_TOUCHED_KEYS
|
||||
assert '"cut_slope_ratio_user"' in _REFRESH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_계산_전에_넘어간다():
|
||||
"""⚠ 이 시험이 이 건의 요지다 — 값을 계산 **인자**로 넘기는지.
|
||||
|
||||
재계산 경로(포장 강제·세월교 하강·선형 재계산)가 모두 인자로 받아야 한다. 한 곳이라도
|
||||
빠지면 그 경로에서만 옛 경사로 그려지고 값만 새것이 된다.
|
||||
"""
|
||||
router = _ROUTER_DESIGN.read_text(encoding="utf-8")
|
||||
assert router.count("cut_slope_ratio=") >= 3, "재계산 경로 중 값을 안 넘기는 곳이 있음"
|
||||
refresh = _REFRESH.read_text(encoding="utf-8")
|
||||
assert "cutSlopeRatio: cutSlopeAt(" in refresh, "브라우저 재계산이 값을 안 넘김"
|
||||
# 계산이 끝난 뒤 베껴 붙이는 꼴이면 안 된다.
|
||||
assert not re.search(r"design\.cut_slope_ratio\s*=", refresh), "계산 뒤에 값만 덮어쓰고 있음"
|
||||
|
||||
|
||||
def test_각도와_경사비가_서로_바뀐다():
|
||||
"""화면은 각도(°), 속은 경사비(1:n). 1:0.4 = 68.2° · 1:1.0 = 45°."""
|
||||
source = _CARD.read_text(encoding="utf-8")
|
||||
assert "ratioToDegrees" in source and "degreesToRatio" in source
|
||||
# 45°는 1:1 — 두 함수가 서로의 역이어야 한다(코드에 그 식이 있는지).
|
||||
assert "Math.atan(1 / Math.max(ratio, 1e-6))" in source
|
||||
assert "1 / Math.tan((clamped * Math.PI) / 180)" in source
|
||||
|
||||
|
||||
def test_칸은_암_측점에만_선다():
|
||||
"""토사 측점은 암반이 없어 암 절토각이 쓰이지 않는다(2026-09-07 사용자 확정)."""
|
||||
chrome = _CHROME.read_text(encoding="utf-8")
|
||||
assert 'cutSlope && section.design?.geometry_preset === "rock"' in chrome
|
||||
|
||||
|
||||
# ── 지반유형 버튼 정리 (2026-09-07 사용자 확정) ────────────────────────────────
|
||||
# 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만 존재하고 이값은
|
||||
# 활성화/비활성화로 반영(기본값은 비활성화)」 — 켜면 토사, 끄면 암이다.
|
||||
_DESIGN_UI = _B06 / "B06_Section_UI_Cross_Design.ts"
|
||||
|
||||
|
||||
def test_지반유형은_토사_토글_하나다():
|
||||
source = _DESIGN_UI.read_text(encoding="utf-8")
|
||||
assert "GROUND_OPTIONS" not in source, "지반유형 버튼 셋이 남아 있음"
|
||||
assert "groundToggle" in source, "토사 토글이 없음"
|
||||
# 저장값은 종전 그대로 — 끔 = ripping_rock, 켬 = soil.
|
||||
assert 'GROUND_ROCK_DEFAULT: GroundType = "ripping_rock"' in source
|
||||
|
||||
|
||||
def test_기본은_암이다():
|
||||
"""사용자 원문 「기본값은 비활성화」 — 설계가 없는 측점도 암으로 선다."""
|
||||
source = _DESIGN_UI.read_text(encoding="utf-8")
|
||||
assert "design?.ground_type ?? GROUND_ROCK_DEFAULT" in source
|
||||
|
||||
|
||||
def test_옛_발파암_자료도_암으로_읽힌다():
|
||||
"""버튼은 없앴지만 저장분에 남은 `blasting_rock` 은 그대로 암 기하로 계산된다."""
|
||||
from config.config_system_design import SECTION_GROUND_TYPE_PRESET
|
||||
|
||||
assert SECTION_GROUND_TYPE_PRESET["blasting_rock"] == "rock"
|
||||
assert SECTION_GROUND_TYPE_PRESET["ripping_rock"] == "rock"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""사용자 값이 재계산에서 사라지지 않는지 — **키 이름으로** 지킨다.
|
||||
|
||||
왜 (2026-09-07 전수 조사) — 횡단 `design` 딕셔너리는 사용자 값과 계산 값이 한 칸에 섞여
|
||||
있고, 재계산(`enforce_pavement_ranges`·`enforce_ford_surface_drops`)은 결과를 통째로
|
||||
갈아 끼운다. 그래서 사용자 값이 살아남는 길은 **둘뿐**이다:
|
||||
|
||||
① 재계산에 **인자로 되먹여** 결과에 그대로 나오는 것
|
||||
(`ground_type`·`section_mode`·`ditch_side`·`ditch_type`·`paved`·
|
||||
`two_stage_slope`·`rock_boundary_offset_m`)
|
||||
② 계산이 만들지 않아 **목록으로 베껴 넣는** 것 (`USER_TOUCHED_KEYS`)
|
||||
|
||||
어느 쪽에도 안 걸린 사용자 값은 **저장할 때 조용히 사라진다**. 실제로 그렇게 사라졌던
|
||||
것이 `extra_spans` 였다(`b6941bd2`). 이 시험은 그 구멍을 다시 못 나게 못박는다 —
|
||||
새 사용자 값을 `CrossSectionPatch` 에 더하면서 ①·② 어느 쪽에도 안 넣으면 여기서 깨진다.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS # noqa: E402
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
|
||||
|
||||
#: 측점을 가리키는 열쇠 — 값이 아니다.
|
||||
_KEY_FIELDS = {"chainage_m"}
|
||||
|
||||
#: 사용자 값이 아니라 **계산 값**이라 재계산이 다시 내는 것이 옳은 필드.
|
||||
#: 브라우저가 조작 중 값을 함께 보내지만 정본은 서버가 다시 낸다(CLAUDE.md 5장).
|
||||
_COMPUTED_FIELDS = {
|
||||
"cut_area_m2",
|
||||
"fill_area_m2",
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
# 배수관 연장(m, 2026-09-08) — 사용자 값이 아니라 **기하가 낸 값**이다. 면적 넷과 **같은
|
||||
# 다리**(`STRUCTURE_ROW_KEYS` → `B06_Section_Server_Calc_Node` →
|
||||
# `recompute_server_side`)로 서버가 다시 내므로 재계산에서 안 사라진다.
|
||||
"pipe_length_m",
|
||||
}
|
||||
|
||||
|
||||
def _recompute_inputs() -> set[str]:
|
||||
"""재계산에 되먹이는 인자 이름 — ① 갈래."""
|
||||
return set(inspect.signature(compute_cross_design).parameters)
|
||||
|
||||
|
||||
def test_사용자_값은_되먹임이거나_보존목록이다():
|
||||
fed = _recompute_inputs()
|
||||
kept = set(USER_TOUCHED_KEYS)
|
||||
missing = [
|
||||
name
|
||||
for name in CrossSectionPatch.model_fields
|
||||
if name not in _KEY_FIELDS
|
||||
and name not in _COMPUTED_FIELDS
|
||||
and name not in fed
|
||||
and name not in kept
|
||||
]
|
||||
assert not missing, (
|
||||
"재계산에서 사라질 사용자 값: "
|
||||
+ ", ".join(missing)
|
||||
+ " — 재계산 인자로 넣거나 USER_TOUCHED_KEYS 에 더할 것"
|
||||
)
|
||||
|
||||
|
||||
def test_보존목록에_계산값이_섞이지_않았다():
|
||||
"""반대 방향 — 계산 값을 베껴 두면 옛 값이 새 계산을 덮는다."""
|
||||
overlap = sorted(set(USER_TOUCHED_KEYS) & _COMPUTED_FIELDS)
|
||||
assert not overlap, f"계산 값이 보존 목록에 있음: {overlap}"
|
||||
|
||||
|
||||
def test_되먹임_갈래가_실제로_여덟_가지():
|
||||
"""①이 줄면(인자에서 빠지면) 그 값은 조용히 기본값으로 되돌아간다 — 수를 못박는다."""
|
||||
fed = _recompute_inputs()
|
||||
expected = {
|
||||
"ground_type",
|
||||
"section_mode",
|
||||
"ditch_side",
|
||||
"ditch_type",
|
||||
"paved",
|
||||
"two_stage_slope",
|
||||
"ditch_enabled",
|
||||
"rock_boundary_offset_m",
|
||||
}
|
||||
assert expected <= fed, f"재계산 인자에서 빠진 것: {sorted(expected - fed)}"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""상세 제원(뒷길이·돌규격·형식)을 **B06 에서 받는지** — 2026-09-08 데스크탑 창 보고.
|
||||
|
||||
무슨 일이었나 — 레지스트리는 그 칸들을 `phase: "detail"` 로 두고 「B05 는 유무·종류·위치만,
|
||||
상세 치수는 B06/B07 에서」(2026-08-17 사용자 확정)로 갈라 놓았는데, **그 화면이 아직 안 받고
|
||||
있었다.** 그래서 실무 프로젝트의 구조물 옵션에 `back_len_cm`·`stone_cm`·`form` 이 통째로 비어
|
||||
있었고, **수량이 품셈 갈래를 못 골라** 금액이 0 으로 남았다(13-4 돌쌓기 뒷길이 · 13-6 큰돌쌓기
|
||||
직경 · 12-3 옹벽 형식).
|
||||
|
||||
고침 — 폼이 `includeDetail` 을 받아 **B06 에서만** 상세 칸까지 그린다. B05 는 종전 그대로다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types # noqa: E402
|
||||
|
||||
_PANEL = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts"
|
||||
_B06 = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Panel.ts"
|
||||
_B05 = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts"
|
||||
|
||||
|
||||
def _type(type_id: str):
|
||||
return next(item for item in load_structure_types() if item.type_id == type_id)
|
||||
|
||||
|
||||
def test_B06_은_상세_칸까지_받는다():
|
||||
assert "includeDetail: true" in _B06.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_B05_는_종전대로_유무_종류_위치만():
|
||||
"""2026-08-17 사용자 확정 — B05 폼에 상세 칸이 생기면 안 된다."""
|
||||
assert "includeDetail" not in _B05.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_기본값_없는_필수_항목은_빈_칸으로_연다():
|
||||
"""첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다."""
|
||||
panel = _PANEL.read_text(encoding="utf-8")
|
||||
assert 'choices.unshift(["", "— 선택 —"])' in panel
|
||||
assert 'option.required === true && (option.default ?? "") === ""' in panel
|
||||
|
||||
|
||||
def test_큰돌쌓기에_메_찰_구분이_있다():
|
||||
"""품셈이 13-6-1 메쌓기 / 13-6-2 찰쌓기로 갈라 두는데 구분 칸이 없어 못 골랐다
|
||||
(2026-09-08 데스크탑 창 요청). 돌쌓기는 이미 타입이 갈려 있다(찰/메)."""
|
||||
bond = next(o for o in _type("boulder_masonry").options if o.key == "bond")
|
||||
assert bond.choices == ["메쌓기", "찰쌓기"]
|
||||
assert bond.default is None, "기본값을 두면 안 된다 — 설계자가 고를 값이다"
|
||||
assert bond.required is True
|
||||
assert bond.phase == "detail", "상세 제원이라 B06/B07 에서 받는다"
|
||||
|
||||
|
||||
def test_규격_축이_타입마다_다르다():
|
||||
"""⚠ 큰돌쌓기는 **직경**(13-6), 돌쌓기는 **뒷길이**(13-4) — 섞으면 엉뚱한 계수로 돈다
|
||||
(2026-09-08 데스크탑 창이 실제로 그 결함을 잡았다)."""
|
||||
stone = next(o for o in _type("boulder_masonry").options if o.key == "stone_cm")
|
||||
back = next(o for o in _type("masonry_wet").options if o.key == "back_len_cm")
|
||||
assert stone.choices == ["40~60", "60~80", "80~100"], "큰돌쌓기 = 직경 축"
|
||||
assert "35" in back.choices and "75" in back.choices, "돌쌓기 = 뒷길이 축"
|
||||
assert set(stone.choices).isdisjoint(set(back.choices))
|
||||
@@ -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
|
||||
@@ -0,0 +1,110 @@
|
||||
"""측구터파기 단면적을 토사/암으로 가르는 규칙 (2026-09-09).
|
||||
|
||||
별표2 Ⅰ.1.나.(5) 「측구터파기 단면적」이 횡단도 표의 법정 칸이라 반만 채워 나가면 안 된다.
|
||||
⚠ **새 입력을 만들지 않는다** — 절토 분리와 같은 근거(지반 유형 + 암반 경계선)를 쓰고,
|
||||
근거가 없으면 **나누지 않고 사유를 낸다**. 절반을 임의로 가르는 것이 가장 나쁘다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Areas import _split_ditch_area # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
|
||||
_STANDARD = {"type": "standard", "top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}
|
||||
_L_TYPE = {"type": "l_type", "width_m": 0.6, "depth_m": 0.3}
|
||||
_TOTAL_STANDARD = (0.9 + 0.3) / 2 * 0.3
|
||||
|
||||
|
||||
def test_경계선이_바닥보다_깊으면_전량_토사() -> None:
|
||||
soil, rock = _split_ditch_area(_STANDARD, 5.0)
|
||||
assert soil == pytest.approx(_TOTAL_STANDARD)
|
||||
assert rock == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_경계선이_상단_위면_전량_암() -> None:
|
||||
soil, rock = _split_ditch_area(_STANDARD, -1.0)
|
||||
assert soil == pytest.approx(0.0)
|
||||
assert rock == pytest.approx(_TOTAL_STANDARD)
|
||||
|
||||
|
||||
def test_중간이면_사다리꼴을_가로로_가른다() -> None:
|
||||
"""깊이 절반 지점 — 위쪽이 넓어 토사분이 절반보다 크다."""
|
||||
soil, rock = _split_ditch_area(_STANDARD, 0.15)
|
||||
assert soil + rock == pytest.approx(_TOTAL_STANDARD)
|
||||
assert soil > _TOTAL_STANDARD / 2 # 위가 넓다
|
||||
# 폭 W(d) = 0.9 − 2·d 를 0~0.15 적분: 0.9·0.15 − 2·0.15²/2
|
||||
assert soil == pytest.approx(0.9 * 0.15 - (0.9 - 0.3) * 0.15**2 / (2 * 0.3))
|
||||
|
||||
|
||||
def test_L형도_합이_보존된다() -> None:
|
||||
total = 0.6 * 0.3 / 2
|
||||
for depth in (0.0, 0.1, 0.2, 0.3, 1.0):
|
||||
soil, rock = _split_ditch_area(_L_TYPE, depth)
|
||||
assert soil + rock == pytest.approx(total), depth
|
||||
|
||||
|
||||
def test_측구가_없으면_둘_다_0() -> None:
|
||||
assert _split_ditch_area({"type": "none"}, 0.2) == (0.0, 0.0)
|
||||
|
||||
|
||||
def _ground(slope: float = 0.5) -> list[dict]:
|
||||
return [
|
||||
{"offset_m": offset / 2.0, "elevation_m": 100.0 - (offset / 2.0) * slope}
|
||||
for offset in range(-24, 25)
|
||||
]
|
||||
|
||||
|
||||
def test_토사_지반은_전량_토사이고_사유가_남는다() -> None:
|
||||
result = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="left_cut")
|
||||
assert result["ditch_split_basis"] in {"soil_ground", "no_ditch"}
|
||||
assert result["ditch_rock_area_m2"] == 0.0
|
||||
assert result["ditch_soil_area_m2"] + result["ditch_rock_area_m2"] == pytest.approx(
|
||||
result["ditch_area_m2"]
|
||||
)
|
||||
|
||||
|
||||
def test_암_지반에_경계선이_없으면_전량_암() -> None:
|
||||
result = compute_cross_design(
|
||||
_ground(),
|
||||
100.0,
|
||||
ground_type="ripping_rock",
|
||||
section_mode="left_cut",
|
||||
rock_boundary_offset_m=None,
|
||||
ditch_enabled=True,
|
||||
)
|
||||
if result["ditch_split_basis"] == "no_ditch":
|
||||
pytest.skip("이 표본에는 측구가 안 선다")
|
||||
assert result["ditch_split_basis"] == "rock_ground_no_boundary"
|
||||
assert result["ditch_soil_area_m2"] == 0.0
|
||||
|
||||
|
||||
def test_암_지반에_경계선이_있으면_그것으로_가른다() -> None:
|
||||
result = compute_cross_design(
|
||||
_ground(),
|
||||
100.0,
|
||||
ground_type="ripping_rock",
|
||||
section_mode="left_cut",
|
||||
rock_boundary_offset_m=0.5,
|
||||
ditch_enabled=True,
|
||||
)
|
||||
if result["ditch_split_basis"] == "no_ditch":
|
||||
pytest.skip("이 표본에는 측구가 안 선다")
|
||||
assert result["ditch_split_basis"] == "rock_boundary"
|
||||
assert result["ditch_soil_area_m2"] + result["ditch_rock_area_m2"] == pytest.approx(
|
||||
result["ditch_area_m2"]
|
||||
)
|
||||
|
||||
|
||||
def test_합계_키는_그대로_남는다() -> None:
|
||||
"""B08 이 읽는 `ditch_area_m2` 를 안 깨뜨린다 — 갈래는 덧붙인 것이다."""
|
||||
result = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="left_cut")
|
||||
assert "ditch_area_m2" in result
|
||||
@@ -0,0 +1,120 @@
|
||||
"""추가(다단) 기슭막이의 **좌우 이동 하한**이 화면에 알려지는지.
|
||||
|
||||
왜 (2026-09-07 원인 확정) — 「좌우 이동 1.0m 를 넣어도 벽이 안 움직인다」는 보고가
|
||||
다섯 측점 중 넷에서 났다. 지형이 막은 것이 아니었다. 선반 길이가 음수가 되지 않도록
|
||||
좌우 이동은 **1.2 × 상하 내림** 아래로 못 내려가는데(`shelfFloor`), 실제 저장값이
|
||||
d 2.6m 이면 하한이 3.12m 라 1.0m 요청은 애초에 아무 변화도 못 낸다. 그런데도
|
||||
「지형에 막힘」 수치(`shiftBlockedM`)는 0 으로 나와 까닭을 알 길이 없었다.
|
||||
|
||||
이 시험은 그 하한이 실제로 걸리는지와, 걸렸을 때 `shiftFloorM` 으로 알리는지를 지킨다.
|
||||
실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(배수관 세트 거울 시험과 같은 방식).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
MODULE = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Extra.ts"
|
||||
|
||||
# 상하 내림 2.0m → 하한 2.4m. 좌우 0.5m 를 요청해도 2.4m 로 밀려 나가야 한다.
|
||||
REQUESTED_X = 0.5
|
||||
REQUESTED_D = 2.0
|
||||
EXPECTED_FLOOR = 2.4
|
||||
|
||||
_RUNNER = """
|
||||
const { buildOutletExtras } = require(process.argv[3]);
|
||||
const { writeFileSync } = require("node:fs");
|
||||
|
||||
// 계류측으로 1:2 로 내려가는 민민한 원지반 — 성토선(1:1.2)보다 완만해 지형이 막을 일이 없다.
|
||||
// 시작점은 원지반보다 5m 위 — 그래야 다단이 설 자리(성토부)가 생긴다.
|
||||
const groundAt = (offset) => 95 - offset / 2;
|
||||
|
||||
const result = buildOutletExtras({
|
||||
start: { offset: 0, elevation: 100 },
|
||||
startBottomElevation: 100,
|
||||
outward: 1,
|
||||
groundAt,
|
||||
limitOffset: 60,
|
||||
adjusts: [{ x: %(x)s, d: %(d)s, h: null, m: null }],
|
||||
});
|
||||
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
count: result.walls.length,
|
||||
appliedX: result.appliedAdjusts[0]?.x ?? null,
|
||||
floor: result.walls[0]?.shiftFloorM ?? null,
|
||||
blocked: result.walls[0]?.shiftBlockedM ?? null,
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _run(tmp_path: Path, requested_x: float = REQUESTED_X) -> dict:
|
||||
out = tmp_path / "out"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
"--ignoreConfig",
|
||||
"--target",
|
||||
"es2022",
|
||||
# 프로젝트 소스는 확장자 없는 상대 경로를 쓴다(번들러 기준) — CommonJS 로 옮겨야
|
||||
# Node 가 그대로 찾아 준다. 러너도 `.cjs` 라 `type` 설정과 무관하게 CJS 로 돈다.
|
||||
"--module",
|
||||
"commonjs",
|
||||
"--skipLibCheck",
|
||||
"--outDir",
|
||||
str(out),
|
||||
str(MODULE),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
# 딸려 오는 화면 모듈에 별칭 경로(`@config/…`)가 있어 형 검사는 실패한다 —
|
||||
# 그래도 JS 는 나온다. 형 검사는 `npm run typecheck` 몫이고, 여기서는 **돌려 보는** 것이 목적.
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
compiled = next(out.rglob("B06_Section_UI_Cross_Culvert_Extra.js"), None)
|
||||
assert compiled is not None, "화면 코드가 JS 로 안 나옴 — tsc 실패"
|
||||
(out / "runner.cjs").write_text(
|
||||
_RUNNER % {"x": requested_x, "d": REQUESTED_D}, encoding="utf-8"
|
||||
)
|
||||
result = tmp_path / "result.json"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(out / "runner.cjs"), str(result), str(compiled)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_좌우_이동은_상하_내림의_1_2배까지_따라_나간다(tmp_path: Path) -> None:
|
||||
produced = _run(tmp_path)
|
||||
assert produced["count"] >= 1, "시험용 자리에서 단이 하나도 안 섰음"
|
||||
assert produced["appliedX"] == pytest.approx(EXPECTED_FLOOR, abs=0.05), (
|
||||
f"요청 {REQUESTED_X}m 가 하한 {EXPECTED_FLOOR}m 로 안 밀림 — {produced['appliedX']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_하한에_눌린_사실을_알린다(tmp_path: Path) -> None:
|
||||
"""요청이 통째로 무시됐으면 그 까닭이 남아야 한다 — 화면이 툴팁으로 읽는다."""
|
||||
produced = _run(tmp_path)
|
||||
assert produced["floor"] == pytest.approx(EXPECTED_FLOOR, abs=0.05)
|
||||
# 지형이 막은 것이 아니므로 「지형에 막힘」은 뜨면 안 된다 — 두 까닭이 섞이면 오해한다.
|
||||
assert not produced["blocked"], "지형 탓으로 잘못 알림"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_요청이_없으면_알리지_않는다(tmp_path: Path) -> None:
|
||||
"""기본 자리(좌우 0)에서는 하한이 걸려도 알릴 것이 없다 — 모든 벽에 뜨면 소리만 된다."""
|
||||
produced = _run(tmp_path, requested_x=0.0)
|
||||
assert produced["count"] >= 1
|
||||
assert not produced["floor"], "손대지 않은 벽에도 하한을 알림"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""B06 세월교 세트(백엔드) 자체검증 — 2026-08-25.
|
||||
|
||||
확인 대상:
|
||||
· `_ford_set()`이 정본·레지스트리 기본값으로 제원을 만든다.
|
||||
· 날개벽 각도가 바닥판 편측 연장(길이×cos각)을 만든다. 설치 "없음"이면 0.
|
||||
· `attach_culvert_sets()`가 세월교를 **소유 측점 한 곳에만** 붙이고,
|
||||
배수관은 종전대로 ±0.02m 측점 일치로만 붙인다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
|
||||
FORD_SLAB_THICKNESS_M,
|
||||
FORD_WALL_THICKNESS_M,
|
||||
_ford_set,
|
||||
attach_culvert_sets,
|
||||
)
|
||||
|
||||
|
||||
def test_ford_set_defaults() -> None:
|
||||
"""저장 옵션이 없으면 레지스트리 기본값(파형강관 Ø1000·월류 폭 10m)을 쓴다."""
|
||||
spec = _ford_set(None)
|
||||
assert spec["type"] == "ford"
|
||||
assert spec["pipe_kind"] == "파형강관"
|
||||
assert spec["diameter_m"] == 1.0
|
||||
assert spec["pipe_count"] == 1
|
||||
assert spec["span_m"] == 10.0
|
||||
assert spec["slab_thickness_m"] == FORD_SLAB_THICKNESS_M
|
||||
assert spec["wall_thickness_m"] == FORD_WALL_THICKNESS_M
|
||||
|
||||
|
||||
def test_wing_angle_drives_slab_extension() -> None:
|
||||
"""바닥판 편측 연장 = 날개벽 길이 × cos(각도). 45°·2m면 1.414m."""
|
||||
spec = _ford_set({"wing_in_length_m": 2, "wing_in_angle_deg": 45})
|
||||
assert spec["wing_in"]["slab_extend_m"] == round(2 * math.cos(math.radians(45)), 3)
|
||||
|
||||
steep = _ford_set({"wing_out_length_m": 2, "wing_out_angle_deg": 30})
|
||||
assert steep["wing_out"]["slab_extend_m"] == round(2 * math.cos(math.radians(30)), 3)
|
||||
|
||||
|
||||
def test_wing_absent_gives_no_extension() -> None:
|
||||
"""날개벽 설치 '없음'이면 연장 0 — 바닥판은 구체 폭만 남는다."""
|
||||
spec = _ford_set({"wing_in": "없음", "wing_in_length_m": 2, "wing_in_angle_deg": 45})
|
||||
assert spec["wing_in"]["installed"] is False
|
||||
assert spec["wing_in"]["slab_extend_m"] == 0.0
|
||||
|
||||
|
||||
def test_attach_ford_only_owner_station(tmp_path: Path) -> None:
|
||||
"""세월교도 배수관과 같이 **소유 측점 한 곳에만** 붙는다(2026-08-25 사용자 확정).
|
||||
|
||||
월류 폭 절반까지 옆 측점에 붙이면 그 측점 횡단도에 같은 구체가 한 벌 더 그려지고
|
||||
3D 솔리드도 어긋난 채 겹친다. 3D 스윕 길이는 소유 측점이 `span_m`으로 직접 낸다.
|
||||
"""
|
||||
pipe_points = tmp_path / "B04_PreProcess" / "drainage" / "edits"
|
||||
pipe_points.mkdir(parents=True)
|
||||
(pipe_points / "pipe_points.json").write_text(
|
||||
'{"points": ['
|
||||
'{"chainage_m": 100.0, "facility": "ford_bridge", "options": {"ford_width_m": 10}},'
|
||||
'{"chainage_m": 200.0, "facility": "pipe", "options": {}}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
sections = [{"chainage_m": value} for value in (94.0, 95.5, 100.0, 104.5, 106.0, 200.0, 201.0)]
|
||||
attached = attach_culvert_sets(tmp_path, sections)
|
||||
|
||||
kinds = [
|
||||
("ford" if "ford" in section else "culvert" if "culvert" in section else None)
|
||||
for section in sections
|
||||
]
|
||||
assert kinds == [None, None, "ford", None, None, "culvert", None]
|
||||
assert attached == 2
|
||||
@@ -0,0 +1,71 @@
|
||||
"""측점 없는 구조물 알림 — 조용히 빠지던 것을 드러낸다 (계획서 3-14 ㉯, 2026-09-09).
|
||||
|
||||
관을 나중에 놓거나 옮기면 그 측점이 안 생긴다 — 측점을 만드는 자리가 B05 노선 [확정]
|
||||
한 곳뿐이기 때문이다. 그 관은 횡단도에도 안 서고 수량·금액에서 통째로 빠진다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Router_Confirm import ( # noqa: E402
|
||||
load_sampling_snapshot,
|
||||
sampling_snapshot_path,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Stations import ( # noqa: E402
|
||||
STATION_MATCH_TOLERANCE_M,
|
||||
_station_chainages,
|
||||
router,
|
||||
)
|
||||
|
||||
|
||||
def test_두_길이_다_열려_있다() -> None:
|
||||
"""점검(GET)과 만들기(POST)가 같은 주소에 선다 — 화면이 하나만 알면 된다."""
|
||||
paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes}
|
||||
assert ("/api/projects/{project_id}/section/missing-stations", ("GET",)) in paths
|
||||
assert ("/api/projects/{project_id}/section/missing-stations", ("POST",)) in paths
|
||||
|
||||
|
||||
def test_종단_정본에서_측점을_읽는다(tmp_path: Path) -> None:
|
||||
folder = tmp_path / "B06_Section" / "longitudinal"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "longitudinal.json").write_text(
|
||||
json.dumps({"stations": [{"chainage_m": 0.0}, {"chainage_m": 85.05}, {"bad": 1}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _station_chainages(tmp_path) == [0.0, 85.05]
|
||||
|
||||
|
||||
def test_측점이_없으면_빈_목록(tmp_path: Path) -> None:
|
||||
"""파일이 없다고 터지지 않는다 — 이 줄은 덤이라 화면을 막으면 안 된다."""
|
||||
assert _station_chainages(tmp_path) == []
|
||||
|
||||
|
||||
def test_같은_자리_판정은_50cm() -> None:
|
||||
"""⚠ 스냅 때문이다 — 관 440.241 은 **측점 440.0** 위에 선다(2026-09-09 실측).
|
||||
|
||||
측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅하므로(횡단 파일명이
|
||||
정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드) 최대 어긋남이 0.5m 다.
|
||||
0.05m 로 보면 **있는 측점을 없다고 세어** 또 만들라고 한다.
|
||||
"""
|
||||
assert STATION_MATCH_TOLERANCE_M == 0.5
|
||||
|
||||
|
||||
def test_샘플링_조건이_없으면_None(tmp_path: Path) -> None:
|
||||
"""조건을 지어내지 않는다 — 그 측점만 다른 지표에서 뽑히면 지반고가 어긋난다."""
|
||||
assert load_sampling_snapshot(tmp_path) is None
|
||||
path = sampling_snapshot_path(tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"filter_key": "", "method": "dtm"}), encoding="utf-8")
|
||||
assert load_sampling_snapshot(tmp_path) is None
|
||||
path.write_text(
|
||||
json.dumps({"filter_key": "csf", "method": "tin", "smooth": True}), encoding="utf-8"
|
||||
)
|
||||
snapshot = load_sampling_snapshot(tmp_path)
|
||||
assert snapshot is not None and snapshot["method"] == "tin" and snapshot["smooth"] is True
|
||||
@@ -0,0 +1,145 @@
|
||||
"""포장 구간 정본 + 물넘이포장 횡단 스펙 — 2026-08-28 사용자 확정 스펙.
|
||||
|
||||
- 포장은 **사용자가 구간으로 지정**한다(구조물 정본 G군). 종단경사 자동 판정은 더 이상
|
||||
포장을 켜지 않고 경고(`pavement_suggested`)로만 남는다.
|
||||
- 물넘이포장은 노면을 월류 폭만큼 판 자리다 — 범위 안 모든 횡단도에 붙고, 그 범위는
|
||||
언제나 포장이다.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import (
|
||||
FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||
attach_culvert_sets,
|
||||
load_culvert_sets,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import pavement_ranges, paved_at
|
||||
|
||||
|
||||
def _write_pipe_points(root, points):
|
||||
edits = root / "B04_PreProcess" / "drainage" / "edits"
|
||||
edits.mkdir(parents=True, exist_ok=True)
|
||||
(edits / "pipe_points.json").write_text(
|
||||
json.dumps({"route_signature": "무관", "points": points}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_structures(root, structures):
|
||||
route_dir = root / "B05_Profile" / "route"
|
||||
route_dir.mkdir(parents=True, exist_ok=True)
|
||||
(route_dir / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
FORD = {
|
||||
"chainage_m": 100.0,
|
||||
"source": "user",
|
||||
"facility": "ford_pavement",
|
||||
"options": {"ford_width_m": 6.0, "ford_height_m": 0.4, "ford_slope_pct": 3.0},
|
||||
}
|
||||
|
||||
|
||||
# ── 물넘이 스펙 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ford_pavement_becomes_its_own_spec(tmp_path):
|
||||
_write_pipe_points(tmp_path, [FORD])
|
||||
(spec,) = load_culvert_sets(tmp_path).values()
|
||||
assert spec["type"] == "ford_pavement"
|
||||
assert spec["span_m"] == 6.0
|
||||
assert spec["depth_m"] == 0.4
|
||||
assert spec["slope_pct"] == 3.0
|
||||
|
||||
|
||||
def test_ford_pavement_without_values_keeps_default_width_and_no_depth(tmp_path):
|
||||
"""깊이는 지어내지 않는다 — 없으면 None이라 화면이 파임을 그리지 않는다."""
|
||||
_write_pipe_points(tmp_path, [{"chainage_m": 50.0, "facility": "ford_pavement"}])
|
||||
(spec,) = load_culvert_sets(tmp_path).values()
|
||||
assert spec["span_m"] == FORD_PAVEMENT_DEFAULT_WIDTH_M
|
||||
assert spec["depth_m"] is None
|
||||
assert spec["slope_pct"] is None
|
||||
|
||||
|
||||
def test_ford_pavement_attaches_to_every_section_in_span(tmp_path):
|
||||
"""월류 폭 6m → 기준측점 ±3m 안의 측점 전부에 붙는다(다른 시설은 소유 측점 한 곳)."""
|
||||
_write_pipe_points(tmp_path, [FORD])
|
||||
sections = [{"chainage_m": value} for value in (94.0, 97.5, 100.0, 102.5, 106.0)]
|
||||
attach_culvert_sets(tmp_path, sections)
|
||||
attached = [s["chainage_m"] for s in sections if "ford_pavement" in s]
|
||||
assert attached == [97.5, 100.0, 102.5]
|
||||
assert all("culvert" not in s and "ford" not in s for s in sections)
|
||||
|
||||
|
||||
# ── 포장 구간 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_pavement_range_comes_from_group_g_structure(tmp_path):
|
||||
_write_structures(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"chainage_m": 200.0,
|
||||
"start_m": 180.0,
|
||||
"end_m": 230.0,
|
||||
"placement": "interval",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert pavement_ranges(tmp_path) == [(180.0, 230.0)]
|
||||
|
||||
|
||||
def test_ford_pavement_range_is_always_paved(tmp_path):
|
||||
_write_pipe_points(tmp_path, [FORD])
|
||||
ranges = pavement_ranges(tmp_path)
|
||||
assert ranges == [(97.0, 103.0)]
|
||||
assert paved_at(100.0, ranges) is True
|
||||
assert paved_at(97.0, ranges) is True
|
||||
assert paved_at(96.9, ranges) is False
|
||||
|
||||
|
||||
def test_other_groups_are_not_pavement(tmp_path):
|
||||
_write_structures(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type_id": "revetment",
|
||||
"chainage_m": 100.0,
|
||||
"start_m": 100.0,
|
||||
"end_m": 115.0,
|
||||
"placement": "interval",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert pavement_ranges(tmp_path) == []
|
||||
|
||||
|
||||
def test_missing_truth_files_are_not_an_error(tmp_path):
|
||||
assert pavement_ranges(tmp_path) == []
|
||||
|
||||
|
||||
# ── 포장 판정 규칙 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_outside_range_keeps_user_value_and_defaults_unpaved():
|
||||
"""경사 제안은 더 이상 포장을 켜지 않는다 — 구간 밖은 사용자 값(없으면 비포장)."""
|
||||
ranges = [(10.0, 20.0)]
|
||||
assert paved_at(30.0, ranges) is False
|
||||
assert paved_at(30.0, ranges, True) is True
|
||||
assert paved_at(30.0, ranges, False) is False
|
||||
|
||||
|
||||
def test_inside_range_overrides_user_off_switch():
|
||||
"""구간 안은 강제 포장 — 물넘이는 콘크리트 노면이고 지정 구간은 자동 반영이다."""
|
||||
assert paved_at(15.0, [(10.0, 20.0)], False) is True
|
||||
|
||||
|
||||
# ── 독립 기슭막이 ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# 2026-08-28 이관으로 기슭막이는 구조물 정본 D군을 떠나 관 지점 시설이 됐다. 전용 엔진
|
||||
# (`B06_Section_Engine_Revetment`)은 호출자가 사라져 2026-09-01 삭제했다 — 벽은 관 세트
|
||||
# (`B06_Section_Engine_Culvert._revet_set`)가 만든다. 그쪽 검증은
|
||||
# `test_revet_span_source.py`에 있다.
|
||||
@@ -0,0 +1,107 @@
|
||||
"""배수관 연장(m)이 **정본까지 간다** — 2026-09-08 B08 창 요청.
|
||||
|
||||
무슨 일이었나 — 관 길이는 횡단 기하가 **m 단위 올림까지** 끝낸 값인데(`CulvertLayout.pipe.lengthM`)
|
||||
**정본(`design`)에 없었다.** 그래서 수량(B08)이 관 11개를 보고도 **연장을 못 내** 배수관 공종이
|
||||
막혀 있었다. B08 창은 「계산을 서버로 옮기든가 저장 때 남기든가」 둘로 갈래를 냈는데, 실제로는
|
||||
**그 계산이 이미 서버 Node 로 돌고 있었다**(`B06_Section_Server_Calc_Node` ←
|
||||
`Server_Calc_Prebuild.recompute_server_side`, [저장]·[확정] 때). 그래서 **그 다리에 값 한 줄만**
|
||||
더 실었다 — 계산을 두 벌로 짜지 않는다(CLAUDE.md 5장).
|
||||
|
||||
⚠ 이름은 `pipe_length_m` 로 **못 박았다**. 오늘 `back_len_cm` 을 엔진이 `stone_back_length_cm`
|
||||
로 읽어 **저장값이 영영 안 닿던** 사고가 있었다 — 키 이름이 어긋나면 값은 조용히 사라진다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
|
||||
|
||||
_LAYOUTS = PROJECT_ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts"
|
||||
_PERSIST = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts"
|
||||
_TYPES = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Types.ts"
|
||||
|
||||
|
||||
def test_패치로_받는다():
|
||||
patch = CrossSectionPatch(chainage_m=85.05, pipe_length_m=7.0)
|
||||
assert patch.pipe_length_m == 7.0
|
||||
assert CrossSectionPatch(chainage_m=0).pipe_length_m is None
|
||||
|
||||
|
||||
def test_음수는_막는다():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
CrossSectionPatch(chainage_m=0, pipe_length_m=-1)
|
||||
|
||||
|
||||
def test_같은_길로_정본에_실린다():
|
||||
"""구조물 면적이 실리는 그 목록에 함께 있어야 [저장]·[확정]·서버 초기값이 모두 나른다."""
|
||||
source = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "export const STRUCTURE_ROW_KEYS" in source
|
||||
block = source[source.index("STRUCTURE_ROW_KEYS = [") :][:300]
|
||||
assert '"pipe_length_m"' in block, "관 길이가 정본에 얹는 키 목록에 없다"
|
||||
assert '"cut_area_m2"' in block, "면적과 같은 목록이어야 한다(길이 하나만 따로 나르지 않는다)"
|
||||
assert "STRUCTURE_ROW_KEYS" in _PERSIST.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_면적을_못_내는_측점에서도_길이는_낸다():
|
||||
"""설계선이 모자라 폐회로 면적을 못 내는 자리에도 **관은 서 있다** — 수량은 그 길이가 필요하다."""
|
||||
source = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "return pipeRow;" in source, "면적 실패 시 null 로 떨어지면 관 길이까지 사라진다"
|
||||
assert "layouts.culvert?.pipe?.lengthM" in source
|
||||
|
||||
|
||||
def test_이름이_한_벌이다():
|
||||
"""`back_len_cm` ↔ `stone_back_length_cm` 사고 재발 방지 — 이름을 적는 자리가 갈리면 안 된다.
|
||||
|
||||
저장 흐름(`_PERSIST`)은 이름을 **직접 안 적고** 공용 목록(`STRUCTURE_ROW_KEYS`)을 돈다 —
|
||||
그것이 이름이 갈리지 않게 하는 방법이다. 그래서 여기서는 그 점을 지킨다.
|
||||
"""
|
||||
for path in (_LAYOUTS, _TYPES):
|
||||
assert "pipe_length_m" in path.read_text(encoding="utf-8"), path.name
|
||||
persist = _PERSIST.read_text(encoding="utf-8")
|
||||
assert "pipe_length_m" not in persist, "이름을 또 적으면 갈릴 자리가 하나 더 생긴다"
|
||||
assert "STRUCTURE_ROW_KEYS" in persist
|
||||
|
||||
|
||||
def test_서버_화이트리스트가_TS_목록과_짝이다():
|
||||
"""⚠ 서버에도 **받을 키 목록**이 따로 있다(`_AREA_KEYS`). 한쪽만 늘리면 Node 가 값을 내도
|
||||
거기서 조용히 버려진다 — 관 길이를 더하며 실제로 걸린 자리다(2026-09-08).
|
||||
"""
|
||||
import re
|
||||
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import _AREA_KEYS
|
||||
|
||||
block = _LAYOUTS.read_text(encoding="utf-8")
|
||||
block = block[block.index("STRUCTURE_ROW_KEYS = [") :]
|
||||
block = block[: block.index("] as const")]
|
||||
ts_keys = set(re.findall(r'"([a-z_0-9]+)"', block))
|
||||
assert ts_keys == set(_AREA_KEYS), (
|
||||
f"짝이 갈림 — TS {sorted(ts_keys)} vs 서버 {sorted(_AREA_KEYS)}"
|
||||
)
|
||||
|
||||
|
||||
def test_소유_측점에만_싣는다():
|
||||
"""⚠ 세트는 **폭의 절반까지 옆 측점에도** 붙는다(`attach_culvert_sets`). 그 자리에 길이를
|
||||
실으면 **같은 관을 두 번** 센다 — 실측에서 관 9개에 값이 10곳 실렸다(2026-09-08).
|
||||
|
||||
가리는 열쇠는 스펙에 함께 얹는 `chainage_m`(그 시설이 **놓인** 자리)이다. 파이썬·TS
|
||||
두 쪽이 같이 얹어야 한다 — 한쪽만 얹으면 세트 거울 시험이 깨진다.
|
||||
|
||||
⚠ **주인을 가리는 법이 2026-09-09 에 바뀌었다** — 거리(0.02m)로 자르던 것을
|
||||
**가장 가까운 측점 하나**로 바꿨다. 관 자리와 측점 자리가 **스냅 때문에 최대 0.5m
|
||||
어긋나기 때문**이다(관 440.241 → 측점 440.0). 거리로 자르면 그런 관은 주인이 없어
|
||||
길이가 아무 데도 안 실리고 B08 이 「연장 없음」으로 막는다(실측: 배수관 넷).
|
||||
**하나만 고르는 것**은 그대로라 두 번 세지 않는다.
|
||||
"""
|
||||
layouts = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다"
|
||||
assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다"
|
||||
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(encoding="utf-8")
|
||||
ts = (PROJECT_ROOT / "common_util" / "common_util_culvert_sets.ts").read_text(encoding="utf-8")
|
||||
assert '"chainage_m": pipe_chainage' in py
|
||||
assert "chainage_m: pipeChainage" in ts
|
||||
@@ -0,0 +1,109 @@
|
||||
"""포장 구간 재계산이 **사용자 값을 지우지 않는지**.
|
||||
|
||||
왜 (실사고 `b6941bd2`) — 포장 구간·세월교 측점은 저장 때 서버가 횡단을 **다시 계산해
|
||||
통째로 갈아 끼운다**. 계산이 만들지 않는 값(구조물 조정·다단 구간값 등)은 그 자리에서
|
||||
따로 베껴 넣어야 살아남는데, `extra_spans` 가 목록에서 빠져 조용히 사라졌다.
|
||||
|
||||
이 시험이 보는 것은 **베껴 넣는 그 자리**다. 지형·계획고는 시험 대상이 아니므로 가짜로
|
||||
바꿔 끼운다(그 둘은 각자 시험이 있다). 목록 자체가 갈리는 것은
|
||||
`test_b06_user_touched_keys.py`, 새 사용자 값이 목록에서 새는 것은
|
||||
`test_b06_design_key_split.py` 가 지킨다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402
|
||||
|
||||
USER_VALUES = {
|
||||
"display_half_width_m": 12.5,
|
||||
"inlet_structure": "L",
|
||||
"basin_adjust": {"innerWidthM": 1.5, "innerHeightM": 1.5, "lateralM": 0.5, "slopeM": 0.2},
|
||||
"revet_adjust": {"outlet": {"x": 2.0, "d": 0.5, "h": None, "m": None}},
|
||||
"ford_adjust": {"inlet": {"heightM": None, "lateralM": 1.0, "slopeM": 0.0}},
|
||||
"box_adjust": {"left": {"lengthM": 2.0, "riseM": 0.0}},
|
||||
"extra_wall_counts": {"outlet": 2, "basin": 0},
|
||||
"extra_spans": {"extra0": {"length_m": 15.0, "before_m": 8.0, "after_m": 7.0}},
|
||||
"revet_link_detached": True,
|
||||
"revet_follow_grade": False,
|
||||
}
|
||||
|
||||
#: 재계산 인자로 되먹여 살아남는 갈래 — 결과에 그대로 나와야 한다.
|
||||
FED_BACK = {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "right_cut",
|
||||
"ditch_side": "left",
|
||||
"ditch_type": "l_type",
|
||||
"two_stage_slope": False,
|
||||
"rock_boundary_offset_m": -1.5,
|
||||
}
|
||||
|
||||
|
||||
def _section() -> dict:
|
||||
design = {
|
||||
"paved": False,
|
||||
"status": "ok",
|
||||
"pavement_suggested": True,
|
||||
**USER_VALUES,
|
||||
**FED_BACK,
|
||||
}
|
||||
return {"chainage_m": 100.0, "samples": [], "design": design}
|
||||
|
||||
|
||||
def _install_fakes(monkeypatch) -> dict:
|
||||
"""지형·계획고·포장구간을 가짜로 — 이 시험의 대상은 「값을 베껴 넣는 자리」다."""
|
||||
seen: dict = {}
|
||||
|
||||
def fake_compute(samples, elevation, **kwargs):
|
||||
seen.update(kwargs)
|
||||
# 계산이 내는 값만 담은 새 설계 — 사용자 값은 하나도 없다(실제 엔진과 같다).
|
||||
return {
|
||||
"paved": True,
|
||||
"cut_area_m2": 1.0,
|
||||
"fill_area_m2": 2.0,
|
||||
**{
|
||||
key: kwargs[key]
|
||||
for key in (
|
||||
"ground_type",
|
||||
"section_mode",
|
||||
"ditch_side",
|
||||
"ditch_type",
|
||||
"two_stage_slope",
|
||||
"rock_boundary_offset_m",
|
||||
)
|
||||
if key in kwargs
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)])
|
||||
monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute)
|
||||
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
|
||||
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
|
||||
return seen
|
||||
|
||||
|
||||
def test_포장_재계산_뒤에도_사용자_값이_남는다(monkeypatch):
|
||||
_install_fakes(monkeypatch)
|
||||
sections = [_section()]
|
||||
changed = design_mod.enforce_pavement_ranges({}, sections, Path("."), None)
|
||||
assert changed == 1, "재계산이 아예 안 돌았음 — 시험 전제가 깨짐"
|
||||
result = sections[0]["design"]
|
||||
for key, value in USER_VALUES.items():
|
||||
assert result.get(key) == value, f"{key} 가 재계산에서 사라짐"
|
||||
assert result["status"] == "ok"
|
||||
assert result["pavement_suggested"] is True
|
||||
assert result["paved"] is True, "포장 구간인데 포장으로 안 바뀜"
|
||||
|
||||
|
||||
def test_되먹임_값이_인자로_실제로_들어간다(monkeypatch):
|
||||
"""①갈래는 「인자로 넣어」 지킨다 — 안 넣으면 기본값으로 되돌아간다."""
|
||||
seen = _install_fakes(monkeypatch)
|
||||
sections = [_section()]
|
||||
design_mod.enforce_pavement_ranges({}, sections, Path("."), None)
|
||||
for key, value in FED_BACK.items():
|
||||
assert seen.get(key) == value, f"{key} 가 재계산 인자로 안 들어감(넘긴 값: {seen.get(key)})"
|
||||
assert seen.get("paved") is True, "포장 구간 재계산은 포장으로 켜야 함"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""[저장]이 여는 창구 넷이 **비어 있으면 안 나가는지**.
|
||||
|
||||
왜 (2026-09-07 조사) — [저장]은 창구 다섯을 순서대로 연다(관 옵션·상단측·관 목록·구조물·
|
||||
`sections/save`). 넷은 초안이 비면 **요청 없이 즉시 돌아와야** 한다. 그래야 보통 저장이
|
||||
1~2건으로 끝나고, 하나가 실패해도 나머지가 나간다(2026-08-29 사고 뒤 일부러 만든 구조).
|
||||
|
||||
이 성질이 깨지면 저장마다 빈 요청이 붙고, 「초안을 한 덩어리로 합치자」는 명분이 되살아난다.
|
||||
합치지 않기로 한 근거(0-6)가 이 성질에 기대고 있으므로 그물을 남긴다.
|
||||
|
||||
코드를 읽어 지키는 시험이다 — 화면 왕복 없이 무너짐을 잡는 것이 목적이다.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
#: (파일, 함수 이름, 비었을 때 돌아가는 표시)
|
||||
CHANNELS = [
|
||||
(
|
||||
ROOT / "B05_Profile" / "B05_Profile_Api_Pipes_Draft.ts",
|
||||
"flushPendingPipes",
|
||||
"관 목록",
|
||||
),
|
||||
(
|
||||
ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts",
|
||||
"flushUphillOverrides",
|
||||
"상단측(측구 방향)",
|
||||
),
|
||||
(
|
||||
ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts",
|
||||
"flushPendingStructures",
|
||||
"구조물",
|
||||
),
|
||||
(
|
||||
ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts",
|
||||
"flushCulvertOptions",
|
||||
"관 옵션",
|
||||
),
|
||||
]
|
||||
|
||||
#: 비었을 때 빠져나가는 모양 — `return;` 만 있는 이른 반환.
|
||||
_EARLY_RETURN = re.compile(r"\breturn\s*;")
|
||||
|
||||
|
||||
def _body(path: Path, name: str) -> str:
|
||||
"""함수 머리부터 다음 최상위 선언 전까지 — 이른 반환이 있는지만 보면 되므로 넉넉히 자른다."""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
match = re.search(rf"(?:async\s+)?(?:export\s+)?(?:async\s+)?function\s+{name}\s*\(", text)
|
||||
if match is None:
|
||||
match = re.search(rf"\b{name}\s*\([^)]*\)\s*(?::[^{{]+)?\{{", text)
|
||||
assert match is not None, f"{path.name} 에서 {name} 을 못 찾음"
|
||||
return text[match.start() : match.start() + 900]
|
||||
|
||||
|
||||
def test_창구_넷은_초안이_비면_요청을_안_낸다():
|
||||
problems = []
|
||||
for path, name, label in CHANNELS:
|
||||
body = _body(path, name)
|
||||
if not _EARLY_RETURN.search(body):
|
||||
problems.append(f"{label}({path.name}:{name}) — 빈 초안에서 빠져나가는 자리가 없음")
|
||||
assert not problems, "\n".join(problems)
|
||||
|
||||
|
||||
def test_저장이_창구를_다섯_다_거친다():
|
||||
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다."""
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
for token in (
|
||||
"flushCulvertOptions()",
|
||||
"flushUphillOverrides(",
|
||||
"flushPendingPipes(",
|
||||
"flushPendingStructures(",
|
||||
):
|
||||
assert token in persist, f"{token} 가 저장 앞단에서 사라짐"
|
||||
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물은 `catch` 로 감싸 둔다.
|
||||
assert persist.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
|
||||
@@ -0,0 +1,118 @@
|
||||
"""[저장]이 「행은 있는데 설계가 빈 측점」을 채우는지 (2026-09-09).
|
||||
|
||||
재생성이 `cross_sections` 행을 지우고 다시 쓰면 `data.design` 에 면적 4키만 남는다.
|
||||
`get_cross_section_designs` 는 `ground_type` 있는 줄만 담으므로 그 상태는 「설계 없음」이고,
|
||||
B08 토적표가 통째로 0 이 됐다(창 셋에서 같은 증상). 예전에는 체인이 뒤이어 부르는 [확정]이
|
||||
그 자리를 채워 가려져 있었다.
|
||||
|
||||
⚠ **값이 있는 행은 안 건드린다**가 이 시험의 핵심이다 — 사용자 조작값·이월분이 거기 있다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import B06_Section.B06_Section_Router_Confirm as confirm # noqa: E402
|
||||
|
||||
|
||||
class _Conn:
|
||||
async def begin(self) -> None:
|
||||
return None
|
||||
|
||||
async def commit(self) -> None:
|
||||
return None
|
||||
|
||||
async def rollback(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self) -> _Conn:
|
||||
return _Conn()
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self) -> _Acquire:
|
||||
return _Acquire()
|
||||
|
||||
|
||||
def _async(value: Any):
|
||||
async def _inner(*_: Any, **__: Any) -> Any:
|
||||
return value
|
||||
|
||||
return _inner
|
||||
|
||||
|
||||
def _run_save(monkeypatch: pytest.MonkeyPatch, missing: list[float]) -> list[float]:
|
||||
"""`save_sections` 를 돌리고 **기본 설계를 계산하라고 넘긴 측점 목록**을 돌려준다."""
|
||||
seen: list[float] = []
|
||||
|
||||
monkeypatch.setattr(confirm, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(
|
||||
confirm, "get_longitudinal_section", _async({"longitudinal_file_path": "long.json"})
|
||||
)
|
||||
monkeypatch.setattr(confirm, "get_project_storage_relative_path", _async("1/3/proj"))
|
||||
monkeypatch.setattr(confirm, "get_cross_section_chainages", _async([0.0, 20.0, 40.0]))
|
||||
monkeypatch.setattr(confirm, "get_cross_sections_missing_design_chainages", _async(missing))
|
||||
monkeypatch.setattr(confirm, "resolve_stored_project_path", lambda _p: str(PROJECT_ROOT))
|
||||
# 행 자체가 없는 측점은 이 시험의 관심사가 아니다 — 빈 목록으로 고정한다.
|
||||
monkeypatch.setattr(confirm, "_rowless_station_chainages", lambda *_a, **_k: [])
|
||||
|
||||
def _spy(_root, _path, chainages, _standard):
|
||||
seen.extend(chainages)
|
||||
return [(value, {"ground_type": "soil"}) for value in chainages]
|
||||
|
||||
monkeypatch.setattr(confirm, "_compute_default_designs", _spy)
|
||||
monkeypatch.setattr(confirm, "_apply_section_edits", _async(None))
|
||||
monkeypatch.setattr(confirm, "_recompute_stored_designs", _async(None))
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
response = asyncio.run(
|
||||
confirm.save_sections(
|
||||
project_id=UUID("fa76c162-71c7-46e5-a95d-fb3930665a45"), route_id=184, request=None
|
||||
)
|
||||
)
|
||||
assert getattr(response, "confirmed", None) is False, response
|
||||
return seen
|
||||
|
||||
|
||||
def test_설계가_빈_측점을_채운다(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen = _run_save(monkeypatch, [20.0, 40.0])
|
||||
assert sorted(seen) == [20.0, 40.0]
|
||||
|
||||
|
||||
def test_값이_있는_행은_안_건드린다(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""빈 측점이 없으면 기본 설계를 아예 계산하지 않는다 — 덮어쓸 일이 없다."""
|
||||
seen = _run_save(monkeypatch, [])
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_빈_설계의_뜻이_ground_type_없음이다() -> None:
|
||||
"""판정은 `get_cross_sections_missing_design_chainages` 한 곳에서만 한다."""
|
||||
import inspect
|
||||
|
||||
from B06_Section.B06_Section_Repository import get_cross_sections_missing_design_chainages
|
||||
|
||||
source = inspect.getsource(get_cross_sections_missing_design_chainages)
|
||||
assert 'design.get("ground_type")' in source
|
||||
|
||||
|
||||
def test_저장도_확정과_같은_판정을_쓴다() -> None:
|
||||
"""두 갈래가 같은 조회를 써야 「저장했는데 확정과 다르다」가 안 생긴다."""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(confirm.save_sections)
|
||||
assert "get_cross_sections_missing_design_chainages" in source
|
||||
@@ -0,0 +1,52 @@
|
||||
/* Node 진입점(구조물 면적) 연결 확인 — 2026-09-06.
|
||||
*
|
||||
* 면적 산식 자체는 `test_b06_structure_areas.mjs` 가 본다. 여기서 지키는 것은 하나:
|
||||
* **구조물이 없는 측점은 결과에 나오면 안 된다.** 나오면 서버가 표준 계산값을
|
||||
* 폐회로 보정값으로 덮어써 수량이 조용히 달라진다.
|
||||
*
|
||||
* 실행: node tmp/tests/test_b06_structure_areas_node.mjs
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "../..");
|
||||
const BUNDLE = join(ROOT, "config/server_calc_node/B06_Section_Server_Calc_Node.js");
|
||||
|
||||
/** 평지 위 표준 횡단 한 측점 — 구조물 없음. */
|
||||
function plainSection(chainage) {
|
||||
const samples = [];
|
||||
for (let offset = -15; offset <= 15; offset += 1) {
|
||||
samples.push({ offset_m: offset, elevation_m: 100, valid: true });
|
||||
}
|
||||
return {
|
||||
chainage_m: chainage,
|
||||
samples,
|
||||
design: {
|
||||
design_elevation_m: 100,
|
||||
design_line: [
|
||||
{ offset_m: -3, elevation_m: 100 },
|
||||
{ offset_m: 3, elevation_m: 100 },
|
||||
],
|
||||
road_edges: { left: { offset_m: -3, elevation_m: 100 }, right: { offset_m: 3, elevation_m: 100 } },
|
||||
cut_area_m2: 1.23,
|
||||
fill_area_m2: 4.56,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const work = mkdtempSync(join(tmpdir(), "areas-node-"));
|
||||
const input = join(work, "input.json");
|
||||
const output = join(work, "output.json");
|
||||
writeFileSync(
|
||||
input,
|
||||
JSON.stringify({ detail: { cross_sections: [plainSection(0), plainSection(20)] } }),
|
||||
);
|
||||
execFileSync("node", [BUNDLE, input, output], { cwd: ROOT });
|
||||
const { areas: rows } = JSON.parse(readFileSync(output, "utf8"));
|
||||
|
||||
assert.ok(Array.isArray(rows), "결과는 배열이어야 한다");
|
||||
assert.equal(rows.length, 0, `구조물 없는 측점은 결과에 없어야 한다 (받은 수: ${rows.length})`);
|
||||
console.log("OK — 구조물 없는 측점은 면적을 덮지 않는다");
|
||||
@@ -0,0 +1,188 @@
|
||||
"""사토장 — 용량에서 폭을 정해 측점마다 단면을 세운다 (2026-09-09).
|
||||
|
||||
⚠ 잠그는 것
|
||||
① 용량이 커질수록 폭이 넓어지고, 상한에서 멈추면 못 담은 몫이 값으로 남는다.
|
||||
② 사토장이 선 측점은 **노선 성토가 줄고** 그만큼이 사토장 몫으로 간다(두 번 세지 않기).
|
||||
③ 「자동」이면 그 측점의 **성토 쪽**에 선다.
|
||||
④ 구간 밖 측점은 안 건드린다 — **새 측점을 만들지 않는다**.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills # noqa: E402
|
||||
|
||||
|
||||
def _samples() -> list[dict]:
|
||||
"""우측(-offset)이 높고 좌측(+offset)이 낮은 지반 — 좌가 성토 쪽이다."""
|
||||
return [
|
||||
{"offset_m": round(offset, 3), "elevation_m": round(100.0 - 0.5 * offset, 4), "valid": True}
|
||||
for offset in [x * 0.5 for x in range(-30, 31)]
|
||||
]
|
||||
|
||||
|
||||
def _sections(chainages: list[float]) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"chainage_m": chainage,
|
||||
"samples": _samples(),
|
||||
"design": compute_cross_design(
|
||||
_samples(),
|
||||
99.8,
|
||||
ground_type="soil",
|
||||
section_mode="right_cut",
|
||||
ditch_side="right",
|
||||
),
|
||||
}
|
||||
for chainage in chainages
|
||||
]
|
||||
|
||||
|
||||
_LONGITUDINAL = {
|
||||
"design_profiles": [
|
||||
{"samples": [{"chainage_m": float(c), "elevation_m": 99.8} for c in range(0, 201, 20)]}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _write_site(root: Path, capacity_m3: float, **options) -> None:
|
||||
(root / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True)
|
||||
(root / "B05_Profile" / "route" / "structures.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"revision": 1,
|
||||
"structures": [
|
||||
{
|
||||
"structure_id": "sp-1",
|
||||
"type_id": "spoil_bank",
|
||||
"placement": "interval",
|
||||
"start_m": 100.0,
|
||||
"end_m": 160.0,
|
||||
"options": {"capacity_m3": capacity_m3, **options},
|
||||
}
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _run(root: Path, capacity_m3: float, **options) -> list[dict]:
|
||||
_write_site(root, capacity_m3, **options)
|
||||
sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0])
|
||||
enforce_spoil_fills(_LONGITUDINAL, sections, root)
|
||||
return sections
|
||||
|
||||
|
||||
def _placed(sections: list[dict]) -> float:
|
||||
inside = [s for s in sections if 100.0 <= s["chainage_m"] <= 160.0]
|
||||
return float(inside[0]["design"].get("spoil_fill_placed_m3") or 0.0)
|
||||
|
||||
|
||||
def test_용량이_클수록_폭이_넓어진다(tmp_path: Path) -> None:
|
||||
small = _run(tmp_path, 200.0)
|
||||
large = _run(tmp_path, 800.0)
|
||||
narrow = small[1]["design"]["spoil_fill_width_m"]
|
||||
wide = large[1]["design"]["spoil_fill_width_m"]
|
||||
assert 0 < narrow < wide
|
||||
assert _placed(small) == pytest.approx(200.0, rel=0.02)
|
||||
assert _placed(large) == pytest.approx(800.0, rel=0.02)
|
||||
|
||||
|
||||
def test_담을_수_없으면_남은_몫이_값으로_남는다(tmp_path: Path) -> None:
|
||||
"""상한에서 멈춘다 — 임의로 더 넓히지 않는다."""
|
||||
sections = _run(tmp_path, 1_000_000.0)
|
||||
design = sections[1]["design"]
|
||||
assert design["spoil_fill_unplaced_m3"] > 0
|
||||
assert design["spoil_fill_width_m"] == pytest.approx(design["spoil_fill_max_width_m"])
|
||||
|
||||
|
||||
def test_노선_성토가_그만큼_줄어든다(tmp_path: Path) -> None:
|
||||
"""같은 흙을 두 번 세지 않는다(확정 ㉠)."""
|
||||
plain = _sections([120.0])[0]["design"]
|
||||
sections = _run(tmp_path, 400.0)
|
||||
inside = sections[2]["design"]
|
||||
assert inside["spoil_fill_area_m2"] > 0
|
||||
assert inside["spoil_fill_replaced_fill_m2"] > 0
|
||||
assert inside["fill_area_m2"] == pytest.approx(
|
||||
plain["fill_area_m2"] - inside["spoil_fill_replaced_fill_m2"], abs=1e-6
|
||||
)
|
||||
|
||||
|
||||
def test_자동이면_성토_쪽에_선다(tmp_path: Path) -> None:
|
||||
sections = _run(tmp_path, 400.0, side="자동(성토 쪽)")
|
||||
# right_cut = 우측 절토 ⇒ 성토는 좌측(+offset).
|
||||
assert sections[1]["design"]["spoil_fill_side"] == "left"
|
||||
|
||||
|
||||
def test_구간_밖_측점은_안_건드린다(tmp_path: Path) -> None:
|
||||
"""새 측점을 만들지 않고, 구간 밖은 종전 그대로다(확정 ③)."""
|
||||
sections = _run(tmp_path, 400.0)
|
||||
assert len(sections) == 6
|
||||
for section in sections:
|
||||
design = section["design"]
|
||||
if 100.0 <= section["chainage_m"] <= 160.0:
|
||||
assert design["spoil_fill_area_m2"] > 0
|
||||
else:
|
||||
assert design.get("spoil_fill_area_m2", 0) == 0
|
||||
|
||||
|
||||
def test_용량이_없으면_아무것도_안_세운다(tmp_path: Path) -> None:
|
||||
(tmp_path / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "B05_Profile" / "route" / "structures.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"revision": 1,
|
||||
"structures": [
|
||||
{
|
||||
"structure_id": "sp-1",
|
||||
"type_id": "spoil_bank",
|
||||
"placement": "interval",
|
||||
"start_m": 100.0,
|
||||
"end_m": 160.0,
|
||||
"options": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sections = _sections([120.0])
|
||||
assert enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) == 0
|
||||
assert sections[0]["design"]["spoil_fill_area_m2"] == 0
|
||||
|
||||
|
||||
def test_사토장을_지우면_값도_사라진다(tmp_path: Path) -> None:
|
||||
"""⚠ 화면 실측으로 잡은 자리(2026-09-09) — 얹기만 하고 **지우지 않아** 구조물을 없앤 뒤에도
|
||||
횡단도에 계속 그려지고 면적표에도 서 있었다.
|
||||
"""
|
||||
sections = _run(tmp_path, 400.0)
|
||||
inside = sections[2]["design"]
|
||||
assert inside["spoil_fill_area_m2"] > 0
|
||||
plain_fill = _sections([120.0])[0]["design"]["fill_area_m2"]
|
||||
|
||||
# 구조물을 지운다 — 빈 목록으로 덮어쓴다.
|
||||
(tmp_path / "B05_Profile" / "route" / "structures.json").write_text(
|
||||
json.dumps({"revision": 2, "structures": []}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
changed = enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path)
|
||||
assert changed > 0, "지운 사토장을 되돌리지 않았다"
|
||||
for section in sections:
|
||||
design = section["design"]
|
||||
# 칸은 남아도 **값이 0** 이어야 한다 — 설계 결과는 사토장이 없어도 칸을 늘 싣는다.
|
||||
assert float(design.get("spoil_fill_area_m2") or 0) == 0, section["chainage_m"]
|
||||
assert float(design.get("spoil_fill_width_m") or 0) == 0, section["chainage_m"]
|
||||
assert design.get("spoil_fill_capacity_m3") in (None, 0), section["chainage_m"]
|
||||
# 노선 성토도 원래 값으로 돌아온다 — 사토장 몫을 빼 두었던 것이 복구된다.
|
||||
assert sections[2]["design"]["fill_area_m2"] == pytest.approx(plain_fill, abs=1e-6)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""저장된 표준 횡단면이 **브라우저까지 닿는지**.
|
||||
|
||||
왜 (2026-09-07 실측) — 표준단면 편집값은 sessionStorage 에만 살아서 **탭을 새로 열면**
|
||||
사라졌다. 그때 브라우저는 config 기본값으로, 서버는 저장분으로 계산해 같은 측점이 갈렸다.
|
||||
용화(route 169) 실측 — 저장분은 암반 횡단경사 **5%** · 측구 상단폭 **0.9m**,
|
||||
config 기본값은 **3%** · **0.69m**. 표준단면은 모든 측점의 횡단 모양을 정하므로
|
||||
면적·유토곡선·수량까지 그대로 흐른다.
|
||||
|
||||
고침은 두 줄이다 — `sections/context` 가 저장분을 **한 칸 더** 실어 보내고(기존 기본값 칸은
|
||||
그대로), 브라우저가 **세션이 비었을 때만** 그 값으로 세운다. 이 시험은 그 두 성질을 지킨다.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Schema import SectionContextResponse # noqa: E402
|
||||
|
||||
_FETCH = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts"
|
||||
_ROUTER = PROJECT_ROOT / "B06_Section" / "B06_Section_Router.py"
|
||||
|
||||
|
||||
def test_응답에_저장분_칸이_따로_있다():
|
||||
"""기존 기본값 칸은 그대로 두고 한 칸만 더한 것 — 옛 화면이 안 깨진다."""
|
||||
fields = SectionContextResponse.model_fields
|
||||
assert "standard_cross_section" in fields, "기본값 칸이 사라짐"
|
||||
assert "stored_standard_cross_section" in fields, "저장분 칸이 없음"
|
||||
# 저장분이 없는 프로젝트도 있다 — 그때는 None 이어야 기본값으로 선다.
|
||||
empty = SectionContextResponse(project_id="x", defaults=_defaults())
|
||||
assert empty.stored_standard_cross_section is None
|
||||
|
||||
|
||||
def _defaults():
|
||||
from B06_Section.B06_Section_Schema import SectionOptionDefaults
|
||||
|
||||
return SectionOptionDefaults(
|
||||
station_interval_m=20.0,
|
||||
cross_half_width_m=20.0,
|
||||
cross_sample_interval_m=0.5,
|
||||
long_sample_interval_m=1.0,
|
||||
vertical_exaggeration=2.0,
|
||||
)
|
||||
|
||||
|
||||
def test_라우터가_저장분을_실어_보낸다():
|
||||
source = _ROUTER.read_text(encoding="utf-8")
|
||||
assert "stored_standard_cross_section=stored_standard" in source, "응답에 안 실림"
|
||||
assert "_stored_standard_cross_section(longitudinal_row)" in source, "저장분을 안 읽음"
|
||||
|
||||
|
||||
def test_브라우저는_세션이_빈_경우에만_저장분으로_선다():
|
||||
"""사용자가 그 탭에서 고친 값이 있으면 건드리면 안 된다 — 초안이 언제나 우선."""
|
||||
source = _FETCH.read_text(encoding="utf-8")
|
||||
assert "function seedStandardCross(" in source, "세우는 자리가 없음"
|
||||
guard = re.search(
|
||||
r"if \(stored && readState<unknown>\(\"std-cross\", projectId\) === null\)", source
|
||||
)
|
||||
assert guard, "세션이 있어도 덮어쓰는 모양임"
|
||||
# 두 갈래(캐시·새 조회) 모두 지나야 새 탭에서도 선다.
|
||||
assert source.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움"
|
||||
@@ -0,0 +1,67 @@
|
||||
/* 구조물 폐회로 면적 — 벽이 성토 사면을 끊으면 면적이 그만큼 줄어드는지 확인.
|
||||
(2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 게 아니라 그려지는 폐회로의 넓이다)
|
||||
TS 두 파일을 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다. */
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const out = mkdtempSync(join(tmpdir(), "aislo-areas-"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"./config/node_modules/typescript/bin/tsc",
|
||||
"common_util/common_util_cross_design_areas.ts",
|
||||
"common_util/common_util_cross_structure_areas.ts",
|
||||
"--outDir", out,
|
||||
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
// tsc 는 확장자 없는 상대 import 를 그대로 둔다 — node 가 읽게 `.js` 를 붙인다.
|
||||
const { readFileSync, writeFileSync } = await import("node:fs");
|
||||
const target = join(out, "common_util_cross_structure_areas.js");
|
||||
writeFileSync(
|
||||
target,
|
||||
readFileSync(target, "utf8").replace(
|
||||
/from "\.\/common_util_cross_design_areas"/,
|
||||
'from "./common_util_cross_design_areas.js"',
|
||||
),
|
||||
);
|
||||
const { computeStructureAreas } = await import(pathToFileURL(target).href);
|
||||
|
||||
// 평지 지반(표고 100). 설계선이 좌(−offset)로 갈수록 지반 아래로 파고든다 = 절토.
|
||||
const ground = [];
|
||||
const designLine = [];
|
||||
for (let o = -20; o <= 20; o += 1) {
|
||||
ground.push({ offset: o, elevation: 100 });
|
||||
designLine.push({ offset_m: o, elevation_m: o >= 0 ? 100 : 100 + o * 0.5 });
|
||||
}
|
||||
|
||||
const wide = computeStructureAreas({ designLine, ground, trim: { minOffset: -20, maxOffset: 20 } });
|
||||
const trimmed = computeStructureAreas({ designLine, ground, trim: { minOffset: -5, maxOffset: 20 } });
|
||||
assert.ok(wide && trimmed, "면적 계산 결과가 나와야 한다");
|
||||
// 전 구간: 0.5 * 20 * 10 = 100㎡ / 벽 안쪽만: 0.5 * 5 * 2.5 = 6.25㎡
|
||||
assert.ok(Math.abs(wide.cutAreaM2 - 100) < 0.01, `전 구간 절토 100㎡ — 실제 ${wide.cutAreaM2}`);
|
||||
assert.ok(Math.abs(trimmed.cutAreaM2 - 6.25) < 0.01, `벽 안쪽 6.25㎡ — 실제 ${trimmed.cutAreaM2}`);
|
||||
|
||||
// 벽 바깥을 성토부선이 대신 그리면 그만큼 다시 잡힌다(폐회로가 넓어진다).
|
||||
const withSlope = computeStructureAreas({
|
||||
designLine,
|
||||
ground,
|
||||
trim: {
|
||||
minOffset: -5,
|
||||
maxOffset: 20,
|
||||
minSlope: { points: [{ offset: -10, elevation: 97.5 }, { offset: -5, elevation: 97.5 }] },
|
||||
},
|
||||
});
|
||||
assert.ok(withSlope, "성토부선이 있는 경우도 계산돼야 한다");
|
||||
assert.ok(
|
||||
withSlope.cutAreaM2 > trimmed.cutAreaM2,
|
||||
`구조물 선이 덮은 만큼 늘어야 한다: ${withSlope.cutAreaM2} > ${trimmed.cutAreaM2}`,
|
||||
);
|
||||
console.log(
|
||||
`구조물 폐회로 면적 확인 — 전구간 ${wide.cutAreaM2.toFixed(2)}㎡ · 벽에서 끊음 ${trimmed.cutAreaM2.toFixed(2)}㎡ · 구조물선 포함 ${withSlope.cutAreaM2.toFixed(2)}㎡`,
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
"""구조물 종방향 구간값(길이·기준측점 전/후) 검증 — 2026-08-24 사용자 확정.
|
||||
|
||||
기슭막이는 길이 10m·전/후 5·5, 집수정은 길이 2m·전/후 1·1이 기본이다. 세 값이
|
||||
곧 그 구조물이 덮는 종방향 범위이고, 그 범위에 걸린 옆 측점 횡단도에 같은
|
||||
기슭막이가 연동으로 선다. 별도 폭 필드는 없다(PLAN.md §4-9).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, _side_spec # noqa: E402
|
||||
|
||||
|
||||
def _pipe_option_default(key: str):
|
||||
for item in load_structure_types():
|
||||
if item.type_id != "pipe":
|
||||
continue
|
||||
for option in item.options:
|
||||
if option.key == key:
|
||||
return option.default
|
||||
raise AssertionError(f"레지스트리에 배관 옵션 {key}가 없다")
|
||||
|
||||
|
||||
def test_registry_defines_basin_before_after():
|
||||
"""집수정 길이 2m·전/후 각 1m가 레지스트리 기본값으로 있어야 한다."""
|
||||
assert _pipe_option_default("inlet_basin_length_m") == 2
|
||||
assert _pipe_option_default("inlet_basin_before_m") == 1
|
||||
assert _pipe_option_default("inlet_basin_after_m") == 1
|
||||
|
||||
|
||||
def test_basin_side_spec_carries_before_after():
|
||||
"""유입이 집수정이면 세트 제원에 길이와 전/후가 함께 실린다(레지스트리 기본값 경유)."""
|
||||
spec = _culvert_set({"inlet_type": "집수정"})["inlet"]
|
||||
assert spec["structure"] == "집수정"
|
||||
assert spec["basin_length_m"] == 2.0
|
||||
assert spec["basin_before_m"] == 1.0
|
||||
assert spec["basin_after_m"] == 1.0
|
||||
# 집수정 쪽에는 기슭막이가 없다 — 벽 제원이 섞이면 링크 판정이 10m로 번진다.
|
||||
assert "revet_length_m" not in spec
|
||||
|
||||
|
||||
def test_basin_before_after_follow_saved_options():
|
||||
"""저장분이 있으면 그대로 쓴다 — 비대칭(전 0.5 · 후 1.5)도 허용."""
|
||||
spec = _side_spec(
|
||||
{
|
||||
"inlet_type": "집수정",
|
||||
"inlet_basin_length_m": 2,
|
||||
"inlet_basin_before_m": 0.5,
|
||||
"inlet_basin_after_m": 1.5,
|
||||
},
|
||||
{},
|
||||
"inlet",
|
||||
)
|
||||
assert (spec["basin_before_m"], spec["basin_after_m"]) == (0.5, 1.5)
|
||||
|
||||
|
||||
def test_revet_span_defaults_unchanged():
|
||||
"""기슭막이 기본은 그대로 길이 10m·전/후 5·5다(이번 변경이 건드리지 않는다)."""
|
||||
culvert = _culvert_set(None)
|
||||
for role in ("inlet", "outlet"):
|
||||
spec = culvert[role]
|
||||
assert spec["revet_length_m"] == 10.0
|
||||
assert spec["revet_before_m"] == 5.0
|
||||
assert spec["revet_after_m"] == 5.0
|
||||
@@ -0,0 +1,152 @@
|
||||
"""C군 벽 제원 — 파이썬·TS 짝 거울 테스트.
|
||||
|
||||
`B06_Section_Engine_Structures_Wall`(서버, 저장분 기준)과
|
||||
`common_util/common_util_structure_walls.ts`(브라우저, 미저장 목록 기준)가 같은 입력에
|
||||
같은 제원을 내는지 대조한다(CLAUDE.md 5장 — 짝을 두면 거울 테스트 필수).
|
||||
|
||||
TS 는 프로젝트 tsc 로 옮겨 **실제 코드 그대로** 돌린다(배수관 세트 거울 테스트와 같은 방식).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
STRUCTURES = [
|
||||
{
|
||||
"structure_id": "abc",
|
||||
"type_id": "masonry_wet",
|
||||
"placement": "interval",
|
||||
"chainage_m": 40.0,
|
||||
"start_m": 35.0,
|
||||
"end_m": 45.0,
|
||||
"options": {"height_m": 2.5, "length_m": 10, "before_m": 5, "after_m": 5},
|
||||
},
|
||||
{
|
||||
"structure_id": None,
|
||||
"type_id": "retaining_wall",
|
||||
"placement": "interval",
|
||||
"chainage_m": 120.0,
|
||||
# 뒤집힌 구간 — 두 벌 다 작은 값을 시작으로 정렬해야 한다.
|
||||
"start_m": 130.0,
|
||||
"end_m": 110.0,
|
||||
"options": {"height_m": 3.0, "side": "우", "tiers": 2},
|
||||
},
|
||||
{
|
||||
"structure_id": "no-range",
|
||||
"type_id": "soil_guard",
|
||||
"placement": "interval",
|
||||
"chainage_m": 200.0,
|
||||
"start_m": None,
|
||||
"end_m": None,
|
||||
"options": {},
|
||||
},
|
||||
]
|
||||
NAMES = {"masonry_wet": "돌쌓기(찰)", "retaining_wall": "옹벽", "soil_guard": "흙막이"}
|
||||
CHAINAGES = [20.0, 40.0, 45.02, 60.0, 115.0, 200.0]
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { attachWallSpecs, wallSpecsFrom } from "./common_util_structure_walls.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const specs = wallSpecsFrom(input.structures, new Map(Object.entries(input.names)));
|
||||
const sections = input.chainages.map((chainage_m) => ({ chainage_m }));
|
||||
attachWallSpecs(sections, specs);
|
||||
writeFileSync(process.argv[3], JSON.stringify({ specs, sections }));
|
||||
"""
|
||||
|
||||
|
||||
def _python_result() -> dict:
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import _FORM_BY_TYPE, attach_wall_structures
|
||||
|
||||
specs = []
|
||||
for item in STRUCTURES:
|
||||
name = NAMES.get(item["type_id"])
|
||||
start, end = item["start_m"], item["end_m"]
|
||||
if not name or start is None or end is None:
|
||||
continue
|
||||
options = item["options"] or {}
|
||||
specs.append(
|
||||
{
|
||||
"structure_id": item["structure_id"],
|
||||
"type_id": item["type_id"],
|
||||
"name": name,
|
||||
"start_m": float(min(start, end)),
|
||||
"end_m": float(max(start, end)),
|
||||
"anchor_m": float(item["chainage_m"]),
|
||||
"form": options.get("form") or _FORM_BY_TYPE.get(item["type_id"]),
|
||||
"height_m": options.get("height_m"),
|
||||
"side": options.get("side"),
|
||||
# 기초 축(2026-09-09 신설) — 저장 칸과 같은 글자. 초안 경로에서 빠지면
|
||||
# 터파기가 안 그려져 「그림이 값에 안 따라간다」가 된다.
|
||||
"foundation": options.get("foundation"),
|
||||
"tiers": options.get("tiers"),
|
||||
"lift_m": options.get("lift_m"),
|
||||
"shift_m": options.get("shift_m"),
|
||||
}
|
||||
)
|
||||
# 얹기 규칙은 같은 함수를 쓸 수 없으므로(정본 파일을 읽는다) 여기서 같은 규칙으로 흉내낸다.
|
||||
sections = [{"chainage_m": value} for value in CHAINAGES]
|
||||
for section in sections:
|
||||
for spec in specs:
|
||||
if spec["start_m"] - 0.02 <= section["chainage_m"] <= spec["end_m"] + 0.02:
|
||||
section["revetment"] = spec
|
||||
break
|
||||
assert callable(attach_wall_structures)
|
||||
return {"specs": specs, "sections": sections}
|
||||
|
||||
|
||||
def _ts_result(tmp_path: Path) -> dict:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_structure_walls.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(
|
||||
json.dumps({"structures": STRUCTURES, "names": NAMES, "chainages": CHAINAGES}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_wall_specs_mirror():
|
||||
with tempfile.TemporaryDirectory() as workdir:
|
||||
ts = _ts_result(Path(workdir))
|
||||
py = _python_result()
|
||||
assert len(ts["specs"]) == len(py["specs"]) == 2
|
||||
assert ts["specs"] == py["specs"]
|
||||
ts_marks = [(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"]]
|
||||
py_marks = [(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in py["sections"]]
|
||||
assert ts_marks == py_marks
|
||||
# 구간 밖 측점에는 안 붙는다 / 경계 오차(0.02m) 안은 붙는다.
|
||||
assert dict(ts_marks)[20.0] is None
|
||||
assert dict(ts_marks)[45.02] == "masonry_wet"
|
||||
@@ -0,0 +1,82 @@
|
||||
"""사용자가 켠 「2단 절토」가 재계산에서 살아남는지.
|
||||
|
||||
왜 (2026-09-07 확정) — 엔진은 2단 절토를 **암 지반 + 암반 경계값이 있을 때만** 실제로
|
||||
적용한다. 그런데 결과 딕셔너리에 「적용했나」(`geometry.two_stage`)를 담아 저장했고,
|
||||
그 값이 다음 재계산에 **인자로 되먹여졌다**. 그래서 토사 측점에서 사용자가 켜 두면
|
||||
저장되는 값이 False 로 바뀌고, 그 False 가 다시 인자가 되어 **켬이 영구히 사라졌다**.
|
||||
|
||||
고침은 「사용자가 켠 값을 그대로 돌려주기」다. 「실제로 적용됐나」를 읽는 곳은 코드 전체에
|
||||
없고(2026-09-07 전수 확인), 2단 사면이 그려지는 것은 이 값이 아니라 **설계선 기하**
|
||||
(`design_line`)가 정한다. 파이썬·TS 짝이 같은 값을 내는지는
|
||||
`test_b06_cross_design_mirror.py` 가 함께 지킨다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
|
||||
|
||||
def _ground(slope: float = 0.35) -> list[dict]:
|
||||
samples = []
|
||||
offset = -12.0
|
||||
while offset <= 12.0001:
|
||||
samples.append(
|
||||
{
|
||||
"offset_m": round(offset, 3),
|
||||
"elevation_m": round(100.0 + slope * offset, 4),
|
||||
"valid": True,
|
||||
}
|
||||
)
|
||||
offset += 0.5
|
||||
return samples
|
||||
|
||||
|
||||
def _design(ground_type: str, two_stage: bool, *, rock_offset: float | None = -1.5) -> dict:
|
||||
return compute_cross_design(
|
||||
_ground(),
|
||||
100.4,
|
||||
ground_type=ground_type,
|
||||
section_mode="left_cut",
|
||||
ditch_side=None,
|
||||
ditch_type="standard",
|
||||
paved=False,
|
||||
standard=None,
|
||||
rock_boundary_offset_m=rock_offset,
|
||||
two_stage_slope=two_stage,
|
||||
ditch_enabled=None,
|
||||
)
|
||||
|
||||
|
||||
def test_토사_측점에서도_켠_값이_남는다():
|
||||
"""엔진이 못 쓰는 자리 — 예전에는 여기서 False 가 저장돼 켬이 사라졌다."""
|
||||
result = _design("soil", True)
|
||||
assert result["two_stage_slope"] is True, "토사 측점에서 사용자의 「켬」이 지워짐"
|
||||
|
||||
|
||||
def test_다시_계산해도_켬이_유지된다():
|
||||
"""되먹임 왕복 — 저장값을 그대로 인자로 넣어 두 번 돌려도 값이 안 눌린다."""
|
||||
once = _design("soil", True)
|
||||
twice = _design("soil", bool(once["two_stage_slope"]))
|
||||
assert twice["two_stage_slope"] is True
|
||||
|
||||
|
||||
def test_끈_값도_그대로_남는다():
|
||||
"""반대 방향 — 사용자가 껐으면 암 지반이라도 켜지지 않는다."""
|
||||
result = _design("ripping_rock", False)
|
||||
assert result["two_stage_slope"] is False
|
||||
|
||||
|
||||
def test_2단_사면은_이_값이_아니라_설계선이_그린다():
|
||||
"""이 키가 「적용 결과」를 안 담아도 그림이 안 바뀌는 근거 — 기하가 갈린다."""
|
||||
applied = _design("ripping_rock", True)
|
||||
not_applied = _design("ripping_rock", False)
|
||||
assert applied["design_line"] != not_applied["design_line"], (
|
||||
"2단 적용 여부가 설계선에 안 나타남 — 그러면 이 키가 유일한 근거가 된다"
|
||||
)
|
||||
# 토사는 어느 쪽으로 켜도 기하가 같다(엔진이 애초에 안 씀) — 그래서 값만 남기면 된다.
|
||||
assert _design("soil", True)["design_line"] == _design("soil", False)["design_line"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""사용자 조작값 목록이 서버·브라우저에서 갈라지지 않는지 지킨다.
|
||||
|
||||
왜 (2026-09-07 발견) — 같은 목록이 세 곳에 흩어져 있었고 서로 달랐다. 브라우저는
|
||||
`extra_spans`(다단 구간값)를 살렸는데 서버 두 경로는 안 살려, 그 측점이 다시 계산되면
|
||||
사용자가 넣은 단별 구간값이 조용히 사라졌다. 새 조작값을 만들 때 한쪽만 고치는 것을 막는다.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS_PATH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts"
|
||||
|
||||
# 브라우저 목록에만 있고 서버 목록에는 없는 둘 — 부르는 쪽이 따로 붙인다.
|
||||
_CALLER_ADDED = {"status", "pavement_suggested"}
|
||||
|
||||
|
||||
def _browser_keys() -> set[str]:
|
||||
"""브라우저 쪽 사용자 값 목록 — 2026-09-07 부터 `USER_TOUCHED_KEYS` 한 벌이 정본이고
|
||||
`PRESERVED_KEYS` 는 거기에 상태 둘을 더해 만든다."""
|
||||
text = TS_PATH.read_text(encoding="utf-8")
|
||||
block = re.search(r"export const USER_TOUCHED_KEYS = \[(.*?)\] as const;", text, re.S)
|
||||
assert block, "USER_TOUCHED_KEYS 를 못 찾음"
|
||||
return set(re.findall(r'"([a-z_]+)"', block.group(1)))
|
||||
|
||||
|
||||
def test_다단_구간값이_서버_목록에_있다():
|
||||
assert "extra_spans" in USER_TOUCHED_KEYS
|
||||
|
||||
|
||||
def test_서버와_브라우저_목록이_같다():
|
||||
assert _browser_keys() - _CALLER_ADDED == set(USER_TOUCHED_KEYS)
|
||||
|
||||
|
||||
def test_브라우저_목록도_한_벌이다():
|
||||
"""보존 목록·저장 payload 가 같은 한 벌을 쓰는지 — 손나열이 되살아나면 깨진다."""
|
||||
text = TS_PATH.read_text(encoding="utf-8")
|
||||
assert "...USER_TOUCHED_KEYS" in text, "PRESERVED_KEYS 가 목록을 다시 적고 있음"
|
||||
store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8")
|
||||
assert "USER_TOUCHED_KEYS" in store, "저장 payload 가 목록을 안 씀"
|
||||
assert store.count('"extra_spans"') == 0, "저장 payload 에 손나열이 남아 있음"
|
||||
|
||||
|
||||
def test_확정_payload_도_손나열이_없다():
|
||||
"""서버 확정 경로 — 스키마를 통째로 덤프하므로 새 필드가 저절로 실린다."""
|
||||
confirm = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text(encoding="utf-8")
|
||||
assert "patch_item.model_dump()" in confirm
|
||||
assert confirm.count('patch["extra_spans"]') == 0, "확정 경로에 손나열이 남아 있음"
|
||||
|
||||
|
||||
def test_서버_목록이_한_벌이다():
|
||||
"""라우터가 목록을 다시 적지 않고 그 한 벌을 쓴다."""
|
||||
router = (ROOT / "B06_Section" / "B06_Section_Router.py").read_text(encoding="utf-8")
|
||||
assert "USER_TOUCHED_KEYS" in router
|
||||
assert router.count('"revet_follow_grade"') == 0, "라우터에 목록 사본이 남아 있음"
|
||||
@@ -0,0 +1,320 @@
|
||||
"""B07 토적도·유역도 엔진 — 좌표 환산과 표기가 척도대로 나오는지 확인한다."""
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import (
|
||||
MM_V,
|
||||
auto_scale_h,
|
||||
build_mass_haul_drawing,
|
||||
)
|
||||
|
||||
|
||||
def _texts(drawing):
|
||||
"""도면 안 모든 글자(자식 포함) — Text 라벨과 Table 칸 글자를 함께 훑는다.
|
||||
|
||||
정보표는 선·문자를 따로 두지 않고 Table 엔티티 하나로 나간다(DXF의 표로 내보내기
|
||||
위함) — 칸 글자는 `shapeData.cells` 안에 있어 Text만 훑으면 통째로 안 보인다.
|
||||
"""
|
||||
out = []
|
||||
|
||||
def walk(entity):
|
||||
if entity.get("type") == "Text":
|
||||
out.append(entity["shapeData"]["label"])
|
||||
if entity.get("type") == "Table":
|
||||
for row in entity["shapeData"].get("cells") or []:
|
||||
out.extend(cell["text"] for cell in row if cell and cell.get("text") is not None)
|
||||
for child in entity.get("children") or []:
|
||||
walk(child)
|
||||
|
||||
for entity in drawing["entities"]:
|
||||
walk(entity)
|
||||
return out
|
||||
|
||||
|
||||
def _lines(drawing, layer_id):
|
||||
"""레이어의 Line 좌표쌍 목록(PolyLine 자식 포함)."""
|
||||
out = []
|
||||
|
||||
def walk(entity):
|
||||
if entity.get("type") == "Line" and entity.get("layerId") == layer_id:
|
||||
shape = entity["shapeData"]
|
||||
out.append(
|
||||
(
|
||||
(shape["startPoint"]["x"], shape["startPoint"]["y"]),
|
||||
(shape["endPoint"]["x"], shape["endPoint"]["y"]),
|
||||
)
|
||||
)
|
||||
for child in entity.get("children") or []:
|
||||
walk(child)
|
||||
|
||||
for entity in drawing["entities"]:
|
||||
walk(entity)
|
||||
return out
|
||||
|
||||
|
||||
LONGITUDINAL = {
|
||||
"stations": [
|
||||
{"chainage_m": 0.0, "station_id": "s0"},
|
||||
{"chainage_m": 20.0, "station_id": "s1"},
|
||||
{"chainage_m": 40.0, "station_id": "s2"},
|
||||
]
|
||||
}
|
||||
|
||||
MASS_HAUL = {
|
||||
"basis": "compacted",
|
||||
"points": [
|
||||
{"station_id": "s0", "chainage_m": 0.0, "cumulative_volume_m3": 0.0},
|
||||
{"station_id": "s1", "chainage_m": 20.0, "cumulative_volume_m3": 1000.0},
|
||||
{"station_id": "s2", "chainage_m": 40.0, "cumulative_volume_m3": 400.0},
|
||||
],
|
||||
"haul_plan": {
|
||||
"blocks": [
|
||||
{
|
||||
"index": 1,
|
||||
"from_m": 0.0,
|
||||
"to_m": 40.0,
|
||||
"base_m3": 0.0,
|
||||
"volume_m3": 1000.0,
|
||||
"direction": "forward",
|
||||
"bands": [
|
||||
{
|
||||
"index": 1,
|
||||
"equipment": "free_haul",
|
||||
"volume_m3": 600.0,
|
||||
"haul_distance_m": 18.5,
|
||||
"ea_m3": 400.0,
|
||||
"rr_m3": 150.0,
|
||||
"br_m3": 50.0,
|
||||
"level_base_m3": 0.0,
|
||||
"level_apex_m3": 600.0,
|
||||
"boundary_from_m": 0.0,
|
||||
"boundary_to_m": 40.0,
|
||||
"haul_from_m": 6.0,
|
||||
"haul_to_m": 34.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"residuals": [
|
||||
{
|
||||
"index": 1,
|
||||
"kind": "spoil",
|
||||
"from_m": 30.0,
|
||||
"to_m": 40.0,
|
||||
"volume_m3": 120.0,
|
||||
"natural_m3": 40.0,
|
||||
"level_from_m3": 400.0,
|
||||
"level_to_m3": 280.0,
|
||||
"ea_m3": 80.0,
|
||||
"rr_m3": 30.0,
|
||||
"br_m3": 10.0,
|
||||
}
|
||||
],
|
||||
"transfers": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_mass_haul_scale_follows_route_length():
|
||||
"""가로 축척은 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림(2026-09-03 사용자 결정)."""
|
||||
assert auto_scale_h(40.0) == 500 # 40m -> 80mm
|
||||
assert auto_scale_h(1106.0) == 2000 # 용화 1,106m -> 553mm (1:1,500이면 737mm로 넘침)
|
||||
assert auto_scale_h(3465.0) == 5000
|
||||
assert MM_V == 0.02 # 세로는 고정(종이 1mm = 50㎥)
|
||||
|
||||
|
||||
def test_mass_haul_curve_uses_paper_scale():
|
||||
"""유토곡선 점은 x=거리xmm_h, y=토량/50mm로 옮겨진다. 40m 노선이면 H 1/500 -> 2.0mm/m."""
|
||||
mm_h = 1000.0 / auto_scale_h(40.0)
|
||||
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
|
||||
curve = _lines(drawing, "b08-masshaul-curve")
|
||||
assert curve[0][0] == (0.0, 0.0)
|
||||
assert curve[0][1] == (20.0 * mm_h, 1000.0 * 0.02)
|
||||
assert curve[1][1] == (40.0 * mm_h, 400.0 * 0.02)
|
||||
|
||||
|
||||
def test_mass_haul_balloon_texts():
|
||||
"""balloon 표기를 납품 도면에 맞춘다 — 종무대·등호 뒤 한 칸·M.N 소수 2자리
|
||||
(2026-09-03 사용자 확정 8행)."""
|
||||
labels = _texts(build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul"))
|
||||
assert "종무대" in labels # 장비명 뒤 숫자는 정체 미확인이라 적지 않는다
|
||||
assert "Q= 600.00M3" in labels
|
||||
assert "L= 18.50M" in labels
|
||||
assert "EA= 400.00M3" in labels
|
||||
assert "사토 1" in labels
|
||||
assert "M.N= 1+10.00" in labels # 측점 간격 20m 기준 30m 지점
|
||||
assert "유 토 곡 선" in labels
|
||||
assert "SCALE H=1:500 V=1:50,000" in labels # 40m 노선의 자동 축척
|
||||
|
||||
|
||||
def test_mass_haul_table_marks_match_delivery_form():
|
||||
"""측점은 `n+ 0.0`, 행 이름은 자간을 벌린 큰 글씨, 눈금은 정규 빨강·추가 회색."""
|
||||
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
|
||||
labels = _texts(drawing)
|
||||
assert "1+ 0.0" in labels and "2+ 0.0" in labels
|
||||
assert not any(text.startswith("No.") for text in labels)
|
||||
assert "누 가 토 량" in labels and "측 점" in labels
|
||||
# 눈금은 표 레이어의 짧은 세로선 — 정규 측점은 빨강(#ff4d4d).
|
||||
ticks = [
|
||||
entity
|
||||
for entity in drawing["entities"]
|
||||
if entity.get("layerId") == "b08-masshaul-table"
|
||||
and entity.get("type") == "Line"
|
||||
and entity.get("lineColor") == "#ff4d4d"
|
||||
]
|
||||
assert ticks, "표 눈금이 없다"
|
||||
for tick in ticks:
|
||||
shape = tick["shapeData"]
|
||||
assert abs(shape["startPoint"]["x"] - shape["endPoint"]["x"]) < 1e-9 # 세로선
|
||||
assert abs(shape["startPoint"]["y"] - shape["endPoint"]["y"]) == 2.0
|
||||
|
||||
|
||||
def test_mass_haul_leader_is_stepped_from_corner():
|
||||
"""지시선은 balloon 모서리에서 나가는 계단형(가로 → 세로) 폴리선이다."""
|
||||
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
|
||||
# 지시선은 띠 레이어의 자식 2개짜리 PolyLine(가로 → 대각) 이다.
|
||||
leaders = [
|
||||
entity
|
||||
for entity in drawing["entities"]
|
||||
if entity.get("type") == "PolyLine"
|
||||
and entity.get("layerId") == "b08-masshaul-band"
|
||||
and len(entity.get("children") or []) == 2
|
||||
]
|
||||
assert leaders, "지시선이 없다"
|
||||
for leader in leaders:
|
||||
first = leader["children"][0]["shapeData"]
|
||||
assert abs(first["startPoint"]["y"] - first["endPoint"]["y"]) < 1e-9 # 첫 구간은 수평
|
||||
|
||||
|
||||
def test_mass_haul_band_chords_sit_at_stored_levels():
|
||||
"""경계현은 level_base, 운반현은 띠 중간 높이에 저장값 그대로 놓인다."""
|
||||
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
|
||||
chords = _lines(drawing, "b08-masshaul-band")
|
||||
mm_h = 1000.0 / auto_scale_h(40.0)
|
||||
assert ((0.0, 0.0), (40.0 * mm_h, 0.0)) in chords # 경계현 level_base=0
|
||||
assert ((6.0 * mm_h, 300.0 * 0.02), (34.0 * mm_h, 300.0 * 0.02)) in chords # 중간 300㎥
|
||||
|
||||
|
||||
BASINS = [
|
||||
{
|
||||
"ring": [(100.0, 100.0), (400.0, 100.0), (400.0, 400.0), (100.0, 400.0)],
|
||||
"props": {
|
||||
"kind": "detail_basin",
|
||||
"index": 1,
|
||||
"chainage_m": 120.0,
|
||||
"area_m2": 15000.0,
|
||||
"relief_m": 165.0,
|
||||
"flow_length_m": 210.0,
|
||||
"pipe_diameter_mm": 800,
|
||||
"bridge_required": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_watershed_info_box_matches_delivery_form():
|
||||
"""정보표는 유역면적 ha 환산·유역표고·유하거리·배수규격을 납품 표기로 적는다."""
|
||||
drawing = build_watershed_drawing(
|
||||
"watershed",
|
||||
route_xy=[(0.0, 0.0), (500.0, 0.0)],
|
||||
basins=BASINS,
|
||||
contours=[[(0.0, 50.0), (500.0, 60.0)]],
|
||||
streams=[[(200.0, 0.0), (200.0, 400.0)]],
|
||||
interval_m=20.0,
|
||||
)
|
||||
labels = _texts(drawing)
|
||||
# 측점 표기는 토적도와 같은 `n+ 0.0`(2026-09-03 사용자 확정 — 유역도까지 통일).
|
||||
assert "(1) 6+ 0.0" in labels
|
||||
assert "배수규격 Φ800mm" in labels
|
||||
assert "1.50 ha" in labels # 15,000㎡
|
||||
assert "165.0 m" in labels
|
||||
assert "210.0 m" in labels
|
||||
assert "수 리 집 수 면 적 유 역 도" in labels
|
||||
assert "S = 1/6,000" in labels
|
||||
|
||||
|
||||
def test_watershed_route_uses_plan_scale():
|
||||
"""평면 좌표는 콘텐츠 최소점을 원점으로 1/6,000 (1m = 1/6mm)로 옮겨진다."""
|
||||
drawing = build_watershed_drawing(
|
||||
"watershed",
|
||||
route_xy=[(0.0, 0.0), (600.0, 0.0)],
|
||||
basins=[],
|
||||
contours=[],
|
||||
streams=[],
|
||||
)
|
||||
route = _lines(drawing, "b08-basin-route")
|
||||
assert route[0][0] == (0.0, 0.0)
|
||||
assert abs(route[0][1][0] - 100.0) < 1e-9 # 600m / 6 = 100mm
|
||||
|
||||
|
||||
def test_clip_line_to_box_splits_outside_runs():
|
||||
"""도엽 등고선처럼 긴 선은 범위 안 구간만 남고, 끝점은 경계 위에 정확히 놓인다.
|
||||
|
||||
2026-08-31 이전에는 경계 바깥 점을 하나씩 물고 나와 외곽이 들쭉날쭉했다.
|
||||
지금은 교차점을 계산해 자른다(Liang-Barsky).
|
||||
"""
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import clip_line_to_box
|
||||
|
||||
box = (0.0, 0.0, 10.0, 10.0)
|
||||
line = [(-5.0, 5.0), (2.0, 5.0), (5.0, 5.0), (20.0, 5.0), (30.0, 5.0), (8.0, 5.0)]
|
||||
runs = clip_line_to_box(line, box)
|
||||
assert runs == [
|
||||
[(0.0, 5.0), (2.0, 5.0), (5.0, 5.0), (10.0, 5.0)],
|
||||
[(10.0, 5.0), (8.0, 5.0)],
|
||||
]
|
||||
assert clip_line_to_box([(50.0, 50.0), (60.0, 60.0)], box) == []
|
||||
# 남은 점이 상자를 벗어나지 않는다
|
||||
for run in runs:
|
||||
for x, y in run:
|
||||
assert 0.0 - 1e-9 <= x <= 10.0 + 1e-9
|
||||
assert 0.0 - 1e-9 <= y <= 10.0 + 1e-9
|
||||
|
||||
|
||||
def test_watershed_diameter_label_snaps_to_standard_size():
|
||||
"""소요 관경(Φ929)만 있는 옛 저장본도 도면에는 규격관(Φ1000)으로 적는다."""
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _diameter_label
|
||||
|
||||
assert _diameter_label({"pipe_diameter_mm": 929.4}) == "Φ1000mm"
|
||||
assert (
|
||||
_diameter_label({"pipe_diameter_mm": 929.4, "recommended_diameter_mm": 1200}) == "Φ1200mm"
|
||||
)
|
||||
assert _diameter_label({"pipe_diameter_mm": 2000}) == "Φ1500mm"
|
||||
assert _diameter_label({"bridge_required": True, "pipe_diameter_mm": 1600}) == "물넘이포장"
|
||||
|
||||
|
||||
def test_watershed_number_badge_is_circle_with_disc():
|
||||
"""유역 번호는 흰 원판(solid 해치) + 원 테두리 + 숫자로 그려 해칭 위에서도 읽힌다."""
|
||||
drawing = build_watershed_drawing(
|
||||
"watershed", route_xy=[(0.0, 0.0), (500.0, 0.0)], basins=BASINS, contours=[], streams=[]
|
||||
)
|
||||
# 방위표 템플릿에도 원이 있으므로 유역 레이어만 센다.
|
||||
circles = [
|
||||
e
|
||||
for e in drawing["entities"]
|
||||
if e.get("type") == "Circle" and e.get("layerId") == "b08-basin-area"
|
||||
]
|
||||
assert len(circles) == 1
|
||||
assert circles[0]["shapeData"]["radius"] == 3.6
|
||||
disc = [
|
||||
e
|
||||
for e in drawing["entities"]
|
||||
if e.get("type") == "Hatch" and e["shapeData"]["options"]["style"] == "solid"
|
||||
]
|
||||
assert len(disc) == 1
|
||||
assert "1" in _texts(drawing)
|
||||
|
||||
|
||||
def test_watershed_basin_has_hatch():
|
||||
"""유역마다 격자 해칭(Hatch 엔티티)이 경계선과 함께 들어간다."""
|
||||
drawing = build_watershed_drawing(
|
||||
"watershed", route_xy=[(0.0, 0.0), (500.0, 0.0)], basins=BASINS, contours=[], streams=[]
|
||||
)
|
||||
hatches = [
|
||||
e
|
||||
for e in drawing["entities"]
|
||||
if e.get("type") == "Hatch" and e["shapeData"]["options"]["style"] == "cross"
|
||||
]
|
||||
assert len(hatches) == 1
|
||||
shape = hatches[0]["shapeData"]
|
||||
assert shape["options"]["style"] == "cross"
|
||||
assert len(shape["points"]) == 4
|
||||
assert hatches[0]["layerId"] == "b08-basin-area"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""표제란 SVG 서명을 CAD 폴리선으로 바꾸는 경로 검사 (2026-09-03 사용자 결정).
|
||||
|
||||
종전에는 `ImageEntity`(data URL)로 들어가 DXF 로 나가면 래스터가 됐다. SVG 만 벡터로
|
||||
바꾸고 PNG·JPG 는 그대로 그림으로 둔다.
|
||||
"""
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import _vectorize_svg_image
|
||||
|
||||
SIGN_SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">'
|
||||
'<path d="M 10 40 L 30 10 L 50 40"/>'
|
||||
'<polyline points="60,10 90,10 90,40"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _image_entity(payload: str, mime: str = "image/svg+xml") -> dict:
|
||||
return {
|
||||
"id": "sign-1",
|
||||
"type": "Image",
|
||||
"lineColor": "#101010",
|
||||
"lineWidth": 1,
|
||||
"layerId": "title",
|
||||
"shapeData": {
|
||||
"points": [
|
||||
{"x": 10.0, "y": 20.0},
|
||||
{"x": 50.0, "y": 20.0},
|
||||
{"x": 50.0, "y": 40.0},
|
||||
{"x": 10.0, "y": 40.0},
|
||||
],
|
||||
"imageData": f"data:{mime};base64,{b64encode(payload.encode()).decode()}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_svg_polylines_reads_path_and_polyline():
|
||||
view_box, runs = svg_polylines(SIGN_SVG)
|
||||
assert view_box == (0.0, 0.0, 100.0, 50.0)
|
||||
assert len(runs) == 2
|
||||
assert runs[0][0] == (10.0, 40.0) and runs[0][-1] == (50.0, 40.0)
|
||||
|
||||
|
||||
def test_fit_keeps_aspect_and_flips_y():
|
||||
"""비율을 지키고 세로를 뒤집는다 — SVG 는 y 가 아래로, 도면은 위로 자란다."""
|
||||
view_box, runs = svg_polylines(SIGN_SVG)
|
||||
fitted = fit_polylines(runs, view_box, (10.0, 20.0, 50.0, 40.0))
|
||||
xs = [x for run in fitted for x, _y in run]
|
||||
ys = [y for run in fitted for _x, y in run]
|
||||
assert 10.0 - 1e-9 <= min(xs) and max(xs) <= 50.0 + 1e-9
|
||||
assert 20.0 - 1e-9 <= min(ys) and max(ys) <= 40.0 + 1e-9
|
||||
# SVG 에서 y=10(위)이던 꼭짓점이 도면에서는 가장 높은 y 가 된다.
|
||||
assert fitted[0][1][1] > fitted[0][0][1]
|
||||
|
||||
|
||||
def test_image_entity_becomes_polylines():
|
||||
result = _vectorize_svg_image(_image_entity(SIGN_SVG))
|
||||
assert all(entity["type"] == "PolyLine" for entity in result)
|
||||
assert len(result) == 2
|
||||
assert all(entity["layerId"] == "title" for entity in result)
|
||||
assert result[0]["children"], "폴리선에 선분이 없다"
|
||||
|
||||
|
||||
def test_raster_image_is_left_alone():
|
||||
raster = _image_entity("not-svg", mime="image/png")
|
||||
assert _vectorize_svg_image(raster) == [raster]
|
||||
|
||||
|
||||
def test_broken_svg_keeps_image():
|
||||
broken = _image_entity("<svg><path d=")
|
||||
assert _vectorize_svg_image(broken) == [broken]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""사토장이 서면 사토 운반거리가 **측점에서 나온다** (2026-09-09 사용자 확정 ③).
|
||||
|
||||
「기존 구조물 측점이나 규칙 측점에만 들어가는 게 맞아」 ⇒ 사토장 자리가 측점이라
|
||||
거리는 **발생점 → 사토장 측점** 누가거리로 그냥 나온다. 가정할 것이 없다.
|
||||
|
||||
⚠ 사토장이 없으면 설계 입력값으로 되돌아간다 — **임의 거리를 넣지 않는다**.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import ( # noqa: E402
|
||||
_spoil_of,
|
||||
_spoil_sites,
|
||||
)
|
||||
|
||||
_PLAN = {
|
||||
"spoil_m3": 300.0,
|
||||
"natural_spoil_m3": 0.0,
|
||||
"residuals": [
|
||||
{"kind": "spoil", "from_m": 0.0, "to_m": 40.0, "volume_m3": 100.0, "natural_m3": 0.0},
|
||||
{"kind": "spoil", "from_m": 200.0, "to_m": 240.0, "volume_m3": 200.0, "natural_m3": 0.0},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _designs(*, extra: float | None = None) -> list[dict]:
|
||||
rows = []
|
||||
for chainage in (100.0, 120.0, 140.0):
|
||||
rows.append(
|
||||
{
|
||||
"chainage_m": chainage,
|
||||
"design": {
|
||||
"spoil_fill_area_m2": 12.0,
|
||||
"spoil_fill_structure_id": "sp-1",
|
||||
"spoil_fill_capacity_m3": 500.0,
|
||||
"spoil_fill_placed_m3": 480.0,
|
||||
"spoil_fill_unplaced_m3": 20.0,
|
||||
"spoil_fill_extra_distance_m": extra,
|
||||
},
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_사토장_구간을_한_덩어리로_접는다() -> None:
|
||||
sites = _spoil_sites(_designs())
|
||||
assert len(sites) == 1
|
||||
assert sites[0]["from_m"] == 100.0
|
||||
assert sites[0]["to_m"] == 140.0
|
||||
assert sites[0]["center_m"] == 120.0
|
||||
assert sites[0]["placed_m3"] == 480.0
|
||||
|
||||
|
||||
def test_거리는_발생점에서_사토장_측점까지다() -> None:
|
||||
spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs()))
|
||||
# 발생점 20m(100㎥) → 100m · 발생점 220m(200㎥) → 100m ⇒ 가중평균 100m
|
||||
assert spoil["distance_m"] == pytest.approx(100.0)
|
||||
assert spoil["distance_basis"] == "사토장(측점) 기준"
|
||||
assert "사토장 측점" in spoil["note"]
|
||||
|
||||
|
||||
def test_추가_운반거리는_항상_더한다() -> None:
|
||||
spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs(extra=50.0)))
|
||||
assert spoil["distance_m"] == pytest.approx(150.0)
|
||||
|
||||
|
||||
def test_못_담는_몫을_사유로_드러낸다() -> None:
|
||||
spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs()))
|
||||
assert "못 담는" in spoil["note"]
|
||||
assert spoil["sites"][0]["unplaced_m3"] == 20.0
|
||||
|
||||
|
||||
def test_사토장이_없으면_설계_입력값으로_돌아간다() -> None:
|
||||
"""임의 거리를 넣지 않는다 — 값이 없으면 막히는 것이 맞다."""
|
||||
spoil = _spoil_of(_PLAN, {"spoil_site_distance_m": 900.0}, [])
|
||||
assert spoil["distance_m"] == 900.0
|
||||
assert spoil["distance_basis"] == "설계 입력값"
|
||||
blocked = _spoil_of(_PLAN, {}, [])
|
||||
assert blocked["distance_m"] is None
|
||||
@@ -0,0 +1,137 @@
|
||||
"""소단 + 다중 무릎이 **끝나는지** — 화면이 멈추던 자리(2026-09-07 실사고).
|
||||
|
||||
무슨 일이 있었나 — 사용자 공용 브라우저에서 **소단을 한 건 놓자 화면이 25분 넘게 멈췄다**
|
||||
(렌더러가 1.4코어를 계속 태움). 서버(파이썬)로 같은 측점을 돌리면 0.01초에 끝나 서버는 멀쩡했다.
|
||||
|
||||
원인 — `cut_profile_points` 의 무릎 가지에서 **제자리 뒤집기**. 암 경계선의 기울기가
|
||||
**암 경사와 토사 경사 사이**면
|
||||
· 토사로 바꾸면 다음 걸음이 경계 **아래** → 「다시 암」
|
||||
· 암으로 바꾸면 다음 걸음이 경계 **위** → 「다시 토사」
|
||||
가 되고, 이때 보간 비율 `share` 가 0 이라 `knee_dist == dist` — **한 걸음도 안 나간다.**
|
||||
`while dist < limit` 이 끝나지 않는다. `multi_knee` 는 **소단이 있으면 항상 참**이라
|
||||
소단이 곧 지뢰였다.
|
||||
|
||||
고침 — 무릎은 **앞으로 나아갈 때만** 인정한다(직전 무릎과 같은 자리면 뒤집지 않고 한 걸음 간다).
|
||||
파이썬·TS 짝 둘 다 같은 모양으로 넣었다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from common_util.common_util_cross_berm import BermSpec, cut_profile_points # noqa: E402
|
||||
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
# 경계선 기울기를 두 설계 경사 **사이**에 둔다 — 암 1:0.4 는 수평 1m 에 2.5m 오르고
|
||||
# 토사 1:1.0 은 1.0m 오른다. 그 사이(1.5m)면 어느 쪽으로 바꿔도 경계를 다시 넘는다.
|
||||
BOUNDARY_RISE = 1.5
|
||||
CUT_RATIO, SOIL_CUT_RATIO = 0.4, 1.0
|
||||
BERM = BermSpec(0.5, 3.0, 0.0)
|
||||
MAX_REACH_M = 50.0
|
||||
|
||||
|
||||
def _boundary(dist: float) -> float:
|
||||
return 100.0 + dist * BOUNDARY_RISE
|
||||
|
||||
|
||||
def _run_with_timeout(seconds: float):
|
||||
"""별도 스레드로 돌려 **끝나는지**를 본다 — 안 끝나면 그 자체가 결함이다."""
|
||||
box: dict[str, object] = {}
|
||||
|
||||
def work() -> None:
|
||||
box["points"] = cut_profile_points(
|
||||
0.0, 100.0, CUT_RATIO, SOIL_CUT_RATIO, _boundary, BERM, MAX_REACH_M, True
|
||||
)
|
||||
|
||||
thread = threading.Thread(target=work, daemon=True)
|
||||
thread.start()
|
||||
thread.join(seconds)
|
||||
return None if thread.is_alive() else box.get("points")
|
||||
|
||||
|
||||
def test_경계_기울기가_두_경사_사이여도_끝난다():
|
||||
points = _run_with_timeout(8.0)
|
||||
assert points is not None, "8초 안에 안 끝남 — 제자리 무릎이 되풀이되고 있다(화면이 멈춘다)"
|
||||
assert len(points) > 2
|
||||
|
||||
|
||||
def test_꼭짓점이_뒤로_가지_않는다():
|
||||
"""무릎을 찍든 소단을 놓든 거리는 **늘 앞으로**만 간다."""
|
||||
points = _run_with_timeout(8.0)
|
||||
assert points is not None
|
||||
for before, after in zip(points, points[1:], strict=False):
|
||||
assert after[0] >= before[0] - 1e-9, f"거리가 뒤로 감: {before} → {after}"
|
||||
|
||||
|
||||
def test_소단이_없으면_종전_그대로():
|
||||
"""이 고침이 소단 없는 경로(무릎 한 번)를 건드리지 않았는지."""
|
||||
single = cut_profile_points(
|
||||
0.0, 100.0, CUT_RATIO, SOIL_CUT_RATIO, _boundary, None, MAX_REACH_M, False
|
||||
)
|
||||
assert single[0] == (0.0, 100.0)
|
||||
assert single[-1][0] > single[0][0]
|
||||
|
||||
|
||||
def test_두_파일이_같은_방식으로_막는다():
|
||||
"""짝이 갈리면 화면(TS)만 멈추고 서버(파이썬)는 멀쩡한 오늘 같은 일이 또 난다."""
|
||||
py = (PROJECT_ROOT / "common_util" / "common_util_cross_berm.py").read_text(encoding="utf-8")
|
||||
ts = (PROJECT_ROOT / "common_util" / "common_util_cross_berm.ts").read_text(encoding="utf-8")
|
||||
assert "last_knee_dist" in py and "knee_dist > last_knee_dist" in py
|
||||
assert "lastKneeDist" in ts and "kneeDist > lastKneeDist" in ts
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_TS_짝도_끝난다(tmp_path: Path):
|
||||
"""실제로 멈춘 것은 **화면(TS)** 이었다 — 그쪽도 끝나는지 직접 돌려 본다."""
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_cross_berm.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
runner = out / "runner.mjs"
|
||||
runner.write_text(
|
||||
"""
|
||||
import { cutProfilePoints } from "./common_util_cross_berm.js";
|
||||
const boundary = (d) => 100 + d * %s;
|
||||
const points = cutProfilePoints(0, 100, %s, %s, boundary, { widthM: 0.5, intervalM: 3, slopeDeg: 0 }, %s, true);
|
||||
console.log(JSON.stringify({ n: points.length, last: points[points.length - 1] }));
|
||||
"""
|
||||
% (BOUNDARY_RISE, CUT_RATIO, SOIL_CUT_RATIO, MAX_REACH_M),
|
||||
encoding="utf-8",
|
||||
)
|
||||
done = subprocess.run( # noqa: S603
|
||||
["node", str(runner)], cwd=str(PROJECT_ROOT), capture_output=True, timeout=20, text=True
|
||||
)
|
||||
assert done.returncode == 0, done.stderr[:400]
|
||||
ts = json.loads(done.stdout.strip().splitlines()[-1])
|
||||
|
||||
# 짝 대조 — 같은 기하에서 **꼭짓점 수와 끝점이 같아야** 한다. 한쪽만 고치면 여기서 걸린다.
|
||||
py_points = _run_with_timeout(8.0)
|
||||
assert py_points is not None
|
||||
assert ts["n"] == len(py_points), f"꼭짓점 수가 갈림 — TS {ts['n']} vs 파이썬 {len(py_points)}"
|
||||
assert abs(ts["last"][0] - py_points[-1][0]) < 1e-6
|
||||
assert abs(ts["last"][1] - py_points[-1][1]) < 1e-6
|
||||
@@ -0,0 +1,68 @@
|
||||
// 수확기 rect 변환(B07_DesignDetail_UI_Cad_Structures.rectPoints) 자체검증 — 로직 복제.
|
||||
// 돌쌓기 돌·돌망태 칸은 rect + rotate(벽기울기) 로 그린다. 회전을 빠뜨리면 돌이 벽을
|
||||
// 삐져나가 클립에 전부 잘려 나간다(2026-09-03 실측: rect 49개가 CAD 에 안 실렸다).
|
||||
// 실행: node tmp/tests/test_cad_rect_harvest.mjs
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const flip = (x, y) => [x, -y];
|
||||
|
||||
function rectPoints(attrs, transform) {
|
||||
const { x, y, width, height } = attrs;
|
||||
const corners = [
|
||||
[x, y],
|
||||
[x + width, y],
|
||||
[x + width, y + height],
|
||||
[x, y + height],
|
||||
[x, y],
|
||||
];
|
||||
const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec(transform ?? "");
|
||||
if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy));
|
||||
const angle = (Number(rotate[1]) * Math.PI) / 180;
|
||||
const [ox, oy] = [Number(rotate[2]), Number(rotate[3])];
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return corners.map(([cx, cy]) => {
|
||||
const dx = cx - ox;
|
||||
const dy = cy - oy;
|
||||
return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos);
|
||||
});
|
||||
}
|
||||
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-9;
|
||||
|
||||
// ① 회전이 없으면 네 모서리를 y만 뒤집어 닫힌 점열로 낸다.
|
||||
{
|
||||
const points = rectPoints({ x: 10, y: 20, width: 4, height: 6 }, null);
|
||||
assert.equal(points.length, 5);
|
||||
assert.deepEqual(points[0], [10, -20]);
|
||||
assert.deepEqual(points[2], [14, -26]);
|
||||
assert.deepEqual(points[0], points[4], "닫힌 점열이어야 한다");
|
||||
}
|
||||
|
||||
// ② rotate(90 cx cy) — 중심 기준 90° 회전. 폭·높이가 맞바뀐다.
|
||||
{
|
||||
const points = rectPoints({ x: -1, y: -2, width: 2, height: 4 }, "rotate(90 0 0)");
|
||||
const xs = points.map((p) => p[0]);
|
||||
const ys = points.map((p) => p[1]);
|
||||
assert.ok(near(Math.max(...xs) - Math.min(...xs), 4), "가로가 원래 세로가 된다");
|
||||
assert.ok(near(Math.max(...ys) - Math.min(...ys), 2), "세로가 원래 가로가 된다");
|
||||
}
|
||||
|
||||
// ③ 회전 중심은 제자리에 남는다 — 벽 기울기만큼 기울여도 돌 중심이 안 움직인다.
|
||||
{
|
||||
const attrs = { x: 8, y: 12, width: 4, height: 2 };
|
||||
const center = [attrs.x + attrs.width / 2, attrs.y + attrs.height / 2];
|
||||
const points = rectPoints(attrs, `rotate(-16.7 ${center[0]} ${center[1]})`);
|
||||
const xs = points.map((p) => p[0]);
|
||||
const ys = points.map((p) => p[1]);
|
||||
assert.ok(near((Math.min(...xs) + Math.max(...xs)) / 2, center[0]));
|
||||
assert.ok(near((Math.min(...ys) + Math.max(...ys)) / 2, -center[1]));
|
||||
}
|
||||
|
||||
// ④ 소수·쉼표 구분자도 읽는다 — SVG가 `rotate(-16.7,120.5,88.25)` 로 쓰기도 한다.
|
||||
{
|
||||
const points = rectPoints({ x: 0, y: 0, width: 2, height: 2 }, "rotate(-16.7,1,1)");
|
||||
assert.ok(!near(points[0][0], 0), "회전이 실제로 적용돼야 한다");
|
||||
}
|
||||
|
||||
console.log("OK test_cad_rect_harvest.mjs — 4건");
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,70 @@
|
||||
// 줌·팬 시 축 눈금이 도형과 맞는지 — Cross_Axes의 역산·배치 로직 복제 검증.
|
||||
// 실행: node tmp/tests/test_cross_axes_zoom.mjs
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 };
|
||||
function niceTickStep(span, targetCount, maxCount) {
|
||||
if (!(span > 0)) return 1;
|
||||
const exponent = Math.floor(Math.log10(span / targetCount));
|
||||
let best = Math.pow(10, exponent + 2);
|
||||
let bestError = Infinity;
|
||||
for (const power of [exponent - 1, exponent, exponent + 1, exponent + 2]) {
|
||||
for (const mantissa of [1, 2, 5]) {
|
||||
const step = mantissa * Math.pow(10, power);
|
||||
const count = Math.floor(span / step) + 1;
|
||||
if (count > Math.max(2, maxCount)) continue;
|
||||
const error = Math.abs(count - targetCount);
|
||||
if (error < bestError) {
|
||||
bestError = error;
|
||||
best = step;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const widthPx = 630;
|
||||
const heightPx = 250;
|
||||
const ppm = (widthPx - CROSS_PAD.left - CROSS_PAD.right) / 24; // 반폭 12m
|
||||
const maxOffset = 12;
|
||||
const plotLeft = CROSS_PAD.left;
|
||||
const plotRight = widthPx - CROSS_PAD.right;
|
||||
const x = (offset) => plotLeft + (maxOffset - offset) * ppm;
|
||||
|
||||
function xTicks({ scale, tx }) {
|
||||
const offsetAt = (px) => maxOffset - ((px - tx) / scale - plotLeft) / ppm;
|
||||
const high = offsetAt(plotLeft);
|
||||
const low = offsetAt(plotRight);
|
||||
const step = niceTickStep(high - low, 7, Math.floor((plotRight - plotLeft) / 48));
|
||||
const out = [];
|
||||
for (let o = Math.ceil(low / step) * step; o <= high + 1e-9; o += step) {
|
||||
out.push({ offset: o, screenX: tx + scale * x(o) });
|
||||
}
|
||||
return { out, step, low, high };
|
||||
}
|
||||
|
||||
// 원배율: 눈금이 플롯 안, 라벨 자리 = 도형 자리.
|
||||
const base = xTicks({ scale: 1, tx: 0 });
|
||||
assert.ok(base.out.length >= 4, `원배율 눈금 부족 ${base.out.length}`);
|
||||
for (const t of base.out) {
|
||||
assert.ok(t.screenX >= plotLeft - 1e-6 && t.screenX <= plotRight + 1e-6, "원배율 눈금 이탈");
|
||||
assert.ok(Math.abs(t.screenX - x(t.offset)) < 1e-9, "원배율 자리 어긋남");
|
||||
}
|
||||
|
||||
// 4배 확대(중앙 고정): 보이는 범위가 1/4로 좁아지고, 눈금 자리가 변환된 도형 자리와 같아야 한다.
|
||||
const scale = 4;
|
||||
const cx = plotLeft + (plotRight - plotLeft) / 2;
|
||||
const tx = cx - cx * scale;
|
||||
const zoomed = xTicks({ scale, tx });
|
||||
assert.ok(
|
||||
Math.abs(zoomed.high - zoomed.low - 24 / scale) < 1e-6,
|
||||
`확대 범위 오류 ${zoomed.high - zoomed.low}`,
|
||||
);
|
||||
assert.ok(zoomed.step < base.step, `확대인데 눈금 간격이 안 촘촘해짐 ${zoomed.step}`);
|
||||
assert.ok(zoomed.out.length >= 4, `확대 눈금 부족 ${zoomed.out.length}`);
|
||||
for (const t of zoomed.out) {
|
||||
assert.ok(t.screenX >= plotLeft - 1e-6 && t.screenX <= plotRight + 1e-6, "확대 눈금 이탈");
|
||||
// 도형은 transform으로 옮겨 그려진다 — 같은 offset의 도형 자리와 눈금 자리가 일치해야 한다.
|
||||
assert.ok(Math.abs(t.screenX - (tx + scale * x(t.offset))) < 1e-9, "확대 자리 어긋남");
|
||||
}
|
||||
console.log("ok");
|
||||
@@ -0,0 +1,57 @@
|
||||
// 확대 상태의 팬 한계가 캐시 보유 범위까지 늘어나는지 — Cross_View_Zoom.clampPan 로직 복제.
|
||||
// 실행: node tmp/tests/test_cross_pan_bounds.mjs
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const plot = { x: 58, y: 10, width: 552, height: 188 };
|
||||
// 표시 반폭 12m(=플롯 폭)보다 넓은 캐시 20m — 좌우로 각각 플롯 폭의 2/3만큼 더 있다.
|
||||
const content = { x: plot.x - 368, y: plot.y - 100, width: plot.width + 736, height: plot.height + 200 };
|
||||
|
||||
function clampAxis(value, scale, start, size, from, to) {
|
||||
const lo = start + size - scale * to;
|
||||
const hi = start - scale * from;
|
||||
return Math.min(Math.max(value, Math.min(lo, hi)), Math.max(lo, hi));
|
||||
}
|
||||
function clampPan(tx, ty, scale, useContent = true) {
|
||||
const bounds =
|
||||
useContent && scale > 1 + 1e-6
|
||||
? {
|
||||
x: Math.min(content.x, plot.x),
|
||||
y: Math.min(content.y, plot.y),
|
||||
right: Math.max(content.x + content.width, plot.x + plot.width),
|
||||
bottom: Math.max(content.y + content.height, plot.y + plot.height),
|
||||
}
|
||||
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
|
||||
return {
|
||||
tx: clampAxis(tx, scale, plot.x, plot.width, bounds.x, bounds.right),
|
||||
ty: clampAxis(ty, scale, plot.y, plot.height, bounds.y, bounds.bottom),
|
||||
};
|
||||
}
|
||||
// 화면 창이 보는 원배율 좌표 구간.
|
||||
const windowSpan = (tx, scale) => [(plot.x - tx) / scale, (plot.x + plot.width - tx) / scale];
|
||||
|
||||
// 원배율: 이동 없음(기존 규칙 유지).
|
||||
assert.deepEqual(clampPan(200, 200, 1), { tx: 0, ty: 0 });
|
||||
|
||||
// 2배 확대: 캐시 왼쪽 끝까지 끌 수 있고, 창이 캐시 범위를 벗어나지 않는다.
|
||||
const scale = 2;
|
||||
const far = clampPan(100000, 100000, scale);
|
||||
const [leftFrom] = windowSpan(far.tx, scale);
|
||||
assert.ok(Math.abs(leftFrom - content.x) < 1e-6, `왼쪽 한계가 캐시 끝이 아님: ${leftFrom}`);
|
||||
const farNeg = clampPan(-100000, -100000, scale);
|
||||
const [, rightTo] = windowSpan(farNeg.tx, scale);
|
||||
assert.ok(
|
||||
Math.abs(rightTo - (content.x + content.width)) < 1e-6,
|
||||
`오른쪽 한계가 캐시 끝이 아님: ${rightTo}`,
|
||||
);
|
||||
// 캐시 범위 밖으로는 못 나간다.
|
||||
for (const raw of [-5000, -800, -100, 0, 100, 800, 5000]) {
|
||||
const { tx } = clampPan(raw, 0, scale);
|
||||
const [from, to] = windowSpan(tx, scale);
|
||||
assert.ok(from >= content.x - 1e-6, `왼쪽 이탈 ${from}`);
|
||||
assert.ok(to <= content.x + content.width + 1e-6, `오른쪽 이탈 ${to}`);
|
||||
}
|
||||
// content가 없으면(옛 규칙) 확대해도 플롯 영역 안으로만 움직인다.
|
||||
const noContent = clampPan(100000, 0, scale, false);
|
||||
const [fitFrom] = windowSpan(noContent.tx, scale);
|
||||
assert.ok(Math.abs(fitFrom - plot.x) < 1e-6, `기본 한계 어긋남 ${fitFrom}`);
|
||||
console.log("ok");
|
||||
@@ -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");
|
||||
@@ -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 # 보정 모수가 살아 있다
|
||||
@@ -0,0 +1,141 @@
|
||||
"""터파기 단면 — 파이썬·TS 짝이 같은 값을 내는지 (2026-09-09).
|
||||
|
||||
화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽이 갈리면
|
||||
「그림은 이런데 수량은 저렇다」가 된다.
|
||||
|
||||
⚠ 근거의 급이 다른 두 갈래를 함께 잠근다 —
|
||||
관은 **법정 표**(KCS 44 40 10 그림 3.2-1), 벽은 **실무 정본 식**(구조도 기슭막이 xls).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from common_util.common_util_excavation import ( # noqa: E402
|
||||
PIPE_TRENCH_WIDTH_MM,
|
||||
WALL_FOUNDATION_DEPTH_M,
|
||||
WALL_FOUNDATION_WIDTH_M,
|
||||
WALL_TRENCH_CLEARANCE_M,
|
||||
pipe_trench_width_m,
|
||||
wall_trench_area_m2,
|
||||
wall_trench_width_m,
|
||||
)
|
||||
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import {
|
||||
PIPE_TRENCH_WIDTH_MM,
|
||||
pipeTrenchWidthM,
|
||||
wallTrenchAreaM2,
|
||||
wallTrenchWidthM,
|
||||
} from "./common_util_excavation.js";
|
||||
|
||||
const out = {
|
||||
table: PIPE_TRENCH_WIDTH_MM,
|
||||
pipe: [300, 800, 1000, 1200, 1500, 250, 1600].map((d) => pipeTrenchWidthM(d)),
|
||||
wallWithFoundation: [1.0, 2.0, 3.0].map((h) => wallTrenchAreaM2(h, 0.7, true)),
|
||||
wallBlindingOnly: [1.0, 2.0, 3.0].map((h) => wallTrenchAreaM2(h, 0.7, false)),
|
||||
wallWidth: [0.7, 1.05, 0].map((t) => wallTrenchWidthM(t)),
|
||||
nulls: [pipeTrenchWidthM(null), wallTrenchAreaM2(null, 0.7, true), wallTrenchAreaM2(1, null, true)],
|
||||
};
|
||||
writeFileSync(process.argv[2], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _ts_values(tmp_path: Path) -> dict:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_excavation.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.glob("*.js"):
|
||||
text = emitted.read_text(encoding="utf-8")
|
||||
emitted.write_text(
|
||||
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp_path / "out.json"
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None:
|
||||
ts = _ts_values(tmp_path)
|
||||
|
||||
assert {int(k): v for k, v in ts["table"].items()} == PIPE_TRENCH_WIDTH_MM
|
||||
assert ts["pipe"] == [pipe_trench_width_m(d) for d in (300, 800, 1000, 1200, 1500, 250, 1600)]
|
||||
assert ts["wallWithFoundation"] == [
|
||||
wall_trench_area_m2(h, 0.7, has_foundation=True) for h in (1.0, 2.0, 3.0)
|
||||
]
|
||||
assert ts["wallBlindingOnly"] == [
|
||||
wall_trench_area_m2(h, 0.7, has_foundation=False) for h in (1.0, 2.0, 3.0)
|
||||
]
|
||||
assert ts["wallWidth"] == [wall_trench_width_m(t) for t in (0.7, 1.05, 0)]
|
||||
assert ts["nulls"] == [None, None, None]
|
||||
|
||||
|
||||
def test_관은_표를_그대로_쓴다() -> None:
|
||||
"""⚠ 관경 + 2b 로 계산하지 않는다 — 표가 관 두께·작업여유를 담고 있다."""
|
||||
assert pipe_trench_width_m(800) == 1.6
|
||||
assert pipe_trench_width_m(800) != 0.8 + 2 * 0.3
|
||||
for diameter in (800, 1000, 1200, 1500):
|
||||
assert pipe_trench_width_m(diameter) is not None, diameter
|
||||
|
||||
|
||||
def test_표에_없는_관경은_지어내지_않는다() -> None:
|
||||
assert pipe_trench_width_m(250) is None
|
||||
assert pipe_trench_width_m(1600) is None
|
||||
|
||||
|
||||
def test_실무_정본_수치와_맞는다() -> None:
|
||||
"""xls 7탭: H=1.0 → 1.03+0.45 = 1.48 · H=2.0 → 2.51 · H=3.0 → 3.54 (평균두께 0.83)."""
|
||||
thickness = 0.83
|
||||
for height, expected in ((1.0, 1.48), (2.0, 2.51), (3.0, 3.54)):
|
||||
area = wall_trench_area_m2(height, thickness, has_foundation=True)
|
||||
assert area == pytest.approx(expected, abs=0.01), height
|
||||
|
||||
|
||||
def test_기초_유무가_기초분을_가른다() -> None:
|
||||
with_base = wall_trench_area_m2(2.0, 0.7, has_foundation=True)
|
||||
without = wall_trench_area_m2(2.0, 0.7, has_foundation=False)
|
||||
assert with_base - without == pytest.approx(
|
||||
WALL_FOUNDATION_DEPTH_M * WALL_FOUNDATION_WIDTH_M - 0.1 * 0.7
|
||||
)
|
||||
|
||||
|
||||
def test_여유폭은_평균두께에_더한다() -> None:
|
||||
assert wall_trench_width_m(0.7) == pytest.approx(0.7 + WALL_TRENCH_CLEARANCE_M)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""세월교 월류 높이 → 노면 하강(계획고·절성토 면적) 확인 (2026-08-30 사용자 지시).
|
||||
|
||||
`_ford_set`이 월류 높이를 스펙에 싣고, `compute_cross_design(surface_drop_m=)`이
|
||||
계획고를 통째로 내려 절·성토 면적까지 따라가는지 본다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import _ford_set # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
|
||||
DROP_M = 0.39
|
||||
|
||||
|
||||
def _samples():
|
||||
"""V자 계류 — 중심이 낮고 바깥으로 갈수록 높아지는 지반."""
|
||||
return [
|
||||
{"offset_m": index / 2, "elevation_m": 100.0 + abs(index / 2) * 0.3, "valid": True}
|
||||
for index in range(-40, 41)
|
||||
]
|
||||
|
||||
|
||||
def test_ford_set_carries_overflow_depth():
|
||||
spec = _ford_set({"ford_width_m": 10, "ford_height_m": DROP_M, "pipe_count": 1})
|
||||
assert spec["overflow_depth_m"] == DROP_M
|
||||
assert spec["span_m"] == 10
|
||||
# 값이 없으면 내리지 않는다.
|
||||
assert _ford_set({"ford_width_m": 10})["overflow_depth_m"] == 0.0
|
||||
|
||||
|
||||
def test_surface_drop_lowers_plan_and_moves_areas():
|
||||
samples = _samples()
|
||||
common = dict(ground_type="soil", section_mode="left_cut")
|
||||
base = compute_cross_design(samples, 100.0, **common)
|
||||
drop = compute_cross_design(samples, 100.0, surface_drop_m=DROP_M, **common)
|
||||
|
||||
# 계획고·노견이 정확히 월류 높이만큼 내려간다(전폭 평행 하강).
|
||||
assert round(base["design_elevation_m"] - drop["design_elevation_m"], 4) == DROP_M
|
||||
for side in ("left", "right"):
|
||||
gap = base["road_edges"][side]["elevation_m"] - drop["road_edges"][side]["elevation_m"]
|
||||
assert round(gap, 4) == DROP_M
|
||||
|
||||
# 노면이 내려가면 절토가 늘고 성토가 준다.
|
||||
assert drop["cut_area_m2"] > base["cut_area_m2"]
|
||||
assert drop["fill_area_m2"] <= base["fill_area_m2"]
|
||||
|
||||
# 프론트가 점선을 되그릴 수 있게 하강량을 echo한다. 하강이 없으면 키 자체가 없다.
|
||||
assert drop["surface_drop_m"] == DROP_M
|
||||
assert "surface_drop_m" not in base
|
||||
@@ -0,0 +1,95 @@
|
||||
// 세월교 측벽 수직화(B06_Section_UI_Cross_Culvert_Basin `leanRatio`) 자체검증 — 로직 복제.
|
||||
// 2026-08-30 사용자 확정: 세월교 측벽은 수직(교본 그림 3-18). 1:0.3 기움은 ㄴ형
|
||||
// 집수정 부재를 재사용하며 딸려 온 값이라 세월교에서만 끈다(집수정은 그대로).
|
||||
// 실행: node tmp/tests/test_ford_wall_vertical.mjs
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const REVET_LEAN_RATIO = 0.3;
|
||||
|
||||
/* ── 복제: buildBasin의 벽·바닥판 좌표 산식 ─────────────────────────── */
|
||||
function basinParts({ anchorOffset, anchorElevation, outward, wallHeight, memberT, floorT,
|
||||
innerWidthM, leanRatio }) {
|
||||
const lean = leanRatio ?? REVET_LEAN_RATIO;
|
||||
const defaultBottom = anchorElevation - floorT;
|
||||
const wallBase = anchorOffset + outward * (lean * wallHeight);
|
||||
const wallOf = (baseOffset, dir, bottomElevation = defaultBottom) => {
|
||||
const topShift = -dir * lean * wallHeight;
|
||||
const spanZ = wallHeight + floorT;
|
||||
const xAt = (elevation) => baseOffset + topShift * ((elevation - defaultBottom) / spanZ);
|
||||
return [
|
||||
{ offset: xAt(bottomElevation), elevation: bottomElevation },
|
||||
{ offset: baseOffset + topShift, elevation: anchorElevation + wallHeight },
|
||||
{ offset: baseOffset + topShift + dir * memberT, elevation: anchorElevation + wallHeight },
|
||||
{ offset: xAt(bottomElevation) + dir * memberT, elevation: bottomElevation },
|
||||
];
|
||||
};
|
||||
const wallOuterAt = (elevation) => {
|
||||
const bottom = anchorElevation - floorT;
|
||||
const t = (elevation - bottom) / (wallHeight + floorT);
|
||||
return wallBase + outward * memberT - outward * lean * wallHeight * t;
|
||||
};
|
||||
const floorTopInner = wallOuterAt(anchorElevation);
|
||||
const floorOuter = floorTopInner + outward * (innerWidthM + memberT);
|
||||
const floorTaper = lean * floorT;
|
||||
return {
|
||||
wall: wallOf(wallBase, outward),
|
||||
floor: [
|
||||
{ offset: floorTopInner, elevation: anchorElevation },
|
||||
{ offset: floorOuter, elevation: anchorElevation },
|
||||
{ offset: floorOuter - outward * floorTaper, elevation: anchorElevation - floorT },
|
||||
{ offset: wallOuterAt(anchorElevation - floorT), elevation: anchorElevation - floorT },
|
||||
],
|
||||
trimOffset: wallBase - outward * lean * wallHeight,
|
||||
floorLengthM: Math.abs(floorOuter - floorTopInner),
|
||||
};
|
||||
}
|
||||
|
||||
const base = {
|
||||
anchorOffset: 3.0, // 노견 끝
|
||||
anchorElevation: 100.0, // 바닥판 상면
|
||||
outward: 1, // 좌측
|
||||
wallHeight: 1.5,
|
||||
memberT: 0.3, // 벽 두께
|
||||
floorT: 0.3, // 물받이 두께(교본 최소 0.3m)
|
||||
innerWidthM: 1.2, // 날개벽 투영 연장
|
||||
};
|
||||
|
||||
/* ── 세월교(leanRatio 0) — 벽이 수직이어야 한다 ─────────────────────── */
|
||||
const ford = basinParts({ ...base, leanRatio: 0 });
|
||||
const [b0, t0, t1, b1] = ford.wall;
|
||||
assert.ok(Math.abs(t0.offset - b0.offset) < 1e-9, `벽 도로측 면이 안 섰다: ${t0.offset} vs ${b0.offset}`);
|
||||
assert.ok(Math.abs(t1.offset - b1.offset) < 1e-9, `벽 계류측 면이 안 섰다: ${t1.offset} vs ${b1.offset}`);
|
||||
// 벽은 노견 끝에서 그대로 내려간다 — 상단 자리(설계선 트림 경계)는 그대로.
|
||||
assert.ok(Math.abs(t0.offset - base.anchorOffset) < 1e-9);
|
||||
assert.ok(Math.abs(ford.trimOffset - base.anchorOffset) < 1e-9);
|
||||
// 두께는 유지.
|
||||
assert.ok(Math.abs(Math.abs(t1.offset - t0.offset) - base.memberT) < 1e-9);
|
||||
// 바닥판 taper 0 = 직사각형(위·아래 바깥 모서리가 같은 offset).
|
||||
assert.ok(Math.abs(ford.floor[1].offset - ford.floor[2].offset) < 1e-9, "바닥판 taper가 남았다");
|
||||
assert.ok(Math.abs(ford.floor[0].offset - ford.floor[3].offset) < 1e-9, "바닥판 안쪽 변이 기울었다");
|
||||
// 바닥 길이 = 날개벽 투영 연장 + 벽 두께(사용자 유지 지시).
|
||||
assert.ok(
|
||||
Math.abs(ford.floorLengthM - (base.innerWidthM + base.memberT)) < 1e-9,
|
||||
`바닥 길이 어긋남: ${ford.floorLengthM}`,
|
||||
);
|
||||
|
||||
/* ── 집수정(기본값) — 종전 1:0.3 기움 그대로여야 한다 ───────────────── */
|
||||
const basin = basinParts(base);
|
||||
const lean = REVET_LEAN_RATIO * base.wallHeight;
|
||||
assert.ok(
|
||||
Math.abs(basin.wall[1].offset - basin.wall[0].offset - -lean) < 1e-9,
|
||||
"집수정 기움이 바뀌었다 — 집수정은 유지가 원칙",
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(basin.floor[1].offset - basin.floor[2].offset) > 1e-9,
|
||||
"집수정 바닥판 taper가 사라졌다",
|
||||
);
|
||||
// 집수정도 상단 도로측 꼭짓점은 노견 끝(트림 경계)에 있다 — 이 규칙은 공통.
|
||||
assert.ok(Math.abs(basin.trimOffset - base.anchorOffset) < 1e-9);
|
||||
|
||||
/* ── 우측(outward -1)도 같은 규칙 ──────────────────────────────────── */
|
||||
const right = basinParts({ ...base, outward: -1, leanRatio: 0 });
|
||||
assert.ok(Math.abs(right.wall[1].offset - right.wall[0].offset) < 1e-9, "우측 벽이 안 섰다");
|
||||
assert.ok(Math.abs(right.floorLengthM - (base.innerWidthM + base.memberT)) < 1e-9);
|
||||
|
||||
console.log("test_ford_wall_vertical.mjs OK");
|
||||
@@ -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)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""초기값 스냅샷 — 촬영·복원이 작업본을 초기 상태로 되돌리는지 확인한다.
|
||||
|
||||
DB는 aiomysql 대신 최소 가짜 커넥션으로 대신한다. 확인하려는 것은 스냅샷 모듈의
|
||||
규약(무엇을 뜨고, 무엇을 되돌리고, 무엇을 건드리지 않는가)이지 드라이버가 아니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
has_initial_snapshot,
|
||||
restore_initial_snapshot,
|
||||
restore_snapshot_files,
|
||||
save_initial_snapshot,
|
||||
snapshot_dir,
|
||||
)
|
||||
|
||||
ROUTE_ROW = {"id": 7, "project_id": "p1", "status": "CONFIRMED", "total_length_m": 123.4}
|
||||
|
||||
|
||||
class _Cursor:
|
||||
"""SELECT는 준비된 행을, INSERT는 새 id를 돌려주는 최소 커서."""
|
||||
|
||||
def __init__(self, state):
|
||||
self._state = state
|
||||
self.lastrowid = 0
|
||||
self._rows = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, params=None):
|
||||
head = sql.strip().split()[0].upper()
|
||||
if head == "INSERT":
|
||||
self._state["inserted"].append((sql, params))
|
||||
self._state["next_id"] += 1
|
||||
self.lastrowid = self._state["next_id"]
|
||||
return
|
||||
if "FROM routes" in sql:
|
||||
self._rows = [dict(ROUTE_ROW)]
|
||||
else:
|
||||
table = sql.split("FROM ")[1].split()[0]
|
||||
self._rows = list(self._state["children"].get(table, []))
|
||||
|
||||
async def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
async def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, state):
|
||||
self._state = state
|
||||
|
||||
def cursor(self, *_args, **_kwargs):
|
||||
return _Cursor(self._state)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path: Path):
|
||||
"""정본 파일이 든 프로젝트 루트를 만든다."""
|
||||
for tree, name, text in (
|
||||
("B05_Profile/route", "structures.json", '{"v": "initial"}'),
|
||||
("B06_Section/longitudinal", "long.json", "initial"),
|
||||
("B06_Section/cross_sections", "cross.json", "initial"),
|
||||
("B04_PreProcess/drainage/edits", "pipe_points.json", '{"pipes": []}'),
|
||||
):
|
||||
folder = tmp_path / tree
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / name).write_text(text, encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state():
|
||||
return {
|
||||
"inserted": [],
|
||||
"next_id": 100,
|
||||
"children": {
|
||||
"route_points": [{"id": 1, "route_id": 7, "chainage_m": 0.0}],
|
||||
"route_statistics": [{"id": 2, "route_id": 7, "min_slope": 1.0}],
|
||||
"longitudinal_sections": [{"id": 3, "route_id": 7, "project_id": "p1"}],
|
||||
"cross_sections": [{"id": 4, "route_id": 7, "project_id": "p1"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_snapshot_captures_files_and_rows(project, state):
|
||||
assert not has_initial_snapshot(project)
|
||||
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
|
||||
|
||||
assert has_initial_snapshot(project)
|
||||
dump = json.loads((snapshot_dir(project) / "db.json").read_text(encoding="utf-8"))
|
||||
assert dump["routes"][0]["total_length_m"] == 123.4
|
||||
assert len(dump["cross_sections"]) == 1
|
||||
# 파일 트리 4개가 통째로 떠 있어야 한다.
|
||||
# 배수유역은 `edits/` 만이 아니라 **폴더 통째**로 뜬다(2026-09-04 사용자 확정).
|
||||
saved = snapshot_dir(project) / "B04_PreProcess__drainage" / "edits" / "pipe_points.json"
|
||||
assert saved.read_text(encoding="utf-8") == '{"pipes": []}'
|
||||
|
||||
|
||||
def test_snapshot_is_taken_once(project, state):
|
||||
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
|
||||
# 촬영 뒤 사용자가 정본을 고쳐도 스냅샷은 그대로여야 한다(읽기 전용 층).
|
||||
(project / "B05_Profile/route/structures.json").write_text('{"v": "edited"}', encoding="utf-8")
|
||||
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
|
||||
|
||||
kept = snapshot_dir(project) / "B05_Profile__route" / "structures.json"
|
||||
assert json.loads(kept.read_text(encoding="utf-8"))["v"] == "initial"
|
||||
|
||||
|
||||
def test_restore_overwrites_edited_masters(project, state):
|
||||
"""편집분이 남아 초기값이 오염되던 것이 이 복원으로 사라진다."""
|
||||
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
|
||||
edited = project / "B04_PreProcess/drainage/edits/pipe_points.json"
|
||||
edited.write_text('{"pipes": [{"chainage_m": 30}]}', encoding="utf-8")
|
||||
(project / "B05_Profile/route/extra.json").write_text("사용자가 만든 것", encoding="utf-8")
|
||||
|
||||
restore_snapshot_files(project)
|
||||
|
||||
assert edited.read_text(encoding="utf-8") == '{"pipes": []}'
|
||||
# 스냅샷에 없던 파일은 남지 않는다(트리 통째 교체).
|
||||
assert not (project / "B05_Profile/route/extra.json").exists()
|
||||
|
||||
|
||||
def test_restore_reinserts_rows_with_new_route_id(project, state):
|
||||
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
|
||||
connection = _Connection(state)
|
||||
new_id = asyncio.run(restore_initial_snapshot(connection, project, "p1"))
|
||||
|
||||
assert new_id == 101 # routes 1행이 먼저 들어간다
|
||||
tables = [sql.split("INSERT INTO ")[1].split()[0] for sql, _ in state["inserted"]]
|
||||
assert tables == [
|
||||
"routes",
|
||||
"route_points",
|
||||
"route_statistics",
|
||||
"longitudinal_sections",
|
||||
"cross_sections",
|
||||
]
|
||||
# 자식 행은 옛 id가 아니라 새 route_id를 달고 들어가야 한다.
|
||||
child_sql, child_params = state["inserted"][1]
|
||||
columns = child_sql.split("(")[1].split(")")[0].replace("`", "").split(", ")
|
||||
assert child_params[columns.index("route_id")] == new_id
|
||||
assert "id" not in columns # AUTO_INCREMENT 자리는 비워 둔다
|
||||
|
||||
|
||||
def test_restore_without_snapshot_returns_none(tmp_path, state):
|
||||
assert asyncio.run(restore_initial_snapshot(_Connection(state), tmp_path, "p1")) is None
|
||||
|
||||
|
||||
# ── 계산 중 진입 차단 마커 · 스냅샷 무효화 · 편집 정본 제거 (2026-08-29) ──
|
||||
|
||||
|
||||
def test_designing_marker_lifecycle(project):
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
clear_designing,
|
||||
designing_lock_path,
|
||||
is_designing,
|
||||
mark_designing,
|
||||
)
|
||||
|
||||
assert not is_designing(project)
|
||||
mark_designing(project)
|
||||
assert is_designing(project)
|
||||
# 마커는 스냅샷 대상 트리 밖(프로젝트 루트)에 있어야 복원에 딸려 들어가지 않는다.
|
||||
assert designing_lock_path(project).parent == project
|
||||
clear_designing(project)
|
||||
assert not is_designing(project)
|
||||
clear_designing(project) # 두 번 내려도 터지지 않는다
|
||||
|
||||
|
||||
def test_marker_not_captured_by_snapshot(project, state):
|
||||
from common_util.common_util_initial_snapshot import mark_designing
|
||||
|
||||
mark_designing(project)
|
||||
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
|
||||
captured = [p.name for p in snapshot_dir(project).rglob("*") if p.is_file()]
|
||||
assert "initial_design.lock" not in captured
|
||||
|
||||
|
||||
def test_discard_initial_snapshot(project, state):
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
discard_initial_snapshot,
|
||||
has_initial_snapshot,
|
||||
)
|
||||
|
||||
asyncio.run(save_initial_snapshot(_Connection(state), project, 7))
|
||||
assert has_initial_snapshot(project)
|
||||
assert discard_initial_snapshot(project) is True
|
||||
assert not has_initial_snapshot(project)
|
||||
# 없는 것을 지우면 False — 호출부가 로그를 남길지 판단한다.
|
||||
assert discard_initial_snapshot(project) is False
|
||||
|
||||
|
||||
def test_wipe_edited_masters(project):
|
||||
"""폴백 재계산이 진짜 초기값을 만들려면 이 둘이 먼저 사라져야 한다."""
|
||||
from common_util.common_util_initial_snapshot import wipe_edited_masters
|
||||
|
||||
removed = wipe_edited_masters(project)
|
||||
|
||||
assert sorted(removed) == [
|
||||
"B04_PreProcess/drainage/edits",
|
||||
"B05_Profile/route/structures.json",
|
||||
]
|
||||
assert not (project / "B05_Profile/route/structures.json").exists()
|
||||
assert not (project / "B04_PreProcess/drainage/edits").exists()
|
||||
# 다른 정본은 건드리지 않는다.
|
||||
assert (project / "B06_Section/longitudinal/long.json").exists()
|
||||
assert wipe_edited_masters(project) == [] # 두 번 불러도 조용히 끝난다
|
||||
@@ -0,0 +1,63 @@
|
||||
"""B05·B06 유토곡선 일원화 소스 검사 (2026-09-03 사용자 지시).
|
||||
|
||||
같은 데이터를 두 화면에 보여 주는 기능이므로 ① 보는 노선 ② 횡단 재계산 창구
|
||||
③ 낡음 판정 규칙이 한 벌이어야 한다. 실측으로 드러난 갈림:
|
||||
· 노선 — B05 route 126(DRAFT) / B06 route 125(CONFIRMED), 20m 성토 4.82㎡ ↔ 85.9㎡
|
||||
· 재계산 — B06만 표준 단면값·암 경계 오프셋을 실어 보냄, 절토 합 228.52㎡ ↔ 270.12㎡
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
REPO = _read("B06_Section/B06_Section_Repository.py")
|
||||
B06_ROUTER = _read("B06_Section/B06_Section_Router.py")
|
||||
REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts")
|
||||
B05_PREVIEW = _read("B05_Profile/B05_Profile_UI_Profile_Preview.ts")
|
||||
B06_PAGE = _read("B06_Section/B06_Section_UI_Page.ts")
|
||||
|
||||
|
||||
def test_workflow_route_context_has_no_confirmed_filter():
|
||||
"""화면이 보는 경로는 최신 경로 — 확정 여부로 거르지 않는다."""
|
||||
assert "async def get_workflow_route_context" in REPO
|
||||
assert "confirmed_only=False" in REPO
|
||||
# 확정본만 보는 창구(B07 납품도면)는 그대로 남아 있어야 한다.
|
||||
assert "async def get_confirmed_route_context" in REPO
|
||||
assert "confirmed_only=True" in REPO
|
||||
|
||||
|
||||
def test_b06_context_uses_workflow_route():
|
||||
"""B06 화면 context는 B05와 같은 최신 경로를 돌려준다."""
|
||||
# 2026-09-06 부터 이 읽기는 `asyncio.gather` 로 묶여 커넥션 인자가 사라졌다
|
||||
# (`run_with_connection` 이 자기 커넥션을 넘긴다). 확인할 것은 **어느 창구를 쓰는가**다.
|
||||
assert "get_workflow_route_context" in B06_ROUTER
|
||||
assert "get_confirmed_route_context" not in B06_ROUTER
|
||||
|
||||
|
||||
def test_cross_refresh_is_single_entry():
|
||||
"""횡단 재계산은 한 창구뿐 — 두 화면이 같은 인자를 실어 보낸다."""
|
||||
assert "export async function refreshCrossDesigns" in REFRESH
|
||||
assert "readStandardCrossSession(projectId)" in REFRESH
|
||||
assert "readRockBoundarySession(projectId, routeId)" in REFRESH
|
||||
for source in (B05_PREVIEW, B06_PAGE):
|
||||
assert "refreshCrossDesigns(" in source
|
||||
assert "previewCrossDesigns(" not in source
|
||||
|
||||
|
||||
def test_cross_refresh_preserves_user_fields():
|
||||
"""서버 설계로 갈아 끼워도 화면 조작으로만 생기는 값은 살아남는다."""
|
||||
for field in (
|
||||
"inlet_structure",
|
||||
"basin_adjust",
|
||||
"revet_adjust",
|
||||
"extra_wall_counts",
|
||||
"extra_spans",
|
||||
"revet_link_detached",
|
||||
"revet_follow_grade",
|
||||
):
|
||||
assert f'"{field}"' in REFRESH # preserveUserFields 의 보존 필드 목록
|
||||
@@ -0,0 +1,423 @@
|
||||
"""채집석 공제 — 사토에서 한 번만, 실어 내는 몫부터 뺀다 (2026-09-09 사용자 확정).
|
||||
|
||||
채집석 공제는 사토에서 한 번만 뺀다.
|
||||
B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
|
||||
빼는 자리는 유토곡선의 사토뿐이다 —
|
||||
실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
|
||||
|
||||
TS 코드를 **실제로 돌려** 확인한다 — 파이썬 짝이 없는 자리라(브라우저·서버 모두 이 TS 를
|
||||
그대로 실행) 소스 문자열을 훑는 것으로는 셈이 맞는지 알 수 없다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeHaulPlan } from "./common_util_mass_haul_balance.js";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||
const out = [];
|
||||
for (const item of input.cases) {
|
||||
const plan = computeHaulPlan(input.result, input.limits, {
|
||||
collected_stone_deduction_m3: item.deduction ?? null,
|
||||
collected_stone_by_ground_m3: item.stoneByGround ?? null,
|
||||
collected_stone_ground_unknown_m3: item.stoneUnknown ?? null,
|
||||
structure_spoil_m3: item.spoil ?? null,
|
||||
structure_spoil_points: item.points ?? null,
|
||||
conversion: item.conversion ?? null,
|
||||
});
|
||||
out.push(
|
||||
plan === null
|
||||
? null
|
||||
: {
|
||||
spoil_m3: plan.spoil_m3,
|
||||
natural_spoil_m3: plan.natural_spoil_m3,
|
||||
borrow_m3: plan.borrow_m3,
|
||||
deduction: plan.collected_stone_deduction_m3,
|
||||
deducted: plan.collected_stone_deducted_m3,
|
||||
spoilIn: plan.structure_spoil_m3,
|
||||
spoilAdded: plan.structure_spoil_added_m3,
|
||||
residuals: plan.residuals.map((r) => ({
|
||||
kind: r.kind,
|
||||
volume_m3: r.volume_m3,
|
||||
natural_m3: r.natural_m3,
|
||||
ea_m3: r.ea_m3,
|
||||
rr_m3: r.rr_m3,
|
||||
br_m3: r.br_m3,
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _mass_haul_result() -> dict:
|
||||
"""절토가 앞, 성토가 뒤인 짧은 노선 — 다 못 쓴 흙이 사토로 남는다."""
|
||||
points = []
|
||||
cumulative = 0.0
|
||||
for index in range(11):
|
||||
cut = 50.0 if index and index <= 5 else 0.0
|
||||
fill = 20.0 if index > 5 else 0.0
|
||||
net = cut - fill
|
||||
cumulative += net
|
||||
points.append(
|
||||
{
|
||||
"station_id": f"S{index:03d}",
|
||||
"chainage_m": index * 20.0,
|
||||
"net_volume_m3": net,
|
||||
"cumulative_volume_m3": cumulative,
|
||||
"cut_soil_m3": cut,
|
||||
"cut_rock_m3": 0.0,
|
||||
"cut_rr_m3": 0.0,
|
||||
"cut_br_m3": 0.0,
|
||||
"cut_compacted_m3": cut,
|
||||
"fill_m3": fill,
|
||||
"natural_spoil": False,
|
||||
"net_area_m2": net / 20.0,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"points": points,
|
||||
"cut_natural_m3": {"ea": 250.0, "rr": 0.0, "br": 0.0},
|
||||
"cut_compacted_m3": 250.0,
|
||||
"fill_compacted_m3": 100.0,
|
||||
"final_cumulative_m3": cumulative,
|
||||
"surplus_m3": max(cumulative, 0.0),
|
||||
"shortage_m3": 0.0,
|
||||
"min_cumulative_m3": 0.0,
|
||||
"max_cumulative_m3": 250.0,
|
||||
"conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0},
|
||||
}
|
||||
|
||||
|
||||
def _run(tmp_path: Path, cases: list[dict]) -> list[dict | None]:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_mass_haul_balance.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# tsc 가 낸 상대 import 에는 확장자가 없어 node ESM 이 못 찾는다 — `.js` 를 붙여 준다.
|
||||
for emitted in out.glob("*.js"):
|
||||
text = emitted.read_text(encoding="utf-8")
|
||||
emitted.write_text(
|
||||
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(
|
||||
json.dumps({"result": _mass_haul_result(), "limits": None, "cases": cases}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_공제가_사토를_줄이고_안_온_값과_0을_가른다(tmp_path: Path) -> None:
|
||||
none_plan, zero_plan, some_plan = _run(tmp_path, [{}, {"deduction": 0.0}, {"deduction": 30.0}])
|
||||
assert none_plan is not None and zero_plan is not None and some_plan is not None
|
||||
|
||||
# ① 「아직 안 옴(None)」과 「공제 없음(0)」은 결과가 같되 **표시가 다르다**.
|
||||
assert none_plan["deduction"] is None
|
||||
assert zero_plan["deduction"] == 0
|
||||
assert none_plan["spoil_m3"] == zero_plan["spoil_m3"]
|
||||
|
||||
# ② 공제한 만큼 사토가 줄어든다.
|
||||
assert some_plan["deducted"] == pytest.approx(30.0)
|
||||
assert some_plan["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] - 30.0)
|
||||
|
||||
# ③ 잔량 자체가 줄어야 운반 물량이 따라간다 — 총량만 줄이면 안 된다.
|
||||
before = sum(r["volume_m3"] for r in zero_plan["residuals"] if r["kind"] == "spoil")
|
||||
after = sum(r["volume_m3"] for r in some_plan["residuals"] if r["kind"] == "spoil")
|
||||
assert after == pytest.approx(before - 30.0)
|
||||
|
||||
# ④ 토취는 건드리지 않는다.
|
||||
assert some_plan["borrow_m3"] == zero_plan["borrow_m3"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_사토보다_큰_공제는_사토까지만_뺀다(tmp_path: Path) -> None:
|
||||
(zero_plan,) = _run(tmp_path, [{"deduction": 0.0}])
|
||||
huge = zero_plan["spoil_m3"] * 10
|
||||
(plan,) = _run(tmp_path, [{"deduction": huge}])
|
||||
|
||||
assert plan["spoil_m3"] == pytest.approx(0.0, abs=1e-6)
|
||||
assert plan["deducted"] == pytest.approx(zero_plan["spoil_m3"])
|
||||
assert plan["deducted"] < huge # 받은 값보다 작게 뺐다는 것이 드러난다
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_자연방토는_나중에_깎인다(tmp_path: Path) -> None:
|
||||
"""실어 내는 몫이 남아 있는 동안에는 자연방토가 줄지 않는다."""
|
||||
(zero_plan,) = _run(tmp_path, [{"deduction": 0.0}])
|
||||
haul_out = zero_plan["spoil_m3"] - zero_plan["natural_spoil_m3"]
|
||||
if haul_out <= 0:
|
||||
pytest.skip("이 표본은 사토가 전부 자연방토라 순서를 못 본다")
|
||||
(plan,) = _run(tmp_path, [{"deduction": haul_out / 2.0}])
|
||||
assert plan["natural_spoil_m3"] == pytest.approx(zero_plan["natural_spoil_m3"])
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_구조물_잔토는_사토에_더해지고_잔량도_함께_는다(tmp_path: Path) -> None:
|
||||
"""⚠ 공제는 빼고 이것은 **더한다**. 총량만 늘리면 운반이 안 는다."""
|
||||
zero_plan, spoil_plan = _run(tmp_path, [{}, {"spoil": 40.0}])
|
||||
assert zero_plan is not None and spoil_plan is not None
|
||||
|
||||
assert zero_plan["spoilIn"] is None # 「아직 안 옴」
|
||||
assert spoil_plan["spoilIn"] == pytest.approx(40.0)
|
||||
assert spoil_plan["spoilAdded"] == pytest.approx(40.0)
|
||||
assert spoil_plan["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] + 40.0)
|
||||
|
||||
before = sum(r["volume_m3"] for r in zero_plan["residuals"] if r["kind"] == "spoil")
|
||||
after = sum(r["volume_m3"] for r in spoil_plan["residuals"] if r["kind"] == "spoil")
|
||||
assert after == pytest.approx(before + 40.0)
|
||||
|
||||
# 자연방토는 안 늘린다 — 구조물 잔토는 실어 내는 흙이다.
|
||||
assert spoil_plan["natural_spoil_m3"] == pytest.approx(zero_plan["natural_spoil_m3"])
|
||||
# 토취는 건드리지 않는다.
|
||||
assert spoil_plan["borrow_m3"] == pytest.approx(zero_plan["borrow_m3"])
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_공제와_잔토가_함께_와도_한_번씩만_먹는다(tmp_path: Path) -> None:
|
||||
zero_plan, both = _run(tmp_path, [{}, {"deduction": 10.0, "spoil": 40.0}])
|
||||
assert both["deducted"] == pytest.approx(10.0)
|
||||
assert both["spoilAdded"] == pytest.approx(40.0)
|
||||
assert both["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] - 10.0 + 40.0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_담을_사토가_없으면_사토를_새로_세운다(tmp_path: Path) -> None:
|
||||
"""⚠ 물량이 사라지면 안 된다 — 파낸 흙은 어디로든 간다(2026-09-09 확정 ㉰).
|
||||
|
||||
토취를 줄이는 길은 「그 잔토를 성토재로 쓸 수 있다」는 근거가 있어야 하므로
|
||||
지금은 **내보내는 쪽(안전측)**으로 둔다.
|
||||
"""
|
||||
(plan,) = _run(
|
||||
tmp_path,
|
||||
[{"spoil": 25.0, "points": [{"chainage_m": 500.0, "spoil_m3": 25.0}]}],
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan["spoilAdded"] == pytest.approx(25.0)
|
||||
spoils = [r for r in plan["residuals"] if r["kind"] == "spoil"]
|
||||
assert sum(r["volume_m3"] for r in spoils) >= 25.0 - 1e-6
|
||||
# 새로 선 사토는 **실어 내는 몫**이다 — 자연방토로 눅이지 않는다.
|
||||
assert all(r["natural_m3"] == 0 for r in spoils if r["volume_m3"] == pytest.approx(25.0))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_잔토를_더한_뒤에_공제를_뺀다(tmp_path: Path) -> None:
|
||||
"""⚠ 순서가 뜻을 가른다 — 사토가 0 인 노선에서 반대 순서면 공제가 영영 안 걸린다.
|
||||
|
||||
실측(두 창): 사토 0 인 노선에서 잔토 126.63 이 얹혔는데 공제는 0 이었다.
|
||||
「더하고 빼기」로 두면 126.63 − 64.75 = 61.88 이 된다.
|
||||
"""
|
||||
base, plan = _run(
|
||||
tmp_path,
|
||||
[
|
||||
{},
|
||||
{
|
||||
"spoil": 100.0,
|
||||
"points": [{"chainage_m": 60.0, "spoil_m3": 100.0}],
|
||||
"deduction": 40.0,
|
||||
},
|
||||
],
|
||||
)
|
||||
assert base is not None and plan is not None
|
||||
assert plan["spoilAdded"] == pytest.approx(100.0)
|
||||
assert plan["deducted"] == pytest.approx(40.0) # 잔토 덕에 뺄 대상이 생겼다
|
||||
# 더하고 뺀 결과가 그대로 남는다.
|
||||
assert plan["spoil_m3"] == pytest.approx(base["spoil_m3"] + 100.0 - 40.0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_터파기_토질이_오면_그_갈래로_담는다(tmp_path: Path) -> None:
|
||||
"""B08 이 `design.ground_type` 으로 이미 판정한 값을 **이어받는다** — 새 근거가 아니다.
|
||||
|
||||
⚠ 못 고른 구조물은 토질이 안 실려 오고, 그 몫은 「지반 모름」으로 남는다
|
||||
(`ground_unknown_m3` 로 드러난다).
|
||||
"""
|
||||
base, plan = _run(
|
||||
tmp_path,
|
||||
[
|
||||
{},
|
||||
{
|
||||
"spoil": 60.0,
|
||||
"points": [
|
||||
# B08 이 보내는 이름(`ground_type`)과 옛 이름(`ground`) 둘 다 받는다.
|
||||
{"chainage_m": 500.0, "spoil_m3": 20.0, "ground_type": "ripping_rock"},
|
||||
{"chainage_m": 520.0, "spoil_m3": 25.0, "ground": "토사"},
|
||||
{"chainage_m": 540.0, "spoil_m3": 15.0}, # 못 고른 것
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
assert base is not None and plan is not None
|
||||
|
||||
def bucket(rows: list[dict], key: str) -> float:
|
||||
return sum(r[key] for r in rows if r["kind"] == "spoil")
|
||||
|
||||
before, after = base["residuals"], plan["residuals"]
|
||||
assert plan["spoilAdded"] == pytest.approx(60.0)
|
||||
# 갈래가 온 몫은 **그 갈래로** 늘어난다.
|
||||
assert bucket(after, "rr_m3") - bucket(before, "rr_m3") == pytest.approx(20.0)
|
||||
assert bucket(after, "ea_m3") - bucket(before, "ea_m3") == pytest.approx(25.0)
|
||||
assert bucket(after, "br_m3") == pytest.approx(bucket(before, "br_m3"))
|
||||
# 못 고른 15 는 어느 갈래에도 안 들어간다 — 「모름」으로 남는다.
|
||||
unknown_before = bucket(before, "volume_m3") - (
|
||||
bucket(before, "ea_m3") + bucket(before, "rr_m3") + bucket(before, "br_m3")
|
||||
)
|
||||
unknown_after = bucket(after, "volume_m3") - (
|
||||
bucket(after, "ea_m3") + bucket(after, "rr_m3") + bucket(after, "br_m3")
|
||||
)
|
||||
assert unknown_after - unknown_before == pytest.approx(15.0, abs=1e-6)
|
||||
|
||||
|
||||
# ── 구조물 잔토의 **상태** — 자연상태로 와서 다짐상태 곡선에 얹힌다 (2026-09-09) ──────
|
||||
# B08 이 보내는 잔토는 터파기 제자리 기하 부피라 **자연상태**(`volume_basis: "natural"`)이고
|
||||
# 유토곡선은 **다짐상태**다. 담기 전에 ×C 하지 않으면 상태가 다른 두 부피를 섞는 것이 된다.
|
||||
_CONVERSION = {
|
||||
"soil": {"loose": 1.25, "compacted": 0.90},
|
||||
"ripping_rock": {"loose": 1.35, "compacted": 1.15},
|
||||
"blasting_rock": {"loose": 1.60, "compacted": 1.30},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_잔토는_다짐으로_바꿔_담는다(tmp_path: Path) -> None:
|
||||
points = [{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": "soil"}]
|
||||
plain, converted = _run(
|
||||
tmp_path,
|
||||
[
|
||||
{"points": points},
|
||||
{"points": points, "conversion": _CONVERSION},
|
||||
],
|
||||
)
|
||||
# 환산 없이 담으면 자연상태 100 이 그대로 들어간다.
|
||||
assert plain["spoilAdded"] == pytest.approx(100.0)
|
||||
# 계수가 오면 ×C — 토사 0.90 이라 다짐 90 으로 담긴다.
|
||||
assert converted["spoilAdded"] == pytest.approx(90.0)
|
||||
# ⚠ 왕복이 맞아야 한다 — B08 이 내보낼 때 ÷C 하면 원래 자연상태 100 이 돌아온다.
|
||||
assert converted["spoilAdded"] / _CONVERSION["soil"]["compacted"] == pytest.approx(100.0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_갈래를_모르면_환산하지_않는다(tmp_path: Path) -> None:
|
||||
"""계수가 없는 몫을 토사로 눅이면 근거 없이 금액이 움직인다."""
|
||||
base, plan = _run(
|
||||
tmp_path,
|
||||
[
|
||||
{"conversion": _CONVERSION},
|
||||
{
|
||||
"points": [{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": None}],
|
||||
"conversion": _CONVERSION,
|
||||
},
|
||||
],
|
||||
)
|
||||
assert plan["spoilAdded"] == pytest.approx(100.0)
|
||||
|
||||
def buckets(item: dict) -> float:
|
||||
return sum(
|
||||
r["ea_m3"] + r["rr_m3"] + r["br_m3"] for r in item["residuals"] if r["kind"] == "spoil"
|
||||
)
|
||||
|
||||
# 갈래 칸은 그대로이고 총량만 는다 ⇒ 그 차이가 「지반 모름」으로 드러난다.
|
||||
assert buckets(plan) == pytest.approx(buckets(base))
|
||||
assert plan["spoil_m3"] == pytest.approx(base["spoil_m3"] + 100.0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_갈래마다_계수가_다르다(tmp_path: Path) -> None:
|
||||
(plan,) = _run(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"points": [
|
||||
{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": "ripping_rock"},
|
||||
{"chainage_m": 80.0, "spoil_m3": 100.0, "ground_type": "blasting_rock"},
|
||||
],
|
||||
"conversion": _CONVERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
assert plan["spoilAdded"] == pytest.approx(115.0 + 130.0)
|
||||
|
||||
|
||||
# ── 채집석의 **축** — 벽 입적(자연)을 다짐 곡선에서 빼려면 ×C (2026-09-09 세 창 확정) ──
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_채집석은_갈래마다_C를_곱해_뺀다(tmp_path: Path) -> None:
|
||||
base, plain, converted = _run(
|
||||
tmp_path,
|
||||
[
|
||||
{"deduction": 0.0},
|
||||
{"deduction": 100.0, "stoneByGround": {"ripping_rock": 100.0}},
|
||||
{
|
||||
"deduction": 100.0,
|
||||
"stoneByGround": {"ripping_rock": 100.0},
|
||||
"conversion": _CONVERSION,
|
||||
},
|
||||
],
|
||||
)
|
||||
# 계수가 안 오면 종전처럼 그대로 뺀다.
|
||||
assert plain["spoil_m3"] == pytest.approx(base["spoil_m3"] - 100.0)
|
||||
# 리핑암 C=1.15 ⇒ 다짐 축에서는 115 을 빼야 같은 양이다.
|
||||
assert converted["deducted"] == pytest.approx(115.0)
|
||||
assert converted["spoil_m3"] == pytest.approx(base["spoil_m3"] - 115.0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_갈래_모르는_채집석은_환산하지_않는다(tmp_path: Path) -> None:
|
||||
(plan,) = _run(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"deduction": 100.0,
|
||||
"stoneByGround": {"ripping_rock": 40.0},
|
||||
"stoneUnknown": 60.0,
|
||||
"conversion": _CONVERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
# 40×1.15 = 46 (환산) + 60 (그대로) = 106
|
||||
assert plan["deducted"] == pytest.approx(106.0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_갈래가_안_오면_총량으로_되돌아간다(tmp_path: Path) -> None:
|
||||
"""값이 안 오는 것과 0 은 다르다 — 갈래가 없으면 종전 동작."""
|
||||
(plan,) = _run(tmp_path, [{"deduction": 30.0, "conversion": _CONVERSION}])
|
||||
assert plan["deducted"] == pytest.approx(30.0)
|
||||
@@ -0,0 +1,45 @@
|
||||
// niceTickStep 자체검증 — B06_Section_UI_Section_Common.ts와 같은 로직(의존 없이 복제).
|
||||
// 실행: node tmp/tests/test_nice_tick_step.mjs
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
function niceTickStep(span, targetCount, maxCount) {
|
||||
if (!(span > 0)) return 1;
|
||||
const exponent = Math.floor(Math.log10(span / targetCount));
|
||||
let best = Math.pow(10, exponent + 2);
|
||||
let bestError = Infinity;
|
||||
for (const power of [exponent - 1, exponent, exponent + 1, exponent + 2]) {
|
||||
for (const mantissa of [1, 2, 5]) {
|
||||
const step = mantissa * Math.pow(10, power);
|
||||
const count = Math.floor(span / step) + 1;
|
||||
if (count > Math.max(2, maxCount)) continue;
|
||||
const error = Math.abs(count - targetCount);
|
||||
if (error < bestError) {
|
||||
bestError = error;
|
||||
best = step;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
const counts = (span, step) => Math.floor(span / step) + 1;
|
||||
|
||||
// 1·2·5 계열만 나온다 + 개수는 상한 이하, 목표 10에 근접.
|
||||
for (const span of [0.4, 1, 3.7, 8, 21, 27, 63, 140, 900, 4200]) {
|
||||
const step = niceTickStep(span, 10, 20);
|
||||
const mantissa = step / Math.pow(10, Math.round(Math.log10(step / 5)) - 0 || 0);
|
||||
assert.ok(step > 0, `step>0 (span=${span})`);
|
||||
const norm = step / Math.pow(10, Math.floor(Math.log10(step) + 1e-9));
|
||||
assert.ok([1, 2, 5].some((m) => Math.abs(norm - m) < 1e-9), `1·2·5 계열 아님: ${step}`);
|
||||
assert.ok(counts(span, step) <= 20, `상한 초과: span=${span} step=${step}`);
|
||||
assert.ok(counts(span, step) >= 3, `너무 성김: span=${span} step=${step}`);
|
||||
void mantissa;
|
||||
}
|
||||
// 표고 27m 범위, 화면 여유 충분 → 2m 간격 10줄.
|
||||
assert.equal(niceTickStep(27, 10, 20), 2);
|
||||
// 화면이 낮아 6줄까지만 → 5m 간격.
|
||||
assert.equal(niceTickStep(27, 10, 6), 5);
|
||||
// 좁은 횡단(2m) → 0.2m 간격.
|
||||
assert.equal(niceTickStep(2, 10, 20), 0.2);
|
||||
// 범위 0 방어.
|
||||
assert.equal(niceTickStep(0, 10, 10), 1);
|
||||
console.log("ok");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user