feat(h5): apply second customer UI feedback and homepage refinements
This commit is contained in:
@@ -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');
|
||||
@@ -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(); }
|
||||
Reference in New Issue
Block a user