Files
Pier_v1/tests/validate-and-foundation.test.ts
T
ypshimandClaude Haiku 5.5 bd84ac6176 fix(shape): 후열 계단 경계를 후열 받침 사이 중앙에 둔다
전·후열 받침 수가 같으면 후열 계단 경계를 전열 경계로 썼기 때문에, 후열 받침이 전열과 마주 보지 않는 교각
(산외교 P11 ⑥|⑦: 경계 12.75, 받침 11.64 · 18.49)에서 경계가 중앙에서 벗어났다. 후열도 자기 받침 사이 중앙을 쓰되,
전열 경계와 블럭 반폭(0.6 m) 이내로 가까우면 전열 경계에 맞춘다 (P1처럼 약간 엇갈린 열에 얇은 단이 생기지 않게).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:38:30 +09:00

315 lines
16 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 { 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();
// The P1 sheet gives 교좌면 EL, which sets the step top (2026-11-10), so it is raised with z.
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.02, seatEl: b.seatEl! + 0.02 } : b));
const m = buildPier({ ...p1, bearings });
expect(m.copingParts).toHaveLength(2);
const [front, back] = m.copingParts;
// P1's front row is on the + side of the axis (n ≈ +0.70, back ≈ −0.60), so the front part is the + half (2026-11-10 fix).
expect(meanN(m, '전열')).toBeGreaterThan(meanN(m, '후열'));
expect(front.nFrom).toBeCloseTo(back.nTo, 9);
expect(front.nTo).toBeCloseTo(m.copingB / 2, 9);
expect(back.nFrom).toBeCloseTo(-m.copingB / 2, 9);
expect(front.nFrom).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 20 mm higher 교좌면 EL alone (z unchanged) also makes the step (2026-11-10)', () => {
const p1 = loadP1();
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, seatEl: b.seatEl! + 0.02 } : b));
expect(buildPier({ ...p1, bearings }).copingParts).toHaveLength(2);
});
it('each block sits on the coping part under it, whichever side of the axis the front row is on (2026-11-10)', () => {
const p1 = loadP1();
const raise = (b: (typeof p1.bearings)[number]) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.3, seatEl: b.seatEl! + 0.3 } : b);
const swapRows = (b: (typeof p1.bearings)[number]) => ({ ...b, row: (b.row === '전열' ? '후열' : '전열') as typeof b.row });
// P1 as given (front on +n) and with the row labels swapped (front on −n).
for (const bearings of [p1.bearings.map(raise), p1.bearings.map(raise).map(swapRows)]) {
const m = buildPier({ ...p1, bearings });
expect(m.copingParts).toHaveLength(2);
for (const b of m.blocks) {
const part = m.copingParts.find((q) => q.k === b.k && b.n >= q.nFrom && b.n <= q.nTo)!;
// The last top point at or before s on the stepped outline is the step top under the block.
const before = part.profile.slice(0, part.profile.findIndex(([ps]) => ps > b.s + 1e-9));
const top = before.filter(([, pz]) => pz > m.endBottom + 1e-9).pop()![1];
expect(b.bottom).toBeCloseTo(top, 6);
}
}
});
it('slightly staggered rows (P1, < half a block) keep the front bounds for the back steps', () => {
const m = buildPier(loadP1());
expect(m.backSegments!.map((x) => x.s0)).toEqual(m.segments.map((x) => x.s0));
});
it('back steps break midway between back bearings, even when they are not opposite the front ones (2026-11-10)', () => {
const p1 = loadP1();
// Move back bearing 7 by 1.5 m along the row: its bounds follow it, the front bounds do not.
const m0 = buildPier(p1);
const u = m0.u;
const bearings = p1.bearings.map((b) => (b.no === 7 ? { ...b, x: b.x + 1.5 * u.e, y: b.y + 1.5 * u.n } : b));
const m = buildPier({ ...p1, bearings });
const back = m.bearings.filter((b) => b.row === '후열').sort((a, b) => a.g - b.g);
const bs = m.backSegments!.map((x) => x.s0);
// ⑥|⑦ and ⑦|⑧ move 0.75 m (> half a block): own midpoints. ⑧|⑨ and ⑨|⑩ stay on the front bounds.
expect(bs[1]).toBeCloseTo((back[0].g + back[1].g) / 2, 9);
expect(bs[2]).toBeCloseTo((back[1].g + back[2].g) / 2, 9);
expect(bs.slice(3)).toEqual(m.segments.slice(3).map((x) => x.s0));
expect(m.segments.map((x) => x.s0)).toEqual(m0.segments.map((x) => x.s0));
});
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('꺾임');
});
});