Files
Pier_v1/tests/bearing-pair.test.ts
T
ypshimandClaude Opus 5.5 2668f17bf2 feat(import): 교대만으로 가져오기 허용
교각 없이 A1 · A2 같은 교대만으로도 가져오고 저장할 수 있게 한다 (A1 + A2 짝 받침 성립, 2026-11-06 검토).
- 불러오기 창: 교각 또는 교대가 1기 이상이면 [가져오기] 활성, 안내 "교대만 가져옵니다"
- 3D: 교각이 없으면 첫 교대의 IP1을 좌표 기준점으로 교대를 그리고, 카메라를 교대에 맞춤(abutView)
- 프로젝트 파일: 교각 0기 + 교대 저장·열기 왕복 테스트 추가
- 브라우저 검증 scripts/verify-abut-only.cjs (CDP 9444 · Vite 5173): A1만 가져오기 → ① ② ③ 이동,
  런타임 예외 0, 교대 받침 카드(S −1.910 % 종단경사 · T 30 · h3 245 · E 400)와 ③ 교대 철근 표시 확인.
  P1 + A1 함께 가져오기 회귀도 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:30:13 +09:00

127 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { parseAbutments } from '../src/core/abut/parse';
import { girderPairs, pairOf, type PairStructure, soleRowThickness } from '../src/core/bearing-pair';
import { loadP1 } from './helpers';
// RAG/받침방위각_솔플레이트검토.md §2 · §6 (Q-SH1·4·8·9). A1 from the RAG workbook's Abut sheet, P1 from the same input.
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const a1 = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const A1: PairStructure = { name: 'A1', bearings: a1.bearings.map((b) => ({ no: b.no, x: b.pos.e, y: b.pos.n, z: b.z, azimuthDeg: b.azimuthDeg! })) };
const p1 = loadP1();
const P1: PairStructure = { name: 'P1', bearings: p1.bearings.map((b) => ({ no: b.no, x: b.x, y: b.y, z: b.z, azimuthDeg: b.azimuthDeg })) };
describe('거더 짝 받침 · 종단경사 S (Q-SH1·4·5)', () => {
const pairs = girderPairs([A1, P1]);
it('pairs A1 ①–⑤ with P1 ①–⑤ on the girder line (offset 0.000 m, azimuth difference ≤ 0.1″)', () => {
expect(pairs.map((p) => [p.from.no, p.to.no])).toEqual([[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]]);
for (const p of pairs) {
expect(p.offsetM).toBeLessThan(0.0005);
expect(p.fromCheckSec).toBeLessThanOrEqual(0.5);
expect(p.distanceM).toBeCloseTo(48.791, 3);
expect(p.warnings).toEqual([]);
}
});
it('S = −1.908 / −1.906 / −1.904 / −1.904 / −1.902 % (상세도 −1.910 … −1.901, ≤ 0.002 %), A1 is the 오르막 쪽', () => {
expect(pairs.map((p) => p.slopePct.toFixed(3))).toEqual(['-1.908', '-1.906', '-1.904', '-1.904', '-1.902']);
const drawing = [-1.910, -1.907, -1.905, -1.903, -1.901];
pairs.forEach((p, i) => expect(Math.abs(p.slopePct - drawing[i])).toBeLessThanOrEqual(0.0025));
expect(pairs.every((p) => p.uphill === 'A1')).toBe(true);
});
it('P1 back row ⑥–⑩ (next span, P2 not in the workbook) has no pair', () => {
for (const no of [6, 7, 8, 9, 10]) expect(pairOf(pairs, 'P1', no)).toBeUndefined();
expect(pairOf(pairs, 'P1', 3)!.from).toEqual({ structure: 'A1', no: 3 });
});
it('WS01: P1 ③ azimuth typed as 75°59′ (CAD) is kept but flagged', () => {
const typo: PairStructure = { ...P1, bearings: P1.bearings.map((b) => (b.no === 3 ? { ...b, azimuthDeg: 90 - (75 + 59 / 60) } : b)) };
const p = girderPairs([A1, typo]).find((q) => q.to.no === 3)!;
expect(p.warnings).toContain('WS01');
expect(p.toCheckSec).toBeGreaterThan(10);
expect(girderPairs([A1, typo]).filter((q) => q.to.no !== 3).every((q) => !q.warnings.includes('WS01'))).toBe(true);
});
it('WS04: two candidates within tolerance → the closest to the girder line is used', () => {
const b1 = P1.bearings[0];
const extra = { ...b1, no: 99, x: b1.x + 0.05 * Math.cos(b1.azimuthDeg * Math.PI / 180), y: b1.y - 0.05 * Math.sin(b1.azimuthDeg * Math.PI / 180) };
const p = girderPairs([A1, { ...P1, bearings: [...P1.bearings, extra] }]).find((q) => q.from.no === 1)!;
expect(p.warnings).toContain('WS04');
expect(p.to.no).toBe(1);
});
});
describe('솔플레이트 열 두께 (Q-SH3·8·9, L 800)', () => {
it('S 1.908 % → T 30, T1 38 / T2 22 (P1 상세도)', () => {
expect(soleRowThickness([{ lengthMm: 800, slopePct: -1.908 }])).toEqual({ T: 30, ends: [{ T1: 38, T2: 22 }] });
});
it('S 3 % → T 34, T1 46 / T2 22; S 0 → T 22', () => {
expect(soleRowThickness([{ lengthMm: 800, slopePct: 3 }])).toEqual({ T: 34, ends: [{ T1: 46, T2: 22 }] });
expect(soleRowThickness([{ lengthMm: 800, slopePct: 0 }]).T).toBe(22);
});
it('one T per row: the largest requirement wins, the flatter bearing gets a thicker T2', () => {
const row = soleRowThickness([{ lengthMm: 800, slopePct: 3 }, { lengthMm: 800, slopePct: 1 }]);
expect(row.T).toBe(34);
expect(row.ends[1]).toEqual({ T1: 38, T2: 30 });
});
});
describe('교량 전체 짝 받침 (bridgePairs, 구조물 순서 A1 · P1 … · A2)', () => {
it('builds the same A1 ↔ P1 pairs from the loaded records', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const pairs = bridgePairs([p1], [a1]);
expect(pairs.map((p) => `${p.from.structure}${p.from.no}-${p.to.structure}${p.to.no}`)).toEqual(['A11-P11', 'A12-P12', 'A13-P13', 'A14-P14', 'A15-P15']);
});
});
describe('짝 받침 성립 범위 (검토 2026-11-06)', () => {
// A2 made from A1: each bearing moved 30 m along its girder and 0.6 m lower (S −2 %).
const a2 = { ...a1, name: 'A2', type: 'A2' as const, bearings: a1.bearings.map((b) => {
const t = (b.azimuthDeg! * Math.PI) / 180;
return { ...b, pos: { e: b.pos.e + 30 * Math.sin(t), n: b.pos.n + 30 * Math.cos(t) }, z: b.z - 0.6 };
}) };
it('A1 and A2 only (one span, no pier): A1 ①–⑤ pair with A2 ①–⑤, S −2 %', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const pairs = bridgePairs([], [a1, a2]);
expect(pairs.map((p) => `${p.from.structure}${p.from.no}-${p.to.structure}${p.to.no}`)).toEqual(['A11-A21', 'A12-A22', 'A13-A23', 'A14-A24', 'A15-A25']);
expect(pairs.every((p) => Math.abs(p.slopePct + 2) < 1e-6 && p.warnings.length === 0)).toBe(true);
});
it('A1 and P1 only: the front row ①–⑤ is computed from A1, the back row ⑥–⑩ takes the assumed front value (가정, WS03)', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const { buildPier } = await import('../src/core/geometry');
const m = buildPier(p1, { pairs: bridgePairs([p1], [a1]) });
const front = m.soles.filter((s) => s.row === '전열'), back = m.soles.filter((s) => s.row === '후열');
expect(front.every((s) => s.slopeSource === '계산' && s.pair?.structure === 'A1')).toBe(true);
expect(back.every((s, i) => s.slopeSource === '가정' && !s.pair && s.slopePct === front[i].slopePct && s.warnings.includes('WS03'))).toBe(true);
});
it('straight bridge: a back row on the same girder lines is not taken for the A1 partner (the nearest along the girder is)', () => {
const straight: PairStructure = { name: 'P1', bearings: P1.bearings.map((b) => {
if (b.no <= 5) return b;
const f = P1.bearings.find((q) => q.no === b.no - 5)!;
const t = (f.azimuthDeg * Math.PI) / 180;
return { ...b, azimuthDeg: f.azimuthDeg, x: f.x + 1.2 * Math.sin(t), y: f.y + 1.2 * Math.cos(t) };
}) };
const pairs = girderPairs([A1, straight]);
expect(pairs.map((p) => p.to.no)).toEqual([1, 2, 3, 4, 5]);
expect(pairs.every((p) => p.warnings.length === 0)).toBe(true);
});
});
describe('교대만 있는 프로젝트 (가져오기 허용 2026-11-06)', () => {
it('a project file with no pier and A1 only round-trips', async () => {
const { parseProject, serializeProject, SCHEMA_VERSION } = await import('../src/core/project');
const file = { schemaVersion: SCHEMA_VERSION, project: { name: '교대만', bridge: '', client: '', author: '', crs: '', memo: '' },
source: { fileName: '운곡교P1입력.xlsx', sheets: [] }, history: [], piers: [], abutments: [a1] };
const back = parseProject(serializeProject(file as Parameters<typeof serializeProject>[0]));
expect(back.piers).toEqual([]);
expect(back.abutments?.map((a) => a.name)).toEqual(['A1']);
});
});