feat(h5): apply second customer UI feedback and homepage refinements

This commit is contained in:
xsl
2026-10-12 14:35:39 +08:00
parent abb1c4c1de
commit fe9623a1d7
16 changed files with 1963 additions and 1082 deletions
+69
View File
@@ -0,0 +1,69 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8');
function extract(name) { return source.split('/* ' + name + '-begin */')[1].split('/* ' + name + '-end */')[0]; }
const context = {};
vm.runInNewContext(extract('camera-outline-pure') + extract('camera-haptic-pure'), context);
const geometry = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../js/camera-geometry.js'), 'utf8'), geometry);
const original = JSON.stringify(geometry.window.CameraGeometry);
for (const row of geometry.window.CameraGeometry) {
const rounded = context.smoothCameraOutline(row.outline);
assert(rounded.includes('Q') && rounded.endsWith('Z'));
assert(!/NaN|Infinity/.test(rounded));
// Curve endpoints/control points remain within the exported contour bounds.
const oldNumbers = row.outline.match(/[-+]?\d*\.?\d+/g).map(Number);
const newNumbers = rounded.match(/[-+]?\d*\.?\d+/g).map(Number);
for (let axis = 0; axis < 2; axis++) {
const oldAxis = oldNumbers.filter((_, i) => i % 2 === axis), newAxis = newNumbers.filter((_, i) => i % 2 === axis);
assert(Math.min(...newAxis) >= Math.min(...oldAxis) - .001);
assert(Math.max(...newAxis) <= Math.max(...oldAxis) + .001);
assert(Math.abs(Math.min(...newAxis) - Math.min(...oldAxis)) <= 2);
assert(Math.abs(Math.max(...newAxis) - Math.max(...oldAxis)) <= 2);
}
}
assert.equal(JSON.stringify(geometry.window.CameraGeometry), original);
for (const value of [null, '', 'M--Z', 'M1,2L1,2Z', 'M0,0L0,0L0,0Z', 'M0,0Q1,2 3,4Z']) assert.equal(context.smoothCameraOutline(value), value);
for (const nav of [null, {}, { vibrate: false }, { vibrate: () => false }, { vibrate: () => { throw Error('denied'); } }]) assert.equal(context.captureHaptic(nav), false);
let pattern;
assert(context.captureHaptic({ vibrate: p => { pattern = Array.from(p); return true; } }));
assert.deepEqual(pattern, [70, 40, 70]);
// Exercise the actual readiness state machine; only camera/pose inputs and clock
// are stubs. No detector thresholds, hold time or capture sequence are changed.
let now = 10000, buzzes = 0, captures = 0;
const state = {
missFrames: 0, MISS_FRAMES_MAX: 3, camVideo: { videoHeight: 600 }, FACE_NEAR: .30, FACE_FAR: .68,
faceOffsetX: () => 0, CENTER_TOL: .18, CAM_STEPS: [{ target: 'front' }], camStep: 0,
HeadPose: { check: () => ({ ok: true }) }, sideYawFlip: false, faceLuma: () => 100,
LUMA_DARK: 65, LUMA_BRIGHT: 235, window: {}, setMask: () => {}, setHint: () => {},
okSince: 0, readyHapticAt: 0, OK_HOLD_MS: 450, Date: { now: () => now },
navigator: { vibrate: () => { buzzes++; return true; } }, doCapture: () => { captures++; }
};
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function onDetect(d)'), source.indexOf(' /* ─── 拍照流程')), state);
const face = { detection: { box: { height: 250 } }, landmarks: { positions: [] } };
state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 200; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 250; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 1);
now += 100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
now += 100; state.onDetect(face); assert.equal(buzzes, 1, 'jitter must not repeatedly buzz');
now += 2100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
state.onDetect(face); assert.equal(buzzes, 2);
state.navigator.vibrate = () => { throw Error('blocked'); };
now += 450; state.onDetect(face); assert.equal(captures, 2, 'vibration failure must not prevent capture');
for (const manual of [true, false]) {
let manualBuzzes = 0, scheduled = [];
const shutter = { shutterLock: false, camLive: true, capturing: false, camSeq: 1,
isManualMode: () => manual, navigator: { vibrate: () => { manualBuzzes++; throw Error('blocked'); } },
clearTimeout: () => {}, nudgeTimer: null, hideNudge: () => {},
$: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) };
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function doCapture()'), source.indexOf(' // 三张拍完:按视角重排')), shutter);
shutter.doCapture();
assert.equal(manualBuzzes, manual ? 1 : 0, 'auto must not double buzz; manual must retain its cue');
assert(shutter.shutterLock && shutter.capturing);
assert.deepEqual(scheduled, [300], 'blocked haptic must still schedule camera capture');
}
console.log('PASS: three bounded rounded contours, invalid outline fallback, no geometry mutation; haptic missing/false/exception safety, pre-capture cue, jitter throttling, unchanged 450ms auto-capture');
+129
View File
@@ -0,0 +1,129 @@
// Browser-local session/preview fixtures. No account/report writes or paid calls.
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 artifacts = process.env.H5_ARTIFACT_DIR;
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
const errors = [], requests = [];
page.on('pageerror', e => errors.push(e.message));
page.on('request', r => requests.push(new URL(r.url()).pathname));
await page.route('**/api/auth/session', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, user: { id: 'second-batch-fixture', kind: 'browser' } }) }));
await page.goto(base);
await page.waitForSelector('#s-splash.active');
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /\.woff2$|faceapi/.test(p)), 'entry must not download complete fonts or face engine');
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 726 });
const home = await page.evaluate(() => {
const en = getComputedStyle(document.querySelector('.splash-slogan-en'));
const zh = getComputedStyle(document.querySelector('.splash-slogan-zh'));
const text = document.querySelector('#s-splash .brand-text');
const style = getComputedStyle(text);
const logo = document.querySelector('#s-splash .brand-logo').getBoundingClientRect();
const row = document.querySelector('#s-splash .brand-row').getBoundingClientRect();
const slogan = document.querySelector('.splash-slogan').getBoundingClientRect();
const badge = document.querySelector('.account-status-home').getBoundingClientRect();
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
const metrics = ctx.measureText(text.textContent);
return { en: en.fontWeight, zh: zh.fontWeight, size: style.fontSize, weight: style.fontWeight,
logoHeight: logo.height, inkHeight: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
logoRatio: logo.width / logo.height, rowX: row.x, rowRight: row.right,
badgeBottom: badge.bottom, sloganTop: slogan.top,
sloganLines: slogan.height / parseFloat(zh.lineHeight), overflow: document.documentElement.scrollWidth > innerWidth };
});
assert.equal(home.en, '400'); assert.equal(home.zh, '700');
assert.equal(home.size, '24px'); assert.equal(home.weight, '700');
assert.equal(home.logoHeight, 26);
assert(Math.abs(home.inkHeight - home.logoHeight) <= 4, 'logo/text optical height mismatch: ' + JSON.stringify(home));
assert(Math.abs(home.logoRatio - 132 / 59) < .01, 'logo must retain original aspect ratio');
assert(home.rowX >= 0 && home.rowRight <= width && !home.overflow, 'brand row overflows at ' + width);
assert(home.badgeBottom <= home.sloganTop, 'status badge overlaps slogan at ' + width);
assert(Math.abs(home.sloganLines - 3) < .1, 'slogan must retain three lines at ' + width);
}
await page.setViewportSize({ width: 375, height: 726 });
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, '00-home-clarified.png'), animations: 'disabled' });
}
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /\.woff2$|faceapi/.test(p)), 'home typography must not add full fonts or face engine');
await page.click('[data-action="open-privacy"]');
await page.waitForSelector('link[href="css/fonts.css"]', { state: 'attached' });
await page.evaluate(async () => { await document.fonts.load('700 23px "MiSans Demibold"', "欢迎体验肌肤状态评估服务"); });
await page.waitForLoadState('networkidle');
assert(requests.some(p => /MiSans-Demibold\.woff2$/.test(p)), 'consent must load PSD Demibold before profile');
assert.equal(await page.locator('#p-privacy .popup-title').evaluate(e => getComputedStyle(e).fontWeight), '700');
const cdp = await page.context().newCDPSession(page);
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
async function actualFont(selector, expected) {
const { root } = await cdp.send('DOM.getDocument');
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector });
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId });
assert(fonts.some(f => f.isCustomFont && new RegExp(expected, 'i').test(f.postScriptName || f.familyName)), selector + ': ' + JSON.stringify(fonts));
}
await actualFont('#p-privacy .popup-title', 'Demibold');
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, '01-consent-demibold.png'), animations: 'disabled' });
}
await page.click('[data-action="close-privacy"]');
// Synthetic local stream only, to inspect full-bleed framing without camera permission.
await page.evaluate(async () => {
document.querySelectorAll('.screen').forEach(s => s.classList.toggle('active', s.id === 's-cam'));
document.querySelector('#s-cam').classList.add('cam-live');
const canvas = document.createElement('canvas'); canvas.width = 375; canvas.height = 726;
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#587581'; ctx.fillRect(0, 0, canvas.width, canvas.height);
const video = document.querySelector('#cam-video'); video.srcObject = canvas.captureStream(5); await video.play();
});
await page.waitForTimeout(400); // Let the existing screen-entry translate animation finish.
await page.evaluate(async () => { await document.fonts.load('700 23px "MiSans Demibold"', '请正脸出镜'); });
await actualFont('#cam-step', 'Demibold');
for (const [step, label] of [['0', '1/3<br>请正脸出镜'], ['2', '2/3<br>请右脸出镜'], ['1', '3/3<br>请左脸出镜']]) {
await page.evaluate(({ step, label }) => { document.querySelector('#cam-mask').dataset.step = step; document.querySelector('#cam-step').innerHTML = label; }, { step, label });
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 726 });
const box = await page.locator('#cam-video').boundingBox();
assert(Math.abs(box.width - width) < 1 && Math.abs(box.height - 726) < 1, 'preview must fill viewport');
assert(Math.abs(box.x) < 1 && Math.abs(box.y) < 1);
assert.equal(await page.locator('#cam-video').evaluate(e => getComputedStyle(e).borderWidth), '0px');
for (const state of ['', 'no-face', 'ok']) {
await page.locator('#cam-mask').evaluate((e, state) => { e.classList.remove('no-face', 'ok'); if (state) e.classList.add(state); }, state);
assert.equal(await page.locator('.cam-line').evaluate(e => getComputedStyle(e).display), 'none');
}
assert(await page.locator('#cam-cut path[data-shape="' + step + '"]').evaluate(e => e.getAttribute('d').includes('Q')));
assert.equal(await page.locator('#cam-step').evaluate(e => getComputedStyle(e).fontSize), '21.5px');
assert.equal(await page.locator('.cam-feature-lines[data-shape="' + step + '"]').evaluate(e => getComputedStyle(e).display), 'block');
}
if (artifacts) {
await page.setViewportSize({ width: 375, height: 726 });
await page.screenshot({ path: path.join(artifacts, '02-camera-' + step + '.png'), animations: 'disabled' });
}
}
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--');
await page.evaluate(async () => { await document.fonts.load('700 40px "MiSans Demibold"', '0123456789'); await document.fonts.load('600 23px MiSans', '护理建议您的肌肤状态表现紧塑力'); });
await page.waitForLoadState('networkidle');
for (const selector of ['#stat-score b', '#stat-age b', '.dim-score b']) await actualFont(selector, 'Demibold');
for (const selector of ['#s-report .report-title', '.dimension-title', '.card-heading']) await actualFont(selector, 'Semibold');
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 844 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
assert.equal(await page.locator('.card-heading').first().evaluate(e => getComputedStyle(e).fontSize), '23px');
}
if (artifacts) {
await page.setViewportSize({ width: 375, height: 726 });
await page.locator('#s-report').evaluate(e => { e.scrollTop = 0; });
await page.screenshot({ path: path.join(artifacts, '03-report-demibold-numbers.png'), animations: 'disabled' });
await page.locator('.card-care').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(artifacts, '04-care-semibold.png'), animations: 'disabled' });
}
assert.deepEqual(errors, []);
console.log('PASS: heavier Chinese slogan, matched logo/text optical height, three lines/no overlap in five widths, no fonts/engine at entry; early consent Demibold; actual PSD font glyphs for headings/numbers; full-bleed camera, no outline in any state, three rounded contours/retained guides; fixed fonts/no overflow in five widths; no JS errors');
} finally { await browser.close(); }