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
@@ -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');