| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155 |
- // 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('<svg'), 'SVG应可生成');
- });
- console.log('== v2 企微实战回归 ==');
- // D1 .bin/无后缀嗅探(企微缓存文件兼容)
- t('sniffImageMime: jpeg magic bytes', () => {
- 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);
|