/* ─── 报告页可视化 mock 冒烟(node,零依赖):node smoke-test.js ─── 断言:① fixture 的 dataURL 可解析(mime 与魔数一致、宽 1280) ② marks 坐标缩放后全部落在照片范围内(坐标空间=照片空间) ③ app.js 纯函数段(vizCollectMarks/vizScaleMarks)对 fixture 提取/换算正确 ④ fixture.result 能驱动 SkinScore.buildViewModel 出完整报告 */ 'use strict'; var fs = require('fs'); var path = require('path'); var pass = 0, fail = 0; function ok(cond, msg) { if (cond) { pass++; console.log(' ✓ ' + msg); } else { fail++; console.error(' ✗ FAIL: ' + msg); } } /* ─── 无依赖图片尺寸解析:PNG 读 IHDR,JPEG 扫 SOF ─── */ function imgSize(buf) { if (buf[0] === 0x89 && buf[1] === 0x50) { // PNG:89 50 4E 47 return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20), fmt: 'png' }; } if (buf[0] === 0xFF && buf[1] === 0xD8) { // JPEG:FF D8,SOF0-15(除 C4/C8/CC) var off = 2; while (off + 9 < buf.length) { if (buf[off] !== 0xFF) { off++; continue; } var marker = buf[off + 1]; if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) { return { w: buf.readUInt16BE(off + 7), h: buf.readUInt16BE(off + 5), fmt: 'jpeg' }; } off += 2 + buf.readUInt16BE(off + 2); } } return null; } function parseDataURL(u) { var m = /^data:(image\/(?:jpeg|png));base64,([A-Za-z0-9+/=]+)$/.exec(u || ''); if (!m) return null; return { mime: m[1], buf: Buffer.from(m[2], 'base64') }; } /* ─── ① fixture 加载与 dataURL 解析 ─── */ console.log('== ① fixture dataURL 解析 =='); var window = {}; new Function('window', fs.readFileSync(path.join(__dirname, 'report-fixture.js'), 'utf8'))(window); var fx = window.MOCK_FIXTURE; ok(fx && fx.result && Array.isArray(fx.photos), 'window.MOCK_FIXTURE 结构完整(result/photos)'); var photo = parseDataURL(fx.photos[0]); var photoSize = photo && imgSize(photo.buf); ok(!!photo, 'photos[0] 是合法 dataURL'); ok(photoSize && photoSize.fmt === 'jpeg' && photo.mime === 'image/jpeg', '照片 jpeg 魔数与 mime 一致'); ok(photoSize && photoSize.w === 1280 && photoSize.h === 960, '照片尺寸 1280×960(实测 ' + (photoSize ? photoSize.w + '×' + photoSize.h : '?') + ')'); ok(fx.photos[1] === null && fx.photos[2] === null, '侧脸位为 null'); var maps = fx.result.face_maps || {}; ['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads'].forEach(function (k) { var p = parseDataURL(maps[k]); var sz = p && imgSize(p.buf); ok(!!p && !!sz, 'face_maps.' + k + ' dataURL 可解析(' + (sz ? sz.fmt : '失败') + ')'); ok(sz && sz.w === 1280 && sz.h === 960, 'face_maps.' + k + ' 已缩放到 1280×960'); ok(p && sz && p.mime === 'image/' + sz.fmt, 'face_maps.' + k + ' mime 与魔数一致(' + (p && p.mime) + ')'); }); /* ─── ② marks 坐标全部落在照片范围内 ─── */ console.log('== ② marks 坐标范围 =='); var coordCount = 0, outCount = 0; (function walk(node) { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) return node.forEach(walk); var ks = node; if (typeof ks.left === 'number' && typeof ks.top === 'number' && typeof ks.width === 'number' && typeof ks.height === 'number') { coordCount++; if (ks.left < 0 || ks.top < 0 || ks.left + ks.width > 1280 || ks.top + ks.height > 960) outCount++; return; } if (typeof ks.x === 'number' && typeof ks.y === 'number') { coordCount++; if (ks.x < 0 || ks.x > 1280 || ks.y < 0 || ks.y > 960) outCount++; return; } Object.keys(ks).forEach(function (k) { if (k !== 'face_maps') walk(ks[k]); }); })(fx.result); ok(coordCount > 100, 'result 内含坐标点/矩形 ' + coordCount + ' 个(原图 4000×3000 → ×0.32)'); ok(outCount === 0, '全部坐标在 1280×960 照片范围内(越界 ' + outCount + ' 个)'); var fr = fx.face_rectangle; ok(fr && fr.left >= 0 && fr.top >= 0 && fr.left + fr.width <= 1280 && fr.top + fr.height <= 960, 'face_rectangle 已缩放且在范围内(' + JSON.stringify(fr) + ')'); /* ─── ③ app.js 纯函数段抽离断言 ─── */ console.log('== ③ app.js 纯函数(viz-pure 锚点抽离) =='); var appSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'js', 'app.js'), 'utf8'); var b = appSrc.indexOf('/* viz-pure-begin'), e = appSrc.indexOf('/* viz-pure-end'); ok(b >= 0 && e > b, 'app.js 含 viz-pure-begin/end 锚点'); var pure = new Function(appSrc.slice(b, e) + '; return { collect: vizCollectMarks, scale: vizScaleMarks };')(); var SMOOTH_MARKS = [{ key: 'brown_spot', color: '#B26A00' }, { key: 'mole', color: '#282828' }, { key: 'acne', color: '#D30C0C' }]; var ACNE_MARKS = [{ key: 'closed_comedones', color: '#E8A13D' }, { key: 'acne', color: '#D30C0C' }, { key: 'acne_pustule', color: '#D30C0C' }, { key: 'acne_nodule', color: '#8E0000' }]; var sm = pure.collect(SMOOTH_MARKS, fx.result); var am = pure.collect(ACNE_MARKS, fx.result); ok(sm.length === 2, '光滑:提取到 brown_spot+acne 两组(mole count=0 被跳过),实得 ' + sm.length + ' 组'); ok(sm[0].polygons.length === 37 && sm[0].rects.length === 37, 'brown_spot 37 处 polygon/rectangle 全保留'); ok(am.length === 2 && am[0].rects.length === 6, '痘痘:closed_comedones 6 处 + acne 3 处(pustule/nodule 为空跳过)'); ok(sm[0].color === '#B26A00', '颜色样式随配置透传'); var half = pure.scale(sm, 0.5); ok(half[0].rects[0].left === sm[0].rects[0].left / 2 && half[0].polygons[0][0][0] === sm[0].polygons[0][0][0] / 2, 'vizScaleMarks 坐标按比例换算(0.5×)'); ok(half[0].rects[0].left + half[0].rects[0].width <= 1280 && half[0].polygons[0].every(function (pt) { return pt[0] <= 1280 && pt[1] <= 960; }), '缩放后坐标仍在照片范围内'); ok(pure.collect(SMOOTH_MARKS, null).length === 0 && pure.collect(null, fx.result).length === 0, '缺 result/缺配置 → 空集合(优雅降级)'); var noKey = pure.collect([{ key: 'not_exist', color: '#000' }], fx.result); ok(noKey.length === 0, '标记类型缺失 → 跳过不报错'); /* ─── ④ fixture 驱动真实评分管线 ─── */ console.log('== ④ SkinScore.buildViewModel(fixture) =='); var SkinScore = require(path.join(__dirname, '..', '..', 'js', 'skin-score.js')); var vm = SkinScore.buildViewModel(fx.result, fx.side, { realAge: 30, skinType: '中性肌肤' }); var catKeys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne']; var textsOK = true, scoreOK = true; catKeys.forEach(function (k) { var c = vm.cats[k]; if (!c || typeof c.text !== 'string' || /undefined|NaN/.test(c.text)) textsOK = false; if (c.P == null || typeof c.S !== 'number') scoreOK = false; }); ok(scoreOK, '6 分类全部出分(案例1 数据完整)'); ok(textsOK, '话术无 undefined/NaN'); ok(typeof vm.total === 'number' || vm.dims.firm.S != null, '综合评分/维度分可用'); console.log('\n' + (fail ? '✗ 失败 ' + fail + ' 项,' : '✓ 全部通过,') + '共 ' + (pass + fail) + ' 项断言'); process.exit(fail ? 1 : 0);