// fpc 端到端测试:计价引擎单元断言 + (可选)真实图纸识别 import { buildQuote, kitchenBillable, tierUnitPrice, applyOps, withinAuthority } from '../src/quote.js'; import { normalizeCabinetName, assessQuality } from '../src/vision.js'; import { renderSVG, buildPlan } from '../src/redraw.js'; import { renderReport } from '../src/report.js'; import { loadDocument, sniffImageMime } from '../src/parse.js'; import { buildAnnounce } from '../src/index.js'; import assert from 'node:assert'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; let pass = 0; let fail = 0; function t(name, fn) { try { fn(); pass++; console.log(' ✓', name); } catch (e) { fail++; console.error(' ✗', name, '→', e.message); } } console.log('== 计价引擎 =='); t('厨房U型计费米数: 长3.5宽2.4 → 扣2转角+冰箱 = 7.2m', () => { const kb = kitchenBillable([3.5, 2.4, 3.5, 2.4]); assert.equal(kb.kitchenType, 'U'); assert.equal(kb.billable, 7.2); }); t('厨房L型计费米数: 长3.6宽1.8 → 扣1转角+冰箱 = 3.8m', () => { const kb = kitchenBillable([3.6, 1.8, 0, 0]); assert.equal(kb.kitchenType, 'L'); assert.equal(kb.billable, 3.8); }); t('PET档15%浮动单价 = 区间上限5000', () => { assert.equal(tierUnitPrice({ name: 'PET', min: 4500, max: 5000 }, 15), 5000); }); t('PET档10%浮动单价 = 区间下限4500', () => { assert.equal(tierUnitPrice({ name: 'PET', min: 4500, max: 5000 }, 10), 4500); }); t('快速报价: 3.6×2.6衣柜@5000 = 46800', () => { const q = buildQuote({ cabinets: [{ type: '主卧衣柜', width: 3.6, height: 2.6 }], tierName: 'PET', floatPct: 15 }); assert.equal(q.rooms[0].total, 46800); }); t('价格型客户默认85折+自动活动', () => { const q = buildQuote({ cabinets: [{ type: '主卧衣柜', width: 3.6, height: 2.6 }], tierKey: 'price', floatPct: 15 }); assert.equal(q.meta.discount, 0.85); assert.ok(q.activityLines.length >= 1, '活动应自动启用'); }); t('品质型客户 +15% 品质系数', () => { const q = buildQuote({ cabinets: [{ type: '主卧衣柜', width: 3.6, height: 2.6 }], tierKey: 'quality', floatPct: 15 }); assert.equal(q.meta.premiumRate, 1.15); }); t('增删项 ops: add+remove 生效', () => { const out = applyOps([{ type: '主卧衣柜', width: 3, height: 2.6 }, { type: '书房书柜', width: 2, height: 2.6 }], [{ op: 'remove', target: '书房书柜' }, { op: 'add', type: '阳台柜', width: 1.8 }]); assert.deepEqual(out.map((c) => c.type), ['主卧衣柜', '阳台柜']); }); t('谈价区间 = 到手价 ± 浮动率', () => { const q = buildQuote({ cabinets: [{ type: '玄关鞋柜', width: 1.2, height: 2.4 }], floatPct: 15 }); const f = q.totals.finalPrice; assert.equal(q.totals.range.low, Math.round(f * 0.85)); assert.ok(withinAuthority(q, f)); assert.ok(!withinAuthority(q, f * 1.2), '超出区间的价格应在授权外'); }); console.log('== 名称归一 =='); t('厨房吊柜→厨房橱柜(强制延米计价)', () => { assert.equal(normalizeCabinetName('厨房吊柜'), '厨房橱柜'); assert.equal(normalizeCabinetName('开放式厨房柜'), '厨房橱柜'); }); t('玄关柜/入户柜→玄关鞋柜', () => { assert.equal(normalizeCabinetName('玄关柜'), '玄关鞋柜'); assert.equal(normalizeCabinetName('入户柜'), '玄关鞋柜'); }); console.log('== 报告渲染 =='); t('renderReport 产出含引擎与交互的HTML', () => { const q = buildQuote({ cabinets: [{ type: '主卧衣柜', width: 3.2, height: 2.6 }], tierKey: 'standard' }); const plan = buildPlan({ analysis: { rooms: [{ name: '主卧', pos: '左中', approxArea: 14 }] }, cabinets: q.rooms.map((r) => ({ type: r.type, width: 3.2, height: 2.6, pos: '左中' })) }); const svg = renderSVG(plan); const html = renderReport({ quote: q, planSVG: svg, clientName: '测试', salesNote: 'x' }); assert.ok(html.includes('id="finalPrice"')); assert.ok(html.includes('function buildQuote'), '计价引擎应内嵌'); assert.ok(svg.startsWith(' { const jpeg = fs.readFileSync('/opt/data/floorplan-quotation-testdata/sample1_shidaichengzhi_q1.jpg'); assert.equal(sniffImageMime(jpeg.subarray(0, 16)), 'image/jpeg'); }); t('loadDocument(.bin) → data:image/jpeg dataURL', async () => { const tmp = path.join(os.tmpdir(), `fpc_test_${Date.now()}.bin`); fs.copyFileSync('/opt/data/floorplan-quotation-testdata/sample1_shidaichengzhi_q1.jpg', tmp); try { const doc = await loadDocument(tmp); assert.ok(doc.pages[0].dataUrl.startsWith('data:image/jpeg;base64,'), 'dataURL 前缀应为 image/jpeg'); } finally { fs.unlinkSync(tmp); } }); // D2 质量门 t('assessQuality: 低置信度垃圾识别 → ok:false', () => { const bad = { cabinets: [ { type: '厨房橱柜', width: 3, height: 2.8, basis: '默认厨房保底', confidence: 0.2 }, { type: '主卧衣柜', width: 1.5, height: 2.6, basis: 'analysis识别、尺寸先验估算', confidence: 0.3 }, { type: '次卧衣柜', width: 1.5, height: 2.6, basis: 'analysis识别、尺寸先验估算', confidence: 0.3 }, ] }; const q = assessQuality(bad); assert.equal(q.ok, false); assert.ok(q.reasons.length >= 1); assert.ok(q.suggestion.includes('补充')); }); t('assessQuality: 尺寸线直读的正常识别 → ok:true', () => { const good = { cabinets: [ { type: '厨房橱柜', width: 2.88, height: 2.7, basis: '图上直读:台面沿墙', confidence: 0.9, walls: [2.88, 0.95, 2.88, 0] }, { type: '主卧衣柜', width: 2.2, height: 2.7, basis: '图上尺寸线直读(2200)', confidence: 0.95 }, { type: '次卧衣柜', width: 2.95, height: 2.7, basis: '图上尺寸线直读(2950)', confidence: 0.95 }, { type: '阳台柜', width: 1.8, height: 2.7, basis: 'VLM估算', confidence: 0.7 }, ] }; const q = assessQuality(good); assert.equal(q.ok, true, `score=${q.score} reasons=${q.reasons.join(';')}`); assert.ok(q.score >= 6); }); // D3 口径一致性锁死(c45b0ef 修复不回退) t('renderReport 内嵌__Q__含厨房walls且柜高与dims一致', () => { const cabinets = [ { type: '厨房橱柜', width: 2.88, height: 2.7, walls: [2.88, 0.95, 2.88, 0], basis: '图上直读', confidence: 0.9 }, { type: '主卧衣柜', width: 2.2, height: 2.7, basis: '图上直读', confidence: 0.9 }, ]; const quote = buildQuote({ cabinets, mode: 'quick', tierKey: 'standard', tierName: 'PET', floatPct: 15, clientName: '测试' }); const html = renderReport({ quote, imageDataUrl: 'data:image/jpeg;base64,xxxx', clientName: '测试', salesNote: '' }); const m = html.match(/window\.__Q__\s*=\s*(\{[\s\S]*?\});\s*\n/); assert.ok(m, 'HTML 应含 window.__Q__ 初始状态'); const Q = JSON.parse(m[1]); const kitchen = Q.cabinets.find((c) => c.type === '厨房橱柜'); assert.ok(kitchen, '内嵌状态应含厨房'); assert.ok(Array.isArray(kitchen.walls) && kitchen.walls.length >= 3 && kitchen.walls[0] === 2.88, '厨房 walls 必须随内嵌状态保留'); const wardrobe = Q.cabinets.find((c) => c.type === '主卧衣柜'); assert.equal(wardrobe.height, 2.7, '柜高应从 dims 还原(不得硬编码 2.6)'); // 页面口径一致:内嵌状态重算 = 服务端到手价(引擎已内嵌,用同参调用验证) const pageQuote = buildQuote({ cabinets: Q.cabinets, tierKey: Q.config.tierKey, tierName: Q.config.tierName, floatPct: Q.config.floatPct, discount: Q.config.discount, mode: Q.config.mode }); assert.equal(pageQuote.totals.finalPrice, quote.totals.finalPrice, '页面重算价必须等于服务端到手价'); }); // D4 群发公告生成 t('buildAnnounce: 含到手价/谈价区间/明细', () => { const cabinets = [ { type: '厨房橱柜', width: 2.88, height: 2.7, walls: [2.88, 0.95, 2.88, 0], basis: '图上直读', confidence: 0.9 }, { type: '主卧衣柜', width: 2.2, height: 2.7, basis: '图上直读', confidence: 0.9 }, ]; const quote = buildQuote({ cabinets, mode: 'quick', tierKey: 'standard', tierName: 'PET', floatPct: 15, clientName: '张女士' }); const md = buildAnnounce({ quote, rec: { analysis: { notes: '两室两厅' } }, htmlPath: '/tmp/x.html', clientName: '张女士', note: '预算紧' }); assert.ok(md.includes('到手价'), '公告应含到手价'); assert.ok(md.includes('谈价参考区间'), '公告应含谈价区间'); assert.ok(md.includes('厨房橱柜'), '公告应含明细'); assert.ok(md.includes('两室两厅'), '公告应含图纸概况'); }); console.log(`\n结果: ${pass} 通过, ${fail} 失败`); process.exit(fail ? 1 : 0);