fix(recommendation): use displayed skin age across reports and admin

This commit is contained in:
xsl
2026-10-16 19:03:25 +08:00
parent 571d78a2ed
commit bb6da17f54
12 changed files with 106 additions and 35 deletions
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const assert=require('node:assert/strict'),SS=require('../js/skin-score'),UI=require('../js/report-ui');
// The reported case: raw34, actual44 => displayed39, product II.
const old={skinAgeRaw:34,realAge:44,skinAgeDisplay:39,dims:{firm:{P:76,S:9.28},vita:{P:76.2,S:9.29},stab:{P:95.5,S:9.87}},
recommendation:{version:'old',scores:{firm:9.28,vita:9.286,stab:9.865}},essence:{name:'25+束紧精华'},summary:'原结论',cats:{},careAdvice:[]};
const bytes=JSON.stringify(old),updated=SS.withDisplayRecommendation(old);
assert.equal(updated.recommendation.skinAge,39);assert.equal(updated.recommendation.ageBasis,'display');assert.equal(updated.essence.tier,2);
assert.equal(updated.skinAgeRaw,34);assert.equal(updated.summary,old.summary);assert.strictEqual(updated.dims,old.dims);assert.equal(JSON.stringify(old),bytes);
const saved={gradeRule:UI.gradeRule,copyRule:UI.copyRule,product:UI.products[0],views:[],care:[]},before=JSON.stringify(saved);
const presentation=UI.forDisplay(updated,{},saved);assert.equal(presentation.product.tier,2);assert.equal(JSON.stringify(saved),before);assert.strictEqual(presentation.views,saved.views);
for(const [raw,actual,display,tier] of [[24,60,55,3],[55,30,35,1],[34,44,39,2],[null,44,null,null],[34,null,34,1]]){
const vm=SS.withDisplayRecommendation({...old,skinAgeRaw:raw,realAge:actual});assert.equal(vm.skinAgeDisplay,display);assert.equal(vm.essence.tier,tier);
}
// New reports also use the exact same age displayed by the report renderer.
for(const actual of [24,40,44,60]){
const raw=JSON.parse(JSON.stringify(SS.MOCK_RESULT));raw.skin_age={value:34};const vm=SS.buildViewModel(raw,SS.MOCK_SIDE,{realAge:actual});
assert.equal(vm.recommendation.skinAge,SS.reportSkinAge(vm,raw));assert.equal(vm.skinAgeRaw,34);
}
console.log('PASS: real case raw34/display39 => II; new/history identical display-age basis; boundary/missing age; scores/copy/saved snapshots unchanged');
+1 -1
View File
@@ -35,7 +35,7 @@ old.product={tier:3,title:'历史推荐不改'};
const oldBytes=JSON.stringify(old), vmBytes=JSON.stringify(vm);
const display=UI.forDisplay(vm,r,old);
assert.equal(display.views[0].level,'优秀');
assert.deepEqual(display.product,old.product);
assert.deepEqual(display.product,ui.product);
assert.deepEqual(display.care.map(v=>v.key),old.care.map(v=>v.key));
assert.equal(JSON.stringify(old),oldBytes);assert.equal(JSON.stringify(vm),vmBytes);
assert.equal(UI.forDisplay(vm,r,ui),ui);
+8 -7
View File
@@ -7,7 +7,7 @@ function check(age, firm, vita, stab, tier, adjustment = 'none') {
const r = SS.recommendProduct(age, { firm, vita, stab });
assert.equal(r.tier, tier, `age=${age}; scores=${firm}/${vita}/${stab}`);
assert.equal(r.adjustment, adjustment);
assert.equal(r.version, '2026-10-12-skin-age-v1');
assert.equal(r.version, '2026-10-16-display-skin-age-v2');
if (tier) assert(Math.abs(r.tier - r.baseTier) <= 1);
count++;
}
@@ -50,11 +50,12 @@ const raw = JSON.parse(JSON.stringify(SS.MOCK_RESULT));
raw.skin_age = { value: 24 };
for (const realAge of [24, 40, 60, undefined]) {
const vm = SS.buildViewModel(raw, SS.MOCK_SIDE, { realAge });
assert.equal(vm.essence.tier, 1); // Weak wrinkles never upgrade under32.
assert.equal(vm.recommendation.skinAge, 24);
const age=realAge==null?24:Math.max(realAge-5,Math.min(realAge+5,24));
assert.equal(vm.essence.tier, SS.recommendProduct(age,vm.recommendation.scores).tier);
assert.equal(vm.recommendation.skinAge, age);
assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12);
assert.equal(UI.build(vm, raw).product.tier, 1);
assert.equal(vm.skinAgeDisplay, realAge == null ? 24 : Math.max(realAge - 5, Math.min(realAge + 5, 24))); // Display clamp does not change raw-age recommendation.
assert.equal(UI.build(vm, raw).product.tier,vm.essence.tier);
assert.equal(vm.skinAgeDisplay, realAge == null ? 24 : Math.max(realAge - 5, Math.min(realAge + 5, 24)));
count++;
}
delete raw.skin_age;
@@ -62,6 +63,6 @@ const missing = SS.buildViewModel(raw, SS.MOCK_SIDE, { realAge: 60 });
assert.equal(missing.group, null);
assert.equal(missing.essence.tier, null);
assert.equal(UI.build(missing, raw).product, null);
assert(missing.recommendation.reason.includes('未获取有效原始肌龄'));
assert(missing.recommendation.reason.includes('未获取有效展示肌龄'));
count++;
console.log(`PASS: ${count} recommendation cases; boundary/threshold/missing/raw-age/actual-age isolation`);
console.log(`PASS: ${count} recommendation cases; unchanged boundary/threshold rules, display-age input and missing-age handling`);
+10 -10
View File
@@ -73,8 +73,8 @@ try {
assert(result.vm.skinAgeRaw < 32);
const expectedAge = Math.max(55, Math.min(65, result.vm.skinAgeRaw));
assert.equal(result.vm.skinAgeDisplay, expectedAge);
assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60.
assert.equal(result.presentation.product.tier, 1);
assert.equal(result.vm.essence.tier, 3); // Display age 55, not raw age below32.
assert.equal(result.presentation.product.tier, 3);
const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
assert.deepEqual(adminRecord.presentation, result.presentation);
assert.deepEqual(adminRecord.recommendation, result.vm.recommendation);
@@ -101,7 +101,7 @@ try {
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('原始肌龄,前端展示肌龄(推荐依据)'));
assert(csv.includes(result.vm.recommendation.version));
assert(csv.includes('综合得分(4:3:3)'));
assert.equal(csv.trim().split('\n')[1].split(',')[14],String(expectedAge));
@@ -125,7 +125,7 @@ try {
assert.equal(await row.locator('td').nth(6).textContent(), String(expectedTotal));
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 ' + expectedAge + '岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
assert((await row.textContent()).includes("1'me 主场精华 III(50+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelForScore(result.vm.dims[key].S)));
}
@@ -154,11 +154,11 @@ try {
}
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('前端展示肌龄 ' + expectedAge + '岁'));
assert(detailText.includes('报告展示肌龄(推荐依据) ' + expectedAge + '岁'));
for (const view of result.presentation.views) assert(detailText.includes(view.text));
for (const care of result.presentation.care) assert(detailText.includes(care.text));
if (process.env.H5_ARTIFACT_DIR) {
await desktop.locator('.d-sec').filter({ hasText: '使用原始肌龄' }).scrollIntoViewIfNeeded();
await desktop.locator('.d-sec').filter({ hasText: '使用展示肌龄' }).scrollIntoViewIfNeeded();
await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-recommendation.png') });
}
await desktop.click('#btn-close');
@@ -249,15 +249,15 @@ try {
const legacyCsv=(await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',');
assert.equal(legacyCsv[12], String(expectedTotal));
assert.equal(legacyCsv[14], String(expectedAge));
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.equal(await page.locator('#product-title').textContent(), ReportUI.products[2].title);
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[2].image, 'historical product refreshes to current displayed age without rewriting stored snapshot');
assert((await page.locator('.ditem-para').allTextContents()).includes(ReportUI.forDisplay(legacy.vm,legacy.payload.result,legacy.presentation).views[0].text));
assert(!(await page.locator('#care-list').textContent()).includes('旧护理待刷新'));
await desktop.goto(base + 'admin.html');
await desktop.locator('#tbl-records tbody tr').first().click();
await desktop.waitForFunction(text => document.querySelector('#d-body').textContent.includes(text), legacyDetail.record.presentation.views[0].text);
assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)'));
assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + expectedAge + '岁'));
assert((await desktop.locator('#d-body').textContent()).includes('2026-10-16-display-skin-age-v2'));
assert((await desktop.locator('#d-body').textContent()).includes('报告展示肌龄(推荐依据) ' + expectedAge + '岁'));
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.