feat(h5): apply October 12 customer report feedback

This commit is contained in:
xsl
2026-10-12 13:15:09 +08:00
parent 3193a4b683
commit d7ae748e08
22 changed files with 2282 additions and 1027 deletions
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const assert = require('assert/strict');
const fs = require('fs');
const vm = require('vm');
const path = require('path');
const SkinScore = require('../js/skin-score');
const ReportUI = require('../js/report-ui');
const result = JSON.parse(fs.readFileSync(path.join(__dirname, '../../reference/facepp-skinanalyze/examples/ref_3view_raw.json'))).result;
const details = ReportUI.wrinkleDetails(result);
assert.equal(details.length, 12);
assert.equal(new Set(details.map(row => row.key)).size, 12);
assert.equal(details[0].name, '抬头纹(额头纹)');
assert.deepEqual(details[0].metrics, {
wrinkle_score: 13, wrinkle_severity_level: 1, wrinkle_deep_num: 22, wrinkle_shallow_num: 18,
wrinkle_norm_length: result.forehead_wrinkle_info.wrinkle_norm_length,
wrinkle_norm_depth: result.forehead_wrinkle_info.wrinkle_norm_depth,
wrinkle_pixel_density: result.forehead_wrinkle_info.wrinkle_pixel_density,
wrinkle_area_ratio: result.forehead_wrinkle_info.wrinkle_area_ratio,
wrinkle_deep_ratio: result.forehead_wrinkle_info.wrinkle_deep_ratio
});
assert.equal(details.find(row => row.key === 'left_eye').metrics.wrinkle_deep_num, 58);
assert.equal(details.find(row => row.key === 'right_eye').metrics.wrinkle_shallow_num, 46);
assert.equal(details.find(row => row.key === 'left_cheek').metrics.wrinkle_score, 0);
const alternate = ReportUI.wrinkleDetails({ right_nasolabial_fold_info: { wrinkle_score: { value: 31 }, wrinkle_severity_level: { value: 2 } },
wrinkle_count: { right_nasolabial_count: 5 }, fine_line: { right_nasolabial_count: 0 } });
assert.equal(alternate.find(row => row.key === 'right_nasolabial').metrics.wrinkle_score, 31);
assert.equal(alternate.find(row => row.key === 'right_nasolabial').metrics.wrinkle_deep_num, 5);
assert.equal(alternate.find(row => row.key === 'right_nasolabial').metrics.wrinkle_shallow_num, 0);
assert(Object.values(alternate[0].metrics).every(value => value === null));
const model = SkinScore.buildViewModel(result, SkinScore.MOCK_SIDE, { realAge: 30 });
const modelBefore = JSON.stringify(model), rawBefore = JSON.stringify(result);
const presentation = ReportUI.build(model, result);
const text = presentation.views.find(view => view.key === 'wrinkles').text;
for (const row of details) assert(text.includes(row.name));
assert(text.includes('标准化长度 1.2088'));
assert.equal(JSON.stringify(model), modelBefore);
assert.equal(JSON.stringify(result), rawBefore);
const excellent = JSON.parse(JSON.stringify(model));
excellent.cats.smooth.level = '良好'; // Legacy level name maps to new "优秀".
excellent.cats.smooth.vars.pore_cnt = 7;
excellent.cats.smooth.vars.blackhead_cnt = 2;
let smooth = ReportUI.build(excellent, result).views.find(view => view.key === 'smooth');
assert(smooth.text.includes('粗大毛孔 7 颗、黑头 2 颗'));
assert(!smooth.text.includes('无明显粗大毛孔'));
assert.equal(smooth.level, '优秀');
excellent.cats.smooth.vars.pore_cnt = null;
smooth = ReportUI.build(excellent, result).views.find(view => view.key === 'smooth');
assert(smooth.text.includes('未完整获取'));
const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8');
const pure = source.match(/\/\* viz-pure-begin[\s\S]*?\*\/([\s\S]*?)\/\* viz-pure-end \*\//)[1];
const sandbox = {}; vm.runInNewContext(pure, sandbox);
const crop = sandbox.vizFaceCrop(4000, 3000, { left: 1479, top: 1133, width: 1116, height: 1116 });
assert(crop.focused);
assert(crop.width < 1600 && crop.height < 1800);
assert(crop.left <= 1479 && crop.top <= 1133);
assert(crop.left + crop.width >= 2595 && crop.top + crop.height >= 2249);
assert(Math.abs(crop.width / crop.height - 215 / 278) < 1e-8);
for (const rect of [null, { left: NaN }, { left: 0, top: 0, width: -1, height: 20 },
{ left: 3900, top: 0, width: 1000, height: 1000 }, { left: 0, top: 0, width: '100', height: 100 }]) {
const full = sandbox.vizFaceCrop(4000, 3000, rect);
assert.equal(full.focused, false); assert.equal(full.width, 4000);
}
for (const rect of [{ left: 0, top: 0, width: 300, height: 300 }, { left: 700, top: 700, width: 300, height: 300 }]) {
const edge = sandbox.vizFaceCrop(1000, 1000, rect);
assert(edge.focused); assert(edge.left >= 0 && edge.top >= 0);
assert(edge.left + edge.width <= 1000 && edge.top + edge.height <= 1000);
assert(edge.left <= rect.left && edge.top <= rect.top);
assert(edge.left + edge.width >= rect.left + rect.width && edge.top + edge.height >= rect.top + rect.height);
}
console.log('PASS: 12 unique wrinkle regions, all nine real metrics, aliases/count fallbacks, missing≠zero; positive pore counts vs excellent score; no raw/model/algorithm mutations; face crop/aspect/edge containment/invalid-box fallback');
+110
View File
@@ -0,0 +1,110 @@
// Read-only mock page. No account creation, report writes or Face++ call.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--' && [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
const cdp = await page.context().newCDPSession(page);
async function swipe(card, dx, dy = 0) {
const frame = card.locator('.dpanel.active .viz-frame');
await frame.scrollIntoViewIfNeeded();
const box = await frame.boundingBox(), x = box.x + box.width / 2, y = Math.min(500, box.y + box.height / 2);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
for (let i = 1; i <= 8; i++) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x + dx * i / 8, y: y + dy * i / 8 }] });
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(300);
}
const cards = page.locator('.card-detail');
await swipe(cards.nth(0), -85);
assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '1');
assert.equal(await page.locator('#p-zoom.show').count(), 0);
await swipe(cards.nth(0), 85);
assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0');
await swipe(cards.nth(0), 0, -85);
assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0', 'vertical scrolling must not change tab');
await swipe(cards.nth(1), -85);
assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '1');
await swipe(cards.nth(1), -85);
assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '2');
await swipe(cards.nth(1), 85);
await cards.nth(1).locator('.dtab').first().click();
assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '0');
await cards.nth(1).locator('.dtab').first().focus();
await page.keyboard.press('ArrowRight');
assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '1');
await swipe(cards.nth(2), -85);
assert.equal(await cards.nth(2).locator('.dtab.active').getAttribute('data-tab'), '1');
assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0');
const image = cards.nth(0).locator('.dpanel.active .ditem-img');
await image.click();
await page.waitForSelector('#p-zoom.show');
assert.equal(await page.locator('#zoom-img').getAttribute('src'), await image.getAttribute('src'));
await page.click('#p-zoom');
await page.waitForSelector('#p-zoom.show', { state: 'hidden' });
await page.waitForTimeout(300);
assert((await page.locator('#ditem-para2').textContent()).includes('抬头纹(额头纹)'));
assert((await page.locator('#ditem-para2').textContent()).includes('右法令纹'));
assert((await page.locator('#smooth-map-note').textContent()).includes('毛孔'));
const focused = await page.locator('.ditem-img').evaluateAll(images => images.map(i => ({ focused: i.dataset.crop, ratio: i.naturalWidth / i.naturalHeight })));
assert(focused.every(i => i.focused === 'face' && Math.abs(i.ratio - 215 / 278) < .005));
const fontSizes = [];
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 844 });
await page.waitForTimeout(150);
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'page overflow ' + width);
fontSizes.push(await page.locator('#tx-tone').evaluate(e => getComputedStyle(e).fontSize));
assert.equal(await page.locator('#product-name .product-line').count(), 2);
for (const line of await page.locator('#product-name .product-line').all()) {
assert.equal(await line.evaluate(e => getComputedStyle(e).whiteSpace), 'nowrap');
assert(await line.evaluate(e => e.scrollWidth <= e.clientWidth), 'product line overflow ' + width);
}
}
assert(fontSizes.every(size => size === '12px'), JSON.stringify(fontSizes));
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--yellow').trim()), '#F5F078');
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.setViewportSize({ width: 375, height: 726 });
await image.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '01-face-focused.png'), animations: 'disabled' });
await cards.nth(0).locator('.dtab').nth(1).click();
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '02-wrinkle-regions.png'), animations: 'disabled' });
await cards.nth(1).locator('.dtab').nth(1).click();
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '03-smooth-maps.png'), animations: 'disabled' });
await page.locator('.card-product').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '04-product-lines.png'), animations: 'disabled' });
}
// Change only the in-browser fixture. Exercise the actual renderer and age-only
// recommendation outside boundary windows, without production report writes.
for (const [age, name, tier] of [[40, '以我自在有势挺括', 'II'], [60, '以我从容有态构塑', 'III']]) {
await page.route('**/assets/mock/report-fixture.js', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: await response.text() + '\nMOCK_FIXTURE.result.skin_age = { value: ' + age + ' };\ndelete MOCK_FIXTURE.result.face_maps.texture_enhanced_blackheads;' });
});
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#product-name .product-line'));
assert.equal(await page.locator('#product-title').textContent(), "1'me 主场精华 " + tier);
assert.equal(await page.locator('#product-name .product-line').first().textContent(), name);
assert(!(await page.locator('#smooth-map-note').textContent()).includes('红色'), 'missing blackhead map must not be claimed');
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 844 });
for (const line of await page.locator('#product-name .product-line').all()) {
assert(await line.evaluate(e => e.scrollWidth <= e.clientWidth), 'tier ' + tier + ' product line overflow ' + width);
}
assert(await page.locator('#product-title').evaluate(e => e.scrollWidth <= e.clientWidth), 'tier ' + tier + ' title overflow ' + width);
}
await page.unroute('**/assets/mock/report-fixture.js');
}
assert.deepEqual(errors, []);
console.log('PASS: real mobile swipes in three independent groups, vertical scroll vs swipe, click/keyboard tabs, no zoom after swipe, zoom still works, seven face-focused maps, 12-region copy, fixed fonts/all three product lines in five widths, partial map caption, PSD yellow, no JS errors');
} finally { await browser.close(); }