feat(h5): apply fourth feedback and tighten local capture validation

This commit is contained in:
xsl
2026-10-12 20:16:41 +08:00
parent e53c809522
commit 92a3965b7d
33 changed files with 3758 additions and 1385 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ const end = source.indexOf(' /* ─── SkinScore viewModel', start);
assert(start >= 0 && end > start);
function setup() {
let timer, complete, signal, result;
const ctx = { IS_MOCK: false, camSeq: 1, AbortController,
const ctx = { IS_MOCK: false, camSeq: 1, AbortController, CaptureQuality: require('../js/capture-quality'), captureDiagnostics: [],
setTimeout(fn, delay) { timer = { fn, delay }; return 1; },
clearTimeout() { timer = null; },
fetch(url, options) {
+104
View File
@@ -0,0 +1,104 @@
// Real local detector + synthetic video containing existing three-view photos.
// Auth/analysis are in-memory fixtures: no account/report writes or paid calls.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import { pathToFileURL, fileURLToPath } 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 } });
page.setDefaultTimeout(60000);
const errors = []; page.on('pageerror', e => errors.push(e.message));
await page.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'local-camera-fixture', kind: 'browser' } } }));
for (const name of ['C3_180.jpg', 'C3_135.jpg', 'C3_-135.jpg']) {
const body = fs.readFileSync(fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/' + name, import.meta.url)));
await page.route('**/sample-' + name, route => route.fulfill({ contentType: 'image/jpeg', body }));
}
await page.addInitScript(() => {
const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600;
let currentImage, offsetX = 0;
function draw() {
if (!currentImage) return;
const c = canvas.getContext('2d'); c.fillStyle = '#111'; c.fillRect(0, 0, canvas.width, canvas.height);
c.drawImage(currentImage, offsetX, 0, canvas.width, canvas.height);
}
setInterval(draw, 100);
window.__cameraFixture = { canvas, set: async name => {
const image = new Image(); image.src = 'sample-' + name; await image.decode();
// Simulate a person following the centering prompt, not an off-center
// landscape source stretched into a portrait camera frame.
let nextOffset = 0;
if (window.faceapi && faceapi.nets.faceLandmark68Net.isLoaded) {
const faces = await faceapi.detectAllFaces(image, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .5 }));
if (faces.length === 1) nextOffset = canvas.width / 2 - (faces[0].box.x + faces[0].box.width / 2) * canvas.width / image.naturalWidth;
}
currentImage = image; offsetX = nextOffset; draw();
} };
navigator.mediaDevices.getUserMedia = async () => {
await window.__cameraFixture.set('C3_180.jpg');
return canvas.captureStream(10);
};
});
await page.route('**/api/skin-analyze', async route => {
const fixture = await page.evaluate(() => ({ ok: true, result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE, report_saved: true }));
await route.fulfill({ json: fixture });
});
await page.goto(base); await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400);
}
await page.click('[data-action="submit-profile"]'); await page.click('[data-action="start-camera"]');
try {
await page.waitForFunction(() => window.faceapi && faceapi.nets.faceLandmark68Net.isLoaded && document.querySelector('#s-cam').classList.contains('cam-live'));
} catch (error) {
console.log(await page.evaluate(() => ({ status: document.querySelector('#cam-status').textContent, hint: document.querySelector('#cam-hint').textContent, toast: document.querySelector('#toast').textContent, width: document.querySelector('#cam-video').videoWidth, classes: document.querySelector('#s-cam').className, engine: !!window.faceapi })), errors);
throw error;
}
assert.equal(await page.locator('#s-cam.cam-manual').count(), 0, 'real automatic pipeline, not manual bypass');
await page.evaluate(() => {
window.__holdSamples = []; window.__hints = [];
const hold = CaptureQuality.hold;
CaptureQuality.hold = function(state, obs, now) {
const result = hold(state, obs, now);
window.__holdSamples.push({ gap: state ? now - state.lastAt : null, frames: result.state && result.state.frames, ready: result.ready, obs });
if (window.__holdSamples.length > 8) window.__holdSamples.shift();
return result;
};
new MutationObserver(() => { window.__hints.push(document.querySelector('#cam-hint').textContent); if (window.__hints.length > 20) window.__hints.shift(); }).observe(document.querySelector('#cam-hint'), { childList: true, subtree: true });
});
try { await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('2/3')); }
catch (error) {
console.log(await page.evaluate(async () => {
const v = document.querySelector('#cam-video');
const faces = await faceapi.detectAllFaces(v, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .5 })).withFaceLandmarks();
return { status: document.querySelector('#cam-status').textContent, hint: document.querySelector('#cam-hint').textContent,
step: document.querySelector('#cam-step').textContent, size: [v.videoWidth, v.videoHeight, v.clientWidth, v.clientHeight],
ratio: window.__dbgRatio, faces: faces.map(d => ({ score: d.detection.score, pose: HeadPose.estimate(d.landmarks.positions, false), box: d.detection.box })), holds: window.__holdSamples, hints: window.__hints };
}).then(value => JSON.stringify(value, null, 2)), errors); throw error;
}
await page.waitForTimeout(1000);
assert((await page.locator('#cam-step').textContent()).includes('2/3'), 'front photo cannot pass the side gate');
await page.evaluate(() => window.__cameraFixture.set('C3_135.jpg'));
await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('3/3'));
await page.waitForTimeout(800);
assert((await page.locator('#cam-step').textContent()).includes('3/3'), 'wrong-direction side cannot pass');
const sent = page.waitForRequest(r => r.url().endsWith('/api/skin-analyze'));
await page.evaluate(() => window.__cameraFixture.set('C3_-135.jpg'));
const body = (await sent).postDataJSON();
await page.waitForSelector('#s-report.active');
for (const key of ['front', 'left', 'right']) {
const row = body.capture_diagnostics.views[key];
assert.equal(row.mode, 'live'); assert.equal(row.direction_checked, true);
assert(row.score >= .5 && row.luma >= 75 && row.luma <= 225);
assert(Math.abs(row.roll) <= 10);
}
assert(Math.abs(body.capture_diagnostics.views.front.yaw) <= 10);
assert(body.capture_diagnostics.views.left.yaw <= -35);
assert(body.capture_diagnostics.views.right.yaw >= 35);
assert(await page.evaluate(() => document.querySelector('#cam-video').srcObject === null), 'camera released after success');
assert.deepEqual(errors, []);
console.log('PASS: actual local models + automatic three-view capture + final JPEG revalidation; front/wrong-direction frames blocked locally; all three approved samples recorded; camera released; no paid call or backend writes');
} finally { await browser.close(); }
+81
View File
@@ -0,0 +1,81 @@
'use strict';
const assert = require('node:assert/strict');
const Quality = require('../js/capture-quality');
const HeadPose = require('../js/head-pose');
const ReportUI = require('../js/report-ui');
const Diagnostics = require('../lib/capture-diagnostics');
function points(yaw = 0, pitch = 0, roll = 0) {
const result = Array.from({ length: 68 }, () => ({ x: 200, y: 150 }));
result[36] = { x: 150, y: 100 }; result[45] = { x: 250, y: 100 };
result[19] = { x: 170, y: 70 }; result[24] = { x: 230, y: 70 };
result[8] = { x: 200, y: 240 };
// Solve the empirical yaw geometry rather than duplicating a second policy.
let low = -80, high = 80;
for (let i = 0; i < 40; i++) {
const mid = (low + high) / 2; result[30] = { x: 200 + mid, y: 140 };
result[33] = { x: 200, y: 161.5 };
if (HeadPose.estimate(result, false).yaw < yaw) low = mid; else high = mid;
}
result[33] = { x: 200, y: 161.5 + pitch / 180 * 170 };
if (roll) {
const angle = roll * Math.PI / 180;
for (const p of result) { const x = p.x - 200, y = p.y - 150; p.x = 200 + x * Math.cos(angle) - y * Math.sin(angle); p.y = 150 + x * Math.sin(angle) + y * Math.cos(angle); }
}
return result;
}
const face = (yaw = 0, pitch = 0, roll = 0) => ({ detection: { score: .95, box: { x: 170, y: 160, width: 260, height: 300 } }, landmarks: { positions: points(yaw, pitch, roll) } });
const options = { count: 1, target: 'front', width: 600, height: 600, offsetX: 0, luma: 120, mirrored: false };
const check = (d, changes) => Quality.evaluate(d, { ...options, ...changes });
assert(check(face()).ok);
assert(!check(null).ok); assert(!check(face(), { count: 2 }).ok);
for (const yaw of [-14, 14]) assert(!check(face(yaw)).ok);
for (const pitch of [-18, 18]) assert(!check(face(0, pitch)).ok);
assert.equal(check(face(0, 0, 15)).hint, '请勿歪头');
for (const yaw of [25, 72]) assert(!check(face(yaw), { target: 'right' }).ok);
assert(check(face(45), { target: 'right' }).ok);
assert(!check(face(45), { target: 'left' }).ok);
assert(check(face(-45), { target: 'left' }).ok);
assert(check(face(-45), { target: 'right', mirrored: true }).ok);
assert(check(face(-45), { target: 'right', ignoreDirection: true }).ok);
assert(!check(face(25), { target: 'left', ignoreDirection: true }).ok);
for (const luma of [null, 50, 240, NaN]) assert(!check(face(), { luma }).ok);
assert(!check(face(), { offsetX: .15 }).ok);
for (const changes of [{ height: 180 }, { height: 410 }, { x: -10 }, { y: 5 }]) {
const d = face(); Object.assign(d.detection.box, changes); assert(!check(d).ok);
}
const weak = face(); weak.detection.score = .4; assert(!check(weak).ok);
const obs = check(face()).observation;
let held = Quality.hold(null, obs, 1000); assert(!held.ready);
held = Quality.hold(held.state, obs, 1400); assert(!held.ready);
assert(Quality.hold(held.state, obs, 1650).ready);
assert(!Quality.hold(Quality.hold(null, obs, 1000).state, obs, 1700).ready, 'two frames never pass');
assert.equal(Quality.hold(held.state, null, 1700).state, null);
assert.equal(Quality.hold(held.state, obs, 3500).state.frames, 1, 'gap resets');
const slow = Quality.hold(Quality.hold(null, obs, 1000).state, obs, 2100);
assert(!slow.ready, 'slow device still needs three frames');
assert(Quality.hold(slow.state, obs, 3200).ready, '1100ms inference must not permanently prevent a stable capture');
for (const change of [{ yaw: 6 }, { pitch: 6 }, { ratio: .6 }, { center_x: .55 }, { center_y: .56 }]) assert.equal(Quality.hold(held.state, { ...obs, ...change }, 1500).state.frames, 1, 'motion resets');
for (const [dims, key] of [
[[10, 50, 90], 'firm'], [[90, 10, 50], 'vita'], [[90, 50, 10], 'stab'],
[[50, 50, 90], 'firm'], [[null, 50, 50], 'vita'], [[50.1, 50, 90], 'vita']
]) {
const vm = { dims: Object.fromEntries(['firm', 'vita', 'stab'].map((key, i) => [key, { P: dims[i] }])), focus: { name: '旧名字' } };
const original = JSON.stringify(vm), focus = ReportUI.focusOf(vm);
assert.equal(focus.key, key); assert.equal(focus.name, ReportUI.dimNames[key]); assert(focus.tip.endsWith('。'));
assert.equal(JSON.stringify(vm), original);
}
assert.equal(ReportUI.focusOf({ dims: {} }), null);
const wrinkle = { key: 'wrinkles', text: '原始皱纹结论。\n分区明细(左右以本人为准):\n抬头纹:皱纹分 13。' };
assert.equal(ReportUI.customerText(wrinkle), '原始皱纹结论。');
assert(wrinkle.text.includes('分区明细'), 'historical snapshot not overwritten');
assert.equal(ReportUI.customerText({ key: 'contour', text: '轮廓清晰;侧脸角度数据未完整获取;其他结论。' }), '轮廓清晰;其他结论。');
const raw = { left_side_result: { left_jawline_info: { left_jawline_quality: 3, left_jawline_angle: 110 } }, side_result: { right_side_result: { right_jawline_info: { right_jawline_quality: 1, right_jawline_angle: 115 } } } };
assert.equal(Diagnostics.assess(raw).status, 'invalid');
assert.equal(Diagnostics.assess({}).status, 'unavailable');
assert.equal(Diagnostics.assess(raw).views.right.status, 'valid');
const normalized = Diagnostics.normalize({ version: Quality.config.version, secret: 'discard', views: { front: { mode: 'live', yaw: 1, score: .9, luma: Infinity, secret: 'discard' }, left: { mode: 'unchecked', reason: 'model-unavailable' } } });
assert.equal(normalized.policy.capture.holdMs, 650);
assert.equal(normalized.views.front.luma, undefined);
assert(!JSON.stringify(normalized).includes('discard'));
assert.equal(Diagnostics.normalize({ version: '<script>' }).version, 'unknown');
console.log('PASS: stricter local pose/framing/light/single-face gates; 650ms/3-frame stability/motion/miss/gap reset; all focus cases/ties/missing/saved scores; no wrinkle mutations; backend quality marking and bounded client diagnostics');
+135
View File
@@ -0,0 +1,135 @@
// Isolated backend/browser/fixture upstream: no production data or paid API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const CaptureQuality = require('../js/capture-quality');
const ReportUI = require('../js/report-ui');
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-fourth-test-'));
const artifacts = process.env.H5_ARTIFACT_DIR;
const child = spawn(process.execPath, ['-e', `
const fs=require('fs'), vm=require('vm'), w={};
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
const f=w.MOCK_FIXTURE;
f.side.right.right_jawline_info.right_jawline_quality=3;
global.fetch=async()=>({ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})});
require('./server.js');
`], { cwd: h5, env: { ...process.env, PORT: '18104', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
try {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timeout); resolve(); } });
child.once('exit', code => { clearTimeout(timeout); reject(Error('server exited ' + code)); });
});
const base = 'http://127.0.0.1:18104/';
browser = await chromium.launch({ headless: true });
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.addInitScript(() => {
navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('fixture denial', 'NotAllowedError'));
window.__scanIterations = [];
document.addEventListener('animationiteration', e => { if (e.target.classList.contains('scan-beam')) window.__scanIterations.push(e.elapsedTime); });
});
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
await page.goto(base); await page.waitForLoadState('networkidle');
assert.equal((await page.locator('[data-action="login-browser"]').textContent()).trim(), '游客登录');
assert(!requests.some(p => /scan-(base|face)|\.woff2$|faceapi/.test(p)), 'no heavy/scanning entry requests');
await page.check('#login-consent'); await page.click('[data-action="login-browser"]');
await page.waitForSelector('#p-login-success.show');
assert((await page.locator('#login-success-message').textContent()).includes('当前使用:游客登录'));
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客已登录');
const api = page.context().request;
await page.addScriptTag({ url: base + 'assets/mock/report-fixture.js' });
const fixture = await page.evaluate(() => window.MOCK_FIXTURE);
const result = await (await api.post(base + 'api/skin-analyze', { data: {
front_image: fixture.photos[0], left_image: fixture.photos[1], right_image: fixture.photos[2],
profile: { real_age: 30 }, capture_diagnostics: { version: CaptureQuality.config.version,
views: { front: { mode: 'live', yaw: 2, luma: 120 }, left: { mode: 'file', direction_checked: false }, right: { mode: 'unchecked', reason: 'model-unavailable' } }, ignore: 'discard' }
} })).json();
assert(result.ok && result.report_saved, 'provider quality=3 must NOT block or request a re-shoot');
assert(result.vm.notes.some(n => n.includes('quality=3')));
const admin = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
assert.equal(admin.capture_quality.status, 'invalid');
assert.equal(admin.capture_quality.views.right.quality, 3);
assert.equal(admin.capture_diagnostics.version, CaptureQuality.config.version);
assert.equal(admin.capture_diagnostics.policy.pose.SIDE_YAW_MIN, 35);
assert.equal(admin.capture_diagnostics.ignore, undefined);
assert(admin.notes.some(n => n.includes('quality=3')));
const wrinkle = admin.presentation.views.find(view => view.key === 'wrinkles');
assert(!wrinkle.text.includes('分区明细') && !wrinkle.text.includes('标准化长度'));
assert.equal(wrinkle.details.length, 12);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('侧脸质量状态,前端拍摄参数版本'));
assert(csv.includes('invalid,' + CaptureQuality.config.version));
const before = (await api.get(base + 'api/reports/' + result.record_id));
const snapshotBefore = await before.text();
await page.click('[data-action="history"]'); await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert(await page.locator('#dims-note').isVisible());
assert.equal(await page.locator('#vm-notes').isVisible(), false);
assert(!(await page.locator('#s-report').textContent()).includes('建议重拍'));
const expected = ReportUI.focusOf(result.vm);
assert.equal(await page.locator('.dims-note-l1').textContent(), '近期优先关注:' + expected.name);
assert.equal(await page.locator('.dims-note-l2').textContent(), expected.tip);
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 844 });
const geometry = await page.locator('#dims-note').evaluate(el => ({ width: el.clientWidth, scroll: el.scrollWidth, bottom: el.getBoundingClientRect().bottom, next: document.querySelector('.dimension-title').getBoundingClientRect().top }));
assert(geometry.scroll <= geometry.width && geometry.bottom <= geometry.next, 'focus wraps without overlap at ' + width);
}
if (artifacts) { fs.mkdirSync(artifacts, { recursive: true }); await page.setViewportSize({ width: 375, height: 726 }); await page.screenshot({ path: path.join(artifacts, '01-focus-history.png') }); }
assert.equal(await (await api.get(base + 'api/reports/' + result.record_id)).text(), snapshotBefore, 'display changes must not mutate historical report');
const desktop = await browser.newPage({ viewport: { width: 1500, height: 1000 } });
desktop.on('pageerror', e => errors.push(e.message));
await desktop.goto(base + 'admin.html');
await desktop.waitForFunction(() => document.querySelector('#tbl-records').textContent.includes('侧脸质量异常'));
await desktop.locator('#tbl-records tbody tr').first().click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('前端参数版本'));
assert((await desktop.locator('#d-body').textContent()).includes('quality=3'));
const wrinkleTable = desktop.locator('.d-sec').filter({ hasText: '皱纹分区原始指标(仅后台复核' });
assert.equal(await wrinkleTable.locator('tbody tr').count(), 12);
assert.equal(await wrinkleTable.locator('thead th').count(), 10);
assert((await wrinkleTable.textContent()).includes('标准化长度'));
if (artifacts) await desktop.screenshot({ path: path.join(artifacts, '02-admin-quality.png') });
// A fast intercepted analysis response still waits for one actual CSS cycle.
await page.goto(base); await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400);
}
await page.click('[data-action="submit-profile"]'); await page.click('[data-action="start-camera"]');
let sent;
await page.route('**/api/skin-analyze', route => { sent = route.request().postDataJSON(); return route.fulfill({ json: result }); });
for (let i = 1; i <= 3; i++) {
await page.waitForFunction(n => document.querySelector('#cam-step').textContent.includes(n + '/3'), i);
await page.waitForTimeout(1600);
await page.setInputFiles('#cam-file', path.join(h5, '../reference/facepp-skinanalyze/examples/images/C3_180.jpg'));
}
await page.waitForSelector('#p-analyzing.show');
await page.waitForTimeout(300);
assert(await page.locator('#p-analyzing').isVisible(), 'fast response must not cut off cycle');
if (artifacts) await page.screenshot({ path: path.join(artifacts, '03-scan-motion.png') });
await page.waitForSelector('#s-report.active');
assert(await page.evaluate(() => window.__scanIterations.some(seconds => seconds >= 1)), 'one actual complete 1000ms CSS cycle, independent of Playwright observation latency');
assert(sent && sent.capture_diagnostics.version === CaptureQuality.config.version);
assert.equal(sent.capture_diagnostics.views.front.mode, 'unchecked');
assert.equal(await page.locator('#p-analyzing.scan-running').count(), 0);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 1, 'intercepted flow creates no extra report');
assert.deepEqual(errors, []);
console.log('PASS: guest login/status; provider quality=3 remains successful/saved/backend-only; calibration/admin/CSV; focus historical immutability/five widths; real CSS cycle gates fast report; fallback trace; no JS errors/paid calls');
} finally {
if (browser) await browser.close();
const stopped = once(child, 'exit'); child.kill(); await stopped;
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+9 -4
View File
@@ -33,7 +33,7 @@ assert(context.captureHaptic({ vibrate: p => { pattern = Array.from(p); return t
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.
// are stubs; the fourth-round shared hold policy now requires 650ms/3 frames.
let now = 10000, buzzes = 0, captures = 0;
const state = {
missFrames: 0, MISS_FRAMES_MAX: 3, camVideo: { videoHeight: 600 }, FACE_NEAR: .30, FACE_FAR: .68,
@@ -41,19 +41,24 @@ const state = {
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 },
holdState: null, CaptureQuality: {
evaluate: d => ({ ok: d.detection.box.height >= 100, hint: '保持不动', observation: { yaw: 0, pitch: 0, center_x: .5, center_y: .5, ratio: .5 } }),
hold: require('../js/capture-quality').hold
},
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 += 450; 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');
now += 300; state.onDetect(face);
now += 350; 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,
@@ -66,4 +71,4 @@ for (const manual of [true, false]) {
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');
console.log('PASS: three bounded rounded contours; haptic safety, pre-capture cue, jitter throttling; fourth-round 650ms/3-frame hold');
+7 -5
View File
@@ -61,7 +61,7 @@ try {
await page.goto(base);
await page.waitForSelector('#s-splash.active');
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /\.woff2$|faceapi|scan-waiting|product-\d+\.webp/.test(p)), 'heavy/scanning assets must not compete with entry');
assert(!requests.some(p => /\.woff2$|faceapi|scan-(waiting|base|face)|product-\d+\.webp/.test(p)), 'heavy/scanning assets must not compete with entry');
await page.waitForFunction(() => document.querySelector('#s-splash .splash-slogan').naturalWidth === 718 && document.querySelector('.brand-lockup').naturalWidth === 517);
await screenshot('01-home');
await page.click('[data-action="open-privacy"]');
@@ -82,7 +82,7 @@ try {
await screenshot('02-consent');
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
assert(requests.some(p => p.endsWith('scan-waiting.webp')), 'preload waiting image only after profile entry');
assert(requests.some(p => p.endsWith('scan-base.webp')), 'preload waiting image only after profile entry');
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400);
}
@@ -121,12 +121,14 @@ try {
return [[540, 720], [540, 1200]].map(([x, y]) => Array.from(context.getImageData(x, y, 1, 1).data));
});
assert(scanPixels[0].slice(0, 3).every(value => value > 245), 'visible PSD face must not be lost to clipping');
assert(scanPixels[1][0] > 220 && scanPixels[1][1] > 220 && scanPixels[1][2] < 150, 'yellow lower face must be visible');
assert(scanPixels[1].slice(0, 3).every(value => value > 245), 'base face stays white; yellow scan is now a separate animated layer');
assert.equal(await page.locator('.scan-beam').evaluate(e => getComputedStyle(e).animationName), 'report-scan');
assert.equal(await page.locator('#p-analyzing [data-action]').count(), 1, 'only cancel is actionable; PSD icons decorative');
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
await page.setViewportSize({ width, height });
const art = await rect('#scan-artwork');
assert.equal(art.width, width); assert.equal(art.height, height);
assert(art.width <= width + .1 && art.height <= height + .1);
assert(Math.abs(art.width / art.height - 1080 / 2091) < .001);
assert.equal(await page.locator('#scan-artwork').evaluate(e => getComputedStyle(e).objectFit), 'contain');
assert(await page.locator('.scan-cancel').isVisible());
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
@@ -189,7 +191,7 @@ try {
// reflow or escape the dialog, and short viewports must keep controls visible.
const fallback = await browser.newPage({ viewport: { width: 375, height: 726 } });
await fallback.route('**/*.woff2', route => route.abort());
await fallback.route('**/scan-waiting.webp', route => route.abort());
await fallback.route('**/scan-base.webp', route => route.abort());
await fallback.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'font-fallback-fixture', kind: 'browser' } } }));
await fallback.goto(base); await fallback.click('[data-action="open-privacy"]');
await fallback.waitForLoadState('networkidle');
+14 -2
View File
@@ -31,8 +31,20 @@ const model = SkinScore.buildViewModel(result, SkinScore.MOCK_SIDE, { realAge: 3
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.deepEqual(presentation.views.find(view => view.key === 'wrinkles').details, details);
assert(!text.includes('分区明细') && !text.includes('标准化长度'));
const approved = {
'预警': '全脸共检测到皱纹 12 条、细纹 34 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。',
'改善': '全脸共检测到皱纹 12 条、细纹 34 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。',
'良好': '面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。'
};
for (const level of Object.keys(approved)) {
const fixture = JSON.parse(modelBefore);
fixture.cats.wrinkles.level = level;
fixture.cats.wrinkles.vars.wr_cnt = 12;
fixture.cats.wrinkles.vars.fl_cnt = 34;
assert.equal(ReportUI.build(fixture, result).views.find(view => view.key === 'wrinkles').text, approved[level]);
}
assert.equal(JSON.stringify(model), modelBefore);
assert.equal(JSON.stringify(result), rawBefore);
const excellent = JSON.parse(JSON.stringify(model));
+4 -3
View File
@@ -53,8 +53,9 @@ try {
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('右法令纹'));
const wrinkleSummary = await page.locator('#ditem-para2').textContent();
assert(wrinkleSummary.includes('面中、口周、眼周') || wrinkleSummary.includes('面部整体平整'));
assert(!wrinkleSummary.includes('分区明细') && !wrinkleSummary.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));
@@ -106,5 +107,5 @@ try {
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');
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, approved wrinkle summary without technical appendix, fixed fonts/all three product lines in five widths, partial map caption, PSD yellow, no JS errors');
} finally { await browser.close(); }
+1 -1
View File
@@ -95,7 +95,7 @@ try {
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-home-status.png') });
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '本机浏览器已登录');
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '游客已登录');
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-history-status.png') });
await page.click('#s-history [data-action="switch-login"]');
assert.equal(await page.locator('#s-login.active').count(), 1);
+1 -1
View File
@@ -21,7 +21,7 @@ await page.click('[data-action="login-browser"]');
assert(await page.locator('#s-login').evaluate(e => e.classList.contains('active')));
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('本机浏览器'));
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('游客'));
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.waitForSelector('#p-login-success.show');
+2 -2
View File
@@ -59,8 +59,8 @@ try {
async function confirmLogin(page, kind) {
await page.waitForSelector('#p-login-success.show');
assert.equal(await page.locator('#login-success-title').textContent(), '登录成功');
assert((await page.locator('#login-success-message').textContent()).includes(kind === 'wechat' ? '当前使用:微信登录' : '当前使用:本机浏览器登录'));
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), kind === 'wechat' ? '微信已登录' : '本机浏览器已登录');
assert((await page.locator('#login-success-message').textContent()).includes(kind === 'wechat' ? '当前使用:微信登录' : '当前使用:游客登录'));
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), kind === 'wechat' ? '微信已登录' : '游客已登录');
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.waitForLoadState('networkidle');