feat(h5): apply fourth feedback and tighten local capture validation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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(); }
|
||||
@@ -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');
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user