feat(h5): calculate integer total from weighted dimensions across reports and admin

This commit is contained in:
xsl
2026-10-14 14:25:17 +08:00
parent 61a7f7c726
commit d8c67762da
16 changed files with 1891 additions and 1435 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);生物可视年龄直接展示算法原始肌龄,不再按实际年龄 ±3 修正;三视图版 18 条话术;
- 精华推荐三重判据(均分档/最差项约束/年龄组档)取最强档,triggers 记录命中判据。
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄读取 `SkinScore.reportSkinAge(vm, result)`;历史报告同样读取原始肌龄而非旧修正展示值,原值缺失显示 `--`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 头部:综合得分通过 `SkinScore.weightedTotal(vm.dims)` 计算,紧塑力×4+透亮感×3+稳韧力×3,使用维度S分数(两位小数)加权、四舍五入取整数,不使用接口 `total_score`;任一维度缺失显示 `--`。后台列表、详情、CSV采用相同公式,历史报告按已保存维度计算展示,不改写原文件;肤龄读取 `SkinScore.reportSkinAge(vm, result)`,原值缺失显示 `--`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 三个 Tab 面板各展示所辖两分类:分类名、S + 档位徽章、话术正文(`vm.cats.*.text`);P 值不直接展示,`P=null` 的分类显示"本次未检出有效数据";
- 报告底部:降级标注(`vm.notes` 中与侧脸/下颌线/补拍相关的条目,去掉接口质量码后展示)、护理建议五段(`vm.careAdvice`)、精华推荐卡(`vm.essence.name` + 命中判据;推荐正文已在护理建议第 4 段,卡内不重复);
- 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。
+1 -1
View File
@@ -72,7 +72,7 @@
<thead>
<tr>
<th>照片</th><th>记录ID</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>检测状态</th><th>总分</th><th>肌龄(原始 / 展示)</th><th>三维度评分 / 档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
<th>检测状态</th><th title="紧塑力×4+透亮感×3+稳韧力×3,四舍五入取整数">综合得分</th><th>肌龄(原始 / 展示)</th><th>三维度评分 / 档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
</tr>
</thead>
<tbody></tbody>
+4 -2
View File
@@ -22,6 +22,7 @@ function fmtTime(iso) {
}
function fmtMs(ms) { return ms == null ? '-' : ms + 'ms'; }
function fmtScore(v) { return v == null ? '-' : v; }
function fmtTotal(v) { return v == null ? '--' : v; }
var LV_CLASS = { '预警': 'lv-bad', '改善': 'lv-warn', '良好': 'lv-good' };
var DIM_NAMES = ReportUI.dimNames;
@@ -145,7 +146,7 @@ function loadRecords() {
'<td>' + esc(it.upload_status) + '</td>' +
'<td><span class="st ' + (ST_CLASS[it.detect_status] || '') + '">' + esc(it.detect_status) + '</span>' +
(it.fail_code ? '<div class="sub">' + esc(it.fail_code) + '</div>' : '') + '</td>' +
'<td><b>' + fmtScore(it.total_score) + '</b></td>' +
'<td><b>' + fmtTotal(it.total_score) + '</b></td>' +
'<td>' + skinAgeHtml(it) + '</td>' +
'<td>' + dimsHtml(it.dims) + '</td>' +
'<td>' + esc(productName(it.essence)) + '<div class="sub">' + esc(ruleLabel(it)) + '</div></td>' +
@@ -405,13 +406,14 @@ function renderDetail(data) {
/* 成功:评分 + 文案 */
if (r.detect_status === '成功') {
h += dSec('综合', '<div class="d-kv">' +
'<span>总分 <b>' + fmtScore(r.total_score) + '</b></span>' +
'<span>综合得分 <b>' + fmtTotal(r.total_score) + '</b></span>' +
'<span>原始肌龄(新推荐依据) <b>' + ageText(r.skin_age) + '</b></span>' +
'<span>前端展示肌龄 <b>' + ageText(r.skin_age_display) + '</b></span>' +
'<span>六分类均分S <b>' + fmtScore(r.avg_s) + '</b></span>' +
'<span>左下颌线角 <b>' + (r.jawline_angle_l != null ? r.jawline_angle_l + '°' : '-') + '</b></span>' +
'<span>右下颌线角 <b>' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + '</b></span>' +
'</div>');
h += '<p class="sub">综合得分=紧塑力×4+透亮感×3+稳韧力×3,按已计算的维度S分数(两位小数)加权,四舍五入取整数;任一维度缺失不生成综合得分,不使用接口总分。</p>';
h += '<p class="sub">前端展示肌龄沿用实际年龄±3岁的修正;新推荐只使用原始肌龄。历史报告保留当时的推荐。</p>';
/* 6 分类表格 */
+2 -7
View File
@@ -1487,13 +1487,8 @@
function renderReport(vm, frontPhoto, sidePhoto, payload) {
reportOwner = AccountClient.user() && AccountClient.user().id;
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
var total = vm.total;
if (total == null) {
var ds = [vm.dims.firm.S, vm.dims.vita.S, vm.dims.stab.S], sum = 0, n = 0, i;
for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; }
if (n) total = Math.round(sum / n * 10);
}
// Historical snapshots also use their saved dimensions, never the old API total.
var total = SkinScore.weightedTotal(vm.dims);
// 环形综合评分:满分 100,环长 2πr(r=52)≈326.73
$('#stat-score').innerHTML = total != null ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
var ring = $('#ring-fg');
+16 -2
View File
@@ -63,6 +63,18 @@
}
function round2(x) { return Math.round((x + 1e-9) * 100) / 100; } // 1e-9 修正 9.445 类浮点
function sRaw(P) { return 7 + 3 * P / 100; }
// One shared 100-point total for H5, saved reports and admin. Never use the
// upstream total or reweight an incomplete set of dimensions.
function weightedTotal(dims) {
if (!dims) return null;
var keys = ['firm', 'vita', 'stab'], weights = [4, 3, 3], total = 0;
for (var i = 0; i < keys.length; i++) {
var score = dims[keys[i]] && dims[keys[i]].S;
if (!isNum(score) || score < 0 || score > 10) return null;
total += score * weights[i];
}
return Math.round(total + 1e-9);
}
function sShow(P) { // 展示层钳制:P 可为负(肤色扣分/大面积敏感),S 会 <7,与文档「S 7.0–10.0」口径矛盾;
if (P == null) return null; // 仅钳展示值,推荐边界比较仍用未舍入维度 S 原值
var s = sRaw(P);
@@ -814,7 +826,8 @@
careAdvice.push({ tag: '生活方式', text: worst ? LIFESTYLE[worst.catKey] : '' });
return {
total: pick(r, 'total_score'),
total: weightedTotal(dims),
totalRule: 'dimensions-433-v1',
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
@@ -902,6 +915,7 @@
return val(result && result.skin_age);
}
return {
weightedTotal: weightedTotal,
reportSkinAge: reportSkinAge,
buildViewModel: buildViewModel,
recommendProduct: recommendProduct,
@@ -952,7 +966,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm.avgS === 9.32, '六分类S均分 9.32');
ok(vm.worst.catKey === 'wrinkles' && vm.worst.P === 23, '最差分类 = 皱纹');
ok(vm.focus.name === '紧塑力', '近期优先关注 = 最差维度紧塑力');
ok(vm.total === 79, '综合评分 = score_info.total_score');
ok(vm.total === 88, '综合得分 = round(7.69×4+9.45×3+9.55×3) = 88,不使用接口总分79');
ok(vm.summary.indexOf('【综合】') === 0 && vm.summary.indexOf('25+束紧精华') > 0, '综合文案生成');
ok(vm.cats.smooth.text.indexOf('粗大毛孔296颗、黑头39颗') >= 0 && vm.cats.smooth.text.indexOf('纹理分87') >= 0,
'光滑话术含粗大毛孔/黑头/纹理分变量(分区计数 4 区求和 296)');
+8 -4
View File
@@ -281,6 +281,7 @@ function fillSuccess(rec, fpJson, persist = true) {
rec.recommendation = vm.recommendation;
rec.presentation = ReportUI.build(vm, fpJson.result);
rec.total_score = vm.total;
rec.total_score_rule = vm.totalRule;
rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left');
rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right');
rec.cats = {}; rec.copy_6 = {};
@@ -517,6 +518,9 @@ function displaySkinAge(rec) {
// Match H5 even for old records; do not rewrite historical files.
return SkinScore.reportSkinAge({ skinAgeRaw: rec.skin_age });
}
function displayTotalScore(rec) {
return SkinScore.weightedTotal(rec.dims);
}
function listItem(rec) {
return {
record_id: rec.record_id,
@@ -526,7 +530,7 @@ function listItem(rec) {
capture_quality: rec.capture_quality || null,
capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null,
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
total_score: rec.total_score, skin_age: rec.skin_age,
total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age: rec.skin_age,
skin_age_display: displaySkinAge(rec), recommendation: rec.recommendation || null,
dims: rec.dims, group: rec.group,
essence: rec.essence ? rec.essence.name : null,
@@ -614,7 +618,7 @@ function handleRecordDetail(req, res, id) {
.map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at }));
sendJson(res, 200, {
ok: true,
record: Object.assign({}, rec, { skin_age_display: displaySkinAge(rec),
record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec),
presentation: rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id) }),
raw_json: rawJson,
photos: photoUrls(rec),
@@ -774,7 +778,7 @@ function handleExport(req, res, url) {
const dimS = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].S : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelMap[r.dims[d].level] : null;
const header = ['记录ID', '创建时间', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '总分', '原始肌龄(推荐依据)', '前端展示肌龄',
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄(推荐依据)', '前端展示肌龄',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本'];
const lines = [header.join(',')];
@@ -782,7 +786,7 @@ function handleExport(req, res, url) {
lines.push([
r.record_id, r.created_at, r.real_age, r.gender, r.region, r.daily_skin_type,
r.shoot_status, r.upload_status, r.detect_status,
r.fail_stage, r.fail_code, r.fail_msg, r.total_score, r.skin_age, displaySkinAge(r),
r.fail_stage, r.fail_code, r.fail_msg, displayTotalScore(r), r.skin_age, displaySkinAge(r),
dimP(r, 'firm'), dimS(r, 'firm'), dimLv(r, 'firm'),
dimP(r, 'vita'), dimS(r, 'vita'), dimLv(r, 'vita'),
dimP(r, 'stab'), dimS(r, 'stab'), dimLv(r, 'stab'),
+1 -1
View File
@@ -108,7 +108,7 @@ function main() {
okNum(tag + '.AP 左下颌线角度', fixture.side.left.left_jawline_info.left_jawline_angle, tr.jawline.left);
okNum(tag + '.AQ 右下颌线角度', fixture.side.right.right_jawline_info.right_jawline_angle, tr.jawline.right);
okNum(tag + '.E 肌肤年龄', vm.skinAgeRaw, tr.skinAge);
okNum(tag + '.F 总分', vm.total, tr.totalScore);
okNum(tag + '.F 综合得分(4:3:3)', vm.total, Math.round(tr.dims.firm.S * 4 + tr.dims.vita.S * 3 + tr.dims.stab.S * 3));
// 六分类 P/S/档位 + 文案逐字
for (var k in CAT_COLS) {
+42
View File
@@ -46,6 +46,9 @@ try {
assert.equal(result.ok, true);
assert.equal(result.report_saved, true);
assert(result.vm && result.presentation);
const expectedTotal = Math.round(result.vm.dims.firm.S * 4 + result.vm.dims.vita.S * 3 + result.vm.dims.stab.S * 3);
assert.equal(result.vm.total, expectedTotal);
assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total');
assert(result.vm.skinAgeRaw < 32);
assert.equal(result.vm.skinAgeDisplay, result.vm.skinAgeRaw);
assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60.
@@ -55,24 +58,32 @@ try {
assert.deepEqual(adminRecord.recommendation, result.vm.recommendation);
assert.equal(adminRecord.skin_age, result.vm.skinAgeRaw);
assert.equal(adminRecord.skin_age_display, result.vm.skinAgeDisplay);
assert.equal(adminRecord.total_score, expectedTotal);
assert.equal(adminRecord.total_score_rule, 'dimensions-433-v1');
for (const key of ['firm', 'vita', 'stab']) assert.equal(adminRecord.dims[key].S, result.vm.dims[key].S);
const list = await (await api.get(base + 'api/admin/records')).json();
assert.equal(list.items[0].skin_age, result.vm.skinAgeRaw);
assert.equal(list.items[0].skin_age_display, result.vm.skinAgeRaw);
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
assert.equal(list.items[0].total_score, expectedTotal);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
assert(csv.includes(result.vm.recommendation.version));
assert(csv.includes('综合得分(4:3:3)'));
assert.equal(csv.trim().split('\n')[1].split(',')[12], String(expectedTotal));
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
assert.equal(snapshot.report.photos.length, 3);
assert.deepEqual(snapshot.report.presentation, result.presentation);
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
assert.equal(snapshot.report.vm.total, expectedTotal);
assert.equal(JSON.parse(fs.readFileSync(path.join(dataRoot, 'records.jsonl'), 'utf8').trim()).total_score, expectedTotal, 'new records persist weighted score');
const desktop = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
desktop.on('pageerror', e => errors.push(e.message));
await desktop.goto(base + 'admin.html');
const row = desktop.locator('#tbl-records tbody tr').first();
await row.waitFor();
assert.equal(await row.locator('td').nth(6).textContent(), String(expectedTotal));
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
@@ -92,6 +103,9 @@ try {
await row.click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据'));
const detailText = await desktop.locator('#d-body').textContent();
assert(detailText.includes('综合得分 ' + expectedTotal));
assert(detailText.includes('紧塑力×4+透亮感×3+稳韧力×3'));
if (process.env.H5_ARTIFACT_DIR) await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-weighted-detail.png') });
assert(detailText.includes(result.vm.recommendation.reason));
assert(detailText.includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
for (const view of result.presentation.views) assert(detailText.includes(view.text));
@@ -123,6 +137,11 @@ try {
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal));
if (process.env.H5_ARTIFACT_DIR) {
await page.waitForTimeout(400); // Capture after the report's entrance fade.
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'report-weighted-total.png') });
}
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
const imagesBefore = await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src));
const copyBefore = await page.locator('.ditem-para').allTextContents();
@@ -147,6 +166,7 @@ try {
legacy.presentation.product = { ...ReportUI.products[1], image: 'assets/img/ui-20261009/product-35.png' };
legacy.presentation.views[0].text = '历史报告原文,不应按新文案重新计算。';
legacy.vm.skinAgeDisplay = 57; // Old snapshot retains the retired display clamp.
legacy.vm.total = 12; // Old stored upstream total must not control the current display.
legacy.vm.essence = { name: '35+支撑精华', text: '旧版推荐', triggers: ['最差项约束'] };
delete legacy.vm.recommendation;
fs.writeFileSync(snapshotFile, JSON.stringify(legacy));
@@ -154,6 +174,7 @@ try {
delete record.presentation;
delete record.recommendation;
record.skin_age_display = 57;
record.total_score = 12;
fs.writeFileSync(recordFile, JSON.stringify(record) + '\n');
const storedBytes = fs.readFileSync(snapshotFile, 'utf8');
const recordBytes = fs.readFileSync(recordFile, 'utf8');
@@ -161,6 +182,12 @@ try {
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-age b').textContent(), String(result.vm.skinAgeRaw), 'old saved display clamp must not override original skin age');
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal), 'old H5 report uses saved dimensions, not saved upstream total');
const legacyList = await (await api.get(base + 'api/admin/records')).json();
assert.equal(legacyList.items[0].total_score, expectedTotal);
const legacyDetail = await (await api.get(base + 'api/admin/records/' + result.record_id)).json();
assert.equal(legacyDetail.record.total_score, expectedTotal);
assert.equal((await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',')[12], String(expectedTotal));
assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title);
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[1].image, 'refresh artwork crop, never the historical recommendation/copy');
assert((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text));
@@ -171,6 +198,21 @@ try {
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + result.vm.skinAgeRaw + '岁'));
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
// An incomplete set must show no total everywhere, not fall back to the API score.
const missingVm = JSON.parse(JSON.stringify(legacy.vm)); missingVm.dims.stab.S = null;
const missingRec = JSON.parse(JSON.stringify(record)); missingRec.dims.stab.S = null;
fs.writeFileSync(snapshotFile, JSON.stringify({ ...legacy, vm: missingVm }));
fs.writeFileSync(recordFile, JSON.stringify(missingRec) + '\n');
await page.reload(); await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]'); await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#stat-score b').textContent(), '--');
assert.equal((await (await api.get(base + 'api/admin/records')).json()).items[0].total_score, null);
assert.equal((await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record.total_score, null);
await desktop.goto(base + 'admin.html');
assert.equal(await desktop.locator('#tbl-records tbody tr').first().locator('td').nth(6).textContent(), '--');
assert.equal((await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',')[12], '');
fs.writeFileSync(recordFile, recordBytes);
// Missing skin age must not leave the previous product visible in the H5.
delete legacy.payload.result.skin_age;
const SkinScore = require('../js/skin-score.js');
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const assert = require('node:assert/strict');
const Score = require('../js/skin-score');
const dims = (firm, vita, stab) => ({ firm: { S: firm }, vita: { S: vita }, stab: { S: stab } });
for (const [scores, total] of [
[[8, 9, 8.5], 85], [[10, 10, 10], 100], [[7, 7, 7], 70],
[[10, 7, 7], 82], [[7, 10, 7], 79], [[7, 7, 10], 79],
[[8.01, 8.82, 8], 83], [[7.69, 8.72, 9.1], 84]
]) assert.equal(Score.weightedTotal(dims(...scores)), total, scores.join('/'));
for (const invalid of [null, undefined, NaN, Infinity, -Infinity, '9', -1, 11]) {
for (const key of ['firm', 'vita', 'stab']) {
const d = dims(8, 9, 8.5); d[key].S = invalid;
assert.equal(Score.weightedTotal(d), null, 'no reweighting invalid/missing ' + key);
}
}
for (const d of [null, {}, { firm: { S: 9 }, vita: { S: 8 } }]) assert.equal(Score.weightedTotal(d), null);
const input = JSON.parse(JSON.stringify(Score.MOCK_RESULT));
const before = JSON.stringify(input);
const expected = Score.buildViewModel(input, Score.MOCK_SIDE, {});
assert.equal(expected.totalRule, 'dimensions-433-v1');
assert.equal(JSON.stringify(input), before, 'preserve raw upstream data');
for (const value of [0, 100, null, undefined]) {
const r = JSON.parse(JSON.stringify(input));
if (value === undefined) delete r.score_info.total_score; else r.score_info.total_score = value;
const changed = Score.buildViewModel(r, Score.MOCK_SIDE, {});
assert.equal(changed.total, expected.total, 'API total has no influence');
assert.deepEqual(changed.dims, expected.dims);
assert.deepEqual(changed.recommendation, expected.recommendation);
}
assert.equal(Score.buildViewModel({}, null, {}).total, null);
console.log('PASS weighted total: 4:3:3, integer/half rounding, missing/invalid dimensions, API total ignored, source/scores/recommendation unchanged');