import { describe, expect, it } from 'vitest'; import * as XLSX from 'xlsx'; import { buildQuantity } from '../src/core/rebar/quantity'; import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; import type { RebarRow } from '../src/core/rebar/summary'; import { createQuantityWorkbook, quantityExportIssues, quantityPieceRows } from '../src/core/quantity-workbook'; import { projectRecords } from '../수량산출/project-inputs'; import { buildPier } from '../src/core/geometry'; import { loadP1 } from './helpers'; const row: RebarRow = {member:'coping',name:'C9',size:'H19',count:2,length:null,pieces:[{length:1234.567,count:2},{length:765.432,count:4}],note:'',spacing:'',actual:'',position:'',direction:'',shape:'가공 조각',basis:'calc'}; const q = buildQuantity([row], {...DEFAULT_MATERIAL,waste:10}); const records = [{name:'P1',quantity:q,dirty:false}]; const meta = {project:'검증',bridge:'검증교',generatedAt:'2026-11-06T00:00:00.000Z',abutmentCount:2}; const values = (sheet: XLSX.WorkSheet) => XLSX.utils.sheet_to_json<(string|number)[]>(sheet,{header:1}); describe('재료표 Excel과 가공 상세', () => { it('실제 파일 왕복 후 길이별 본수·중량 합이 반올림 없이 집계와 일치한다', () => { const wb = XLSX.read(XLSX.write(createQuantityWorkbook(records,meta),{type:'buffer',bookType:'xlsx'}),{type:'buffer'}); const pieces = values(wb.Sheets['가공 상세']).slice(1); expect(pieces.reduce((n,r)=>n+Number(r[7]),0)).toBe(6); expect(pieces.reduce((n,r)=>n+Number(r[8]),0)).toBeCloseTo(q.groups[0].diameters[0].totalM,12); expect(pieces.reduce((n,r)=>n+Number(r[9]),0)).toBeCloseTo(q.kg,12); expect(values(wb.Sheets.P1).find(r=>r[1]==='C9')).toMatchObject({4:2,9:6}); expect(values(wb.Sheets['합계'])[1][2]).toBeCloseTo(q.kgWithWaste,12); expect(values(wb.Sheets['생성 정보']).find(r=>r[0]==='범위')![1]).toContain('교대 2기 제외'); }); it('미재생성·미생성 교각이 있으면 Excel 생성을 막는다', () => { const bad=[{...records[0],dirty:true},{name:'P2',quantity:null,dirty:false}]; expect(quantityExportIssues(bad)).toEqual(['P1: 형상 재생성 필요','P2: 형상 없음']); expect(()=>createQuantityWorkbook(bad,meta)).toThrow('재생성 필요'); }); it('부분 소계와 미계산 사유를 파일에 보존한다', () => { const partial=buildQuantity([row],DEFAULT_MATERIAL,['저부 P6 미확정']); const wb=createQuantityWorkbook([{name:'P1',quantity:partial,dirty:false}],meta); expect(values(wb.Sheets['검토 항목'])[1]).toEqual(['P1','저부 P6 미확정']); expect(values(wb.Sheets['합계'])[1][4]).toContain('검토용'); }); it('예약명·대소문자·절단·문자치환으로 시트명이 충돌해도 모두 출력한다', () => { const names=['합계','가공 상세','P1','p1','A/B','A:B','긴이름'.repeat(20)+'1','긴이름'.repeat(20)+'2']; const wb=createQuantityWorkbook(names.map(name=>({...records[0],name})),meta); expect(new Set(wb.SheetNames.map(n=>n.toLowerCase())).size).toBe(names.length+5); expect(wb.SheetNames.every(n=>n.length<=31)).toBe(true); expect(quantityPieceRows(names.map(name=>({...records[0],name})))).toHaveLength(1+names.length*2); }); it('공종 수량에 수동 중량이 있어도 미확정 철근을 계산 완료로 처리하지 않는다', () => { const input={...loadP1(),quantityTakeoff:{values:{rebar:123},reviewed:true}},model=buildPier(input); const result=projectRecords({project:{name:'',bridge:''},selected:'P1',piers:[{input,model,dirty:false,rebarTon:null,rebarIssues:['저부 미확정']}]})[0].result!; expect(result.issues).toContain('철근재료표 일부 계산: 저부 미확정'); expect(result.complete).toBe(false); }); });