Files
Pier_v1/tests/validate-and-foundation.test.ts
ypshimandClaude Opus 5.5 370bd3d7cd feat(shape): 다주형 코핑 하면을 오목형으로 (Level D+200 · 오목 500 · 헌치 1500)
spec §3·§4.4(2026-11-06 개정)와 3주형형상배근.md §2.2(2주형 DWG 실측)를 따른다.
- 기둥 사이 하면: Level 끝에서 헌치 수평 1500 동안 500 올라가 평탄, 다음 기둥 앞에서 내려옴
  (이전 Level + 300, 헌치 300 × 300 대체)
- 다주형 Level 폭 기본값 = D + 200 (입력값이 있으면 입력 우선). T형은 2100 유지
- 오목 평탄부가 없으면 RB-M01 오류
- 3주형 테스트는 오목형이 성립하는 20 m 코핑으로 바꿈 (P1 길이 12 m는 RB-M01)
- 2주형 DWG 치수(L 24200, D 3000) 하면 꺾임점 테스트 추가

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

267 lines
13 KiB
TypeScript
Raw Permalink 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 { describe, expect, it } from 'vitest';
import { buildPier, solveIp4 } from '../src/core/geometry';
import { validateInput, validatePier } from '../src/core/validate';
import type { Issue, PierInput } from '../src/core/types';
import { loadP1 } from './helpers';
function run(input: PierInput): Issue[] {
const issues: Issue[] = [];
if (validateInput(input, issues)) validatePier(input, buildPier(input), issues);
return issues;
}
describe('validation (spec §10)', () => {
it('P1 raises I01 (block 100 mm < cover + bar 119 mm) and no E/W', () => {
const issues = run(loadP1());
expect(issues.map((i) => i.code)).toContain('I01');
expect(issues.filter((i) => i.level !== 'I')).toEqual([]);
});
it('E01 when the pier type and column count disagree', () => {
const issues = run({ ...loadP1(), type: '2주형' });
expect(issues.map((i) => i.code)).toContain('E01');
});
it('allows different front/back counts with a non-blocking notice', () => {
const p1 = loadP1();
const issues: Issue[] = [];
const bad = { ...p1, bearings: p1.bearings.filter((b) => !(b.row === '후열' && b.no === 10)) };
expect(validateInput(bad, issues)).toBe(true);
expect(issues).toContainEqual(expect.objectContaining({code:'W09',level:'W'}));
const m=buildPier(bad);
expect(m.bearings).toHaveLength(9);
expect(m.blocks).toHaveLength(9);
expect(m.backSegments).toHaveLength(4);
expect(m.copingParts.every(p=>p.profile.flat().every(Number.isFinite))).toBe(true);
});
it.each(['전열','후열'] as const)('keeps all bearings when %s has more entries, even with integrated layout selected', row => {
const p=loadP1();
const input={...p,bearingLayout:'일체' as const,bearings:p.bearings.filter(b=>!(b.row===row && b.no===(row==='전열'?1:10)))};
const m=buildPier(input);
expect(m.blocks).toHaveLength(input.bearings.length);
expect(m.integratedBlocks).toHaveLength(0);
expect(m.derived.frontBackDiffSpread).toBeGreaterThanOrEqual(0);
expect(m.copingParts.every(p=>p.profile.flat().every(Number.isFinite))).toBe(true);
});
it('allows a single bearing in one row but rejects a missing row', () => {
const p=loadP1();
const input={...p,bearings:p.bearings.filter(b=>b.row==='후열'||b.no===1)};
expect(buildPier(input).bearings).toHaveLength(6);
const missing={...p,bearings:p.bearings.filter(b=>b.row==='전열')};
const issues:Issue[]=[];
expect(validateInput(missing,issues)).toBe(false);
expect(issues).toContainEqual(expect.objectContaining({code:'E02',level:'E'}));
expect(()=>buildPier(missing)).toThrow('각각 받침');
});
it('W01 when IP2 disagrees with the 1:9 recalculation by more than 1 mm', () => {
const p1 = loadP1();
const ips = p1.ips.map((p) => (p.name === 'IP2' ? { ...p, x: p.x + 0.002 } : p));
expect(run({ ...p1, ips }).map((i) => i.code)).toContain('W01');
});
});
describe('foundation types', () => {
it('직접기초: footing, no piles', () => {
const m = buildPier({ ...loadP1(), foundationType: '직접기초' });
expect(m.footings).toHaveLength(1);
expect(m.piles).toHaveLength(0);
});
it('직접기초: blinding concrete under the footing (Q4 추천안, 적용)', () => {
const m = buildPier({ ...loadP1(), foundationType: '직접기초' });
expect(m.blindings).toHaveLength(1);
expect(m.blindings[0].thickness).toBeCloseTo(0.1, 6);
});
it('말뚝기초: steel pipe pile 609.6 mm (Q5 추천안)', () => {
const m = buildPier(loadP1());
expect(m.piles.length).toBeGreaterThan(0);
expect(m.piles[0].diameter).toBeCloseTo(0.6096, 6);
});
it('2주형: underside between columns is 오목형 (500 above Level); column tops and T형 stay at Level', () => {
const t = buildPier(loadP1());
const raised = t.levelBottom + 0.5;
expect(t.copingParts[0].profile.some(([, z]) => Math.abs(z - raised) < 1e-9)).toBe(false);
expect(t.copingGeom.levelWidth).toBe(2100);
const two = buildPier({ ...loadP1(), type: '2주형', columnCount: 2 });
expect(two.copingParts[0].profile.some(([, z]) => Math.abs(z - raised) < 1e-9)).toBe(true);
const outlineLow = Math.min(...two.copingParts[0].profile.map(([, z]) => z));
expect(outlineLow).toBeCloseTo(two.levelBottom, 9);
expect(two.columns.every((c) => c.top === two.levelBottom)).toBe(true);
// Level width default for 2·3주형 = D + 200 (D 2000 when absent).
expect(two.copingGeom.levelWidth).toBe(2200);
});
it('3주형 builds with two 오목형 spans', () => {
const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 20000 });
const raised = m.copingParts[0].profile.filter(([, z]) => Math.abs(z - (m.levelBottom + 0.5)) < 1e-9);
expect(raised).toHaveLength(4);
});
it('RB-M01: columns too close for two 1500 haunches → error', () => {
// P1 length 12.166 m as 3주형: columns L/5 · L/2 · 4L/5 are 3650 apart, minus Level 2200 = 1450 < 3000.
expect(() => buildPier({ ...loadP1(), type: '3주형', columnCount: 3 })).toThrow(/RB-M01/);
});
it('2주형 DWG dimensions (L 24200, D 3000): Level 3200, haunch 1500 rising 500 (3주형형상배근.md §2.2)', () => {
const m = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingLengthMm: 24200, columnDiameterMm: 3000, copingH1Mm: 1500, copingH2Mm: 1500 });
expect(m.copingGeom.levelWidth).toBe(3200);
const profile = m.copingParts[0].profile;
const has = (x: number, z: number) => profile.some(([s, el]) => Math.abs(s - x) < 1e-9 && Math.abs(el - z) < 1e-9);
const [c1, c2] = m.columns.map((c) => c.s);
// Level ends at column ± 1.6, the haunch reaches Level + 0.5 another 1.5 inward, then the flat 오목 part.
expect(has(c1 + 1.6, m.levelBottom)).toBe(true);
expect(has(c1 + 3.1, m.levelBottom + 0.5)).toBe(true);
expect(has(c2 - 3.1, m.levelBottom + 0.5)).toBe(true);
expect(has(c2 - 1.6, m.levelBottom)).toBe(true);
// Ends: taper from the end height (h1 1500 below the top) down to Level.
expect(has(0, m.levelBottom + 1.5)).toBe(true);
});
for (const type of ['2주형', '3주형'] as const) {
for (const dims of [type === '2주형' ? {} : { copingLengthMm: 20000 }, { copingLengthMm: 22000, copingH1Mm: 1200, copingH2Mm: 1000, levelWidthMm: 2400 }]) {
it(`${type}: 오목형 spans and 1500 haunches follow every column, dimensions ${JSON.stringify(dims)}`, () => {
const m = buildPier({ ...loadP1(), ...dims, type, columnCount: type === '2주형' ? 2 : 3 });
const profile = m.copingParts[0].profile;
const half = m.copingGeom.levelWidth / 2000;
const has = (x: number, z: number) => profile.some(([s, el]) => Math.abs(s - x) < 1e-9 && Math.abs(el - z) < 1e-9);
for (const c of m.columns) {
expect(has(c.s - half, m.levelBottom)).toBe(true);
expect(has(c.s + half, m.levelBottom)).toBe(true);
expect(c.top).toBe(m.levelBottom);
}
for (let i = 0; i < m.columns.length - 1; i++) {
const left = m.columns[i].s + half;
const right = m.columns[i + 1].s - half;
expect(has(left + 1.5, m.levelBottom + 0.5)).toBe(true);
expect(has(right - 1.5, m.levelBottom + 0.5)).toBe(true);
}
expect(Math.min(...profile.map(([, z]) => z))).toBeCloseTo(m.levelBottom, 9);
});
}
}
it('현장타설말뚝: no footing, one pile of column diameter per column, tip = ground EL − length', () => {
const input = { ...loadP1(), foundationType: '현장타설말뚝' as const };
const m = buildPier(input);
expect(m.footings).toHaveLength(0);
expect(m.piles).toHaveLength(1);
expect(m.piles[0].diameter).toBeCloseTo(2.0, 6);
expect(m.piles[0].top).toBe(input.groundEl);
expect(m.columns[0].bottom).toBe(input.groundEl);
expect(m.piles[0].bottom).toBeCloseTo(input.groundEl - input.pileLengthMm / 1000, 6);
});
});
describe('bearing and block checks (spec §10 W03, W04)', () => {
it('P1 has no W03 or W04', () => {
const codes = run(loadP1()).map((i) => i.code);
expect(codes).not.toContain('W03');
expect(codes).not.toContain('W04');
});
it('W03 when a bearing centre is closer than half a block width to a step boundary', () => {
const m = buildPier(loadP1());
const boundary = m.segments[1].s0;
const bearings = m.bearings.map((b, k) => (k === 0 ? { ...b, g: boundary + 0.3 } : b));
const issues: Issue[] = [];
validatePier(loadP1(), { ...m, bearings }, issues);
expect(issues.map((i) => i.code)).toContain('W03');
});
it('W04 when a block footprint extends past the coping end', () => {
const m = buildPier(loadP1());
const blocks = m.blocks.map((b, k) => (k === 0 ? { ...b, g: 0.1 } : b));
const issues: Issue[] = [];
validatePier(loadP1(), { ...m, blocks }, issues);
expect(issues.map((i) => i.code)).toContain('W04');
});
});
describe('일체기초 (spec §4.6)', () => {
it('2주형 L 12000: columns 7200 apart share one footing of 17600 with 4 × 8 piles', () => {
const m = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingLengthMm: 12000, foundationType: '말뚝기초' });
expect(m.footings).toHaveLength(1);
expect(m.footings[0].length).toBeCloseTo(17.6, 6);
expect(m.footings[0].s).toBeCloseTo(6.0, 6);
expect(m.blindings[0].length).toBeCloseTo(17.8, 6);
expect(m.piles).toHaveLength(32);
expect(m.derived.pileLayout).toBe('7@2314 × 3@2250 = 32본');
});
it('3주형 L 20000: three columns 6000 apart share one footing (outer distance + 10400)', () => {
const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 20000, foundationType: '직접기초' });
expect(m.footings).toHaveLength(1);
expect(m.footings[0].length).toBeCloseTo(22.4, 6);
});
it('2주형 with columns 18000 apart: separate footings of 10400', () => {
const m = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingLengthMm: 30000, foundationType: '직접기초' });
expect(m.footings).toHaveLength(2);
expect(m.footings.every((f) => Math.abs(f.length - 10.4) < 1e-9)).toBe(true);
});
});
describe('횡방향 단차 (spec §4.3, Q1·Q2 추천안)', () => {
it('P1 has one coping part (no step)', () => {
expect(buildPier(loadP1()).copingParts).toHaveLength(1);
});
it('a 20 mm step in one segment splits the coping into two parts at the bearing-row midline', () => {
const p1 = loadP1();
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.02 } : b));
const m = buildPier({ ...p1, bearings });
expect(m.copingParts).toHaveLength(2);
const [front, back] = m.copingParts;
expect(front.nTo).toBeCloseTo(back.nFrom, 9);
expect(front.nFrom).toBeCloseTo(-m.copingB / 2, 9);
expect(back.nTo).toBeCloseTo(m.copingB / 2, 9);
expect(front.nTo).toBeCloseTo((meanN(m, '전열') + meanN(m, '후열')) / 2, 9);
// Segment 2 (bearing no. 7) is the one raised by 20 mm on the back row only.
const topAt = (part: typeof front, s: number) => part.profile.find(([ps, pz]) => Math.abs(ps - s) < 1e-9 && pz > m.levelBottom)![1];
const s2 = m.segments[2].s0;
expect(topAt(back, s2) - topAt(front, s2)).toBeCloseTo(0.02, 6);
});
it('a 3 mm difference is below the 5 mm tolerance: no step', () => {
const p1 = loadP1();
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.003 } : b));
expect(buildPier({ ...p1, bearings }).copingParts).toHaveLength(1);
});
});
function meanN(m: ReturnType<typeof buildPier>, row: '전열' | '후열'): number {
const rows = m.bearings.filter((b) => b.row === row);
return rows.reduce((sum, b) => sum + b.n, 0) / rows.length;
}
describe('꺾인 코핑 IP4 (spec §4.1)', () => {
const cross = (a: { e: number; n: number }, b: { e: number; n: number }) => a.e * b.n - a.n * b.e;
it('right (clockwise) bend of +10° puts IP4 on the IP1→IP2 line, with IP3 on the turned line', () => {
const rad = (10 * Math.PI) / 180;
const d2 = { e: Math.cos(rad), n: -Math.sin(rad) }; // heading east, turning right → south-east
const expectedIp4 = { e: 5, n: 0 };
const ip3 = { e: expectedIp4.e + 5 * d2.e, n: expectedIp4.n + 5 * d2.n };
const ip4 = solveIp4({ e: 0, n: 0 }, { e: 1, n: 0 }, ip3, 10)!;
expect(ip4.e).toBeCloseTo(5, 9);
expect(ip4.n).toBeCloseTo(0, 9);
});
it('zero angle has no IP4', () => {
expect(solveIp4({ e: 0, n: 0 }, { e: 1, n: 0 }, { e: 10, n: 2 }, 0)).toBeNull();
});
it('a bend on a straight P1 sheet (IP1, IP2, IP3 collinear) is refused: IP4 would land on IP3', () => {
const p1 = loadP1();
const ips = p1.ips.map((p) => (p.name === 'IP4' ? { ...p, angleDeg: 5 } : p));
// Both messages refuse the bend. Which check fires first depends on the axis orientation (x = 북, 2026-11-03).
expect(() => buildPier({ ...p1, ips })).toThrow('꺾임');
});
});