1122 lines
83 KiB
JavaScript
1122 lines
83 KiB
JavaScript
'use strict';
|
|
const { test } = require('node:test'), assert = require('node:assert/strict');
|
|
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
|
const root = path.resolve(__dirname, '..');
|
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
function setup(options = {}) {
|
|
const timers = [], scopes = [], navigation = [], submitted = [], exported = [], cameraIds = [], nativeCameras = [], engineOptions = [], updates = [];
|
|
let clock = 1000, definition, selected, failRead = false;
|
|
const app = options.app || { globalData: { profile: { real_age: 30 } } };
|
|
const user = { id: 'guest-one', kind: 'taobao_guest' };
|
|
const api = { user: () => user, createAnalysisAttempt: () => ({ requestId: 'same-attempt', profile: null }),
|
|
analyze: async (photos, profile, signal, attempt) => { submitted.push({ photos, signal, attempt }); return { result: {} }; }, ...options.api };
|
|
const markup = fs.readFileSync(path.join(root, 'pages/camera/camera.axml'), 'utf8');
|
|
const platform = {
|
|
getSystemInfoSync: () => ({ windowWidth: 375, windowHeight: 726 }),
|
|
createArCameraContext: id => { cameraIds.push(id); return { faceBeauty() {} }; },
|
|
chooseImage(value) { selected = value; value.success({ apFilePaths: ['photo-' + submitted.length] }); value.complete(); },
|
|
showToast() {}, redirectTo: value => navigation.push(value.url), navigateBack() {},
|
|
getImageInfo(value) { value.success({ width: 4000, height: 3000 }); },
|
|
getFileSystemManager: () => ({ readFile(value) { if (failRead) value.fail({ errorMessage: '读取失败' }); else value.success({ data: '/9j/2Q==' }); } }),
|
|
createCanvasContext(id) {
|
|
assert.match(markup, /<canvas[^>]*id="compress-canvas"/);
|
|
assert.equal(id, 'compress-canvas');
|
|
return { clearRect() {}, drawImage() {}, draw(keep, callback) { callback(); },
|
|
toTempFilePath(value) { exported.push(value); value.success({ apFilePath: 'jpeg' }); },
|
|
toDataURL(value) { value.success({ dataURL: 'data:image/jpeg;base64,/9j/2Q==' }); } };
|
|
},
|
|
canvasPutImageData(value) { assert.equal(value.data.length, value.width * value.height * 4); value.success(); }
|
|
};
|
|
if (options.nativeFace) platform.createCameraContext = id => {
|
|
cameraIds.push(id);
|
|
const record = { id, starts: 0, stops: 0, photos: [] };
|
|
nativeCameras.push(record);
|
|
const context = { takePhoto(value) { record.photos.push(value); }, onCameraFrame(callback) {
|
|
record.callback = callback;
|
|
return { start(value) { record.starts++; if (options.nativeStartFailure) value.fail({}); else value.success(); }, stop() { record.stops++; } };
|
|
} };
|
|
if (options.nativeMissingFrames) delete context.onCameraFrame;
|
|
return context;
|
|
};
|
|
Object.assign(platform, options.my);
|
|
function later(fn, delay) { const row = { fn, delay, active: true }; timers.push(row); return row; }
|
|
const clear = row => { if (row) row.active = false; };
|
|
const imageModule = { exports: {} };
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'utils/image.js'), 'utf8'), { module: imageModule, my: platform, Promise, setTimeout: later, clearTimeout: clear, Uint8Array, Uint8ClampedArray, ArrayBuffer });
|
|
const cameraConfig = { environment: options.environment || 'development', transport: options.transport || 'cloud', nativeCamera: options.nativeCamera !== false,
|
|
cameraDebug: options.cameraDebug === undefined ? !!(options.query && options.query.debug === 'local-face') : options.cameraDebug === true,
|
|
baseUrl: options.baseUrl || '', arPixelFormat: options.arPixelFormat || 'RGBA', nativePixelFormat: options.nativePixelFormat,
|
|
nativeFaceBackend: options.nativeFaceBackend, photoInputMode: options.photoInputMode,
|
|
nativeFrameSize: options.nativeFrameSize, nativeFaceInputSize: options.nativeFaceInputSize, nativeFaceMaxSide: options.nativeFaceMaxSide, nativeFaceLandmarkModel: options.nativeFaceLandmarkModel,
|
|
arFrontPosition: options.arFrontPosition, captureMode: options.captureMode || (options.nativeFace ? 'native-face' : options.ar ? 'ar' : 'system') };
|
|
const developmentModule = { exports: {} };
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'utils/development.js'), 'utf8'), { module: developmentModule, require: () => cameraConfig });
|
|
const deps = { '../../utils/api': api, '../../utils/image': imageModule.exports,
|
|
'../../utils/development': developmentModule.exports,
|
|
'../../utils/voice': { VOICE: { step: [], countdown: '', shot: '', analyzing: '', fail: '' }, play() {}, stop() {}, ...options.voice },
|
|
'../../utils/permissions': { cameraSupported: () => !options.cameraUnsupported, settings: async () => ({ 'scope.camera': true }), state: (value, key) => value[key], ensure: async scope => { scopes.push(scope); }, diagnose: async () => [], ...options.permissions },
|
|
'../../utils/native-face': { create(value) { engineOptions.push(value); return options.detectorFactory ? options.detectorFactory(value) : options.detector || { init: async () => ({ backend: 'cpu' }), detect: async frame => nativeResult(frame.noseX), dispose() {} }; } },
|
|
'../../config': cameraConfig };
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'pages/camera/camera.js'), 'utf8'), {
|
|
Page(value) { definition = value; }, require(name) { return name in deps ? deps[name] : require(path.resolve(root, 'pages/camera', name)); },
|
|
my: platform, getApp: options.getApp || (() => app), Promise, setTimeout: later, clearTimeout: clear, Date: { now: () => clock }
|
|
});
|
|
const page = Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)), setData(value, callback) { updates.push(value); Object.assign(this.data, value); if (callback) callback(); } });
|
|
page.onLoad(options.query); page.onReady(); page.onShow();
|
|
return { page, api, app, timers, scopes, navigation, submitted, exported, cameraIds, nativeCameras, engineOptions, cameraConfig, updates, platform, image: imageModule.exports,
|
|
failRead(value) { failRead = value; }, setClock(value) { clock = value; },
|
|
timer(delay, at) { const row = timers.find(item => item.active && item.delay === delay); assert(row, 'expected timer ' + delay); row.active = false; clock = at === undefined ? clock + delay : at; row.fn(); } };
|
|
}
|
|
test('mounted camera bounds replace early system viewport dimensions after a native title bar is added', () => {
|
|
let measured;
|
|
const f = setup({ my: { createSelectorQuery() {
|
|
return { select(value) { assert.equal(value, '.cam'); return this; }, boundingClientRect() { return this; }, exec(fn) { measured = fn; } };
|
|
} } });
|
|
assert.equal(f.page.viewHeight, 662, 'initial framing excludes the custom header until mounted bounds arrive');
|
|
measured([{ width: 375, height: 682 }]);
|
|
assert.equal(f.page.viewHeight, 682);
|
|
assert.equal(f.page.viewWidth, 375);
|
|
assert.match(f.page.data.maskStageStyle, /height:682px/);
|
|
f.page.onHide(); measured([{ width: 375, height: 500 }]);
|
|
assert.equal(f.page.viewHeight, 682, 'a hidden page ignores late layout results');
|
|
f.page.onUnload();
|
|
});
|
|
|
|
test('camera recovery control is hidden during normal startup, verification and shot transitions', () => {
|
|
const markup = fs.readFileSync(path.join(root, 'pages/camera/camera.axml'), 'utf8');
|
|
const expression = markup.match(/class="cam-enable" a:if="\{\{([^}]+)\}\}"/)[1];
|
|
const visible = state => Function('s', 'with(s){return !!(' + expression + ')}')({
|
|
cameraSupported: true, cameraEnabled: false, identityPending: false, previewUrl: '', doneTip: '',
|
|
debugComplete: false, analyzing: false, analysisError: '', camError: false, permissionMessage: '', ...state
|
|
});
|
|
assert.equal(visible({}), false, 'not-yet-mounted camera is not a recovery error');
|
|
assert.equal(visible({ camError: true }), true, 'real failures retain a way to reopen');
|
|
assert.equal(visible({ permissionMessage: '相机权限未开启' }), true);
|
|
for (const state of [{ identityPending: true }, { cameraEnabled: true }, { previewUrl: 'photo' },
|
|
{ doneTip: '已完成拍摄' }, { analyzing: true }, { analysisError: '稍后重试' }, { debugComplete: true }]) {
|
|
assert.equal(visible({ camError: true, ...state }), false);
|
|
}
|
|
});
|
|
|
|
test('retry hides the old camera error while authorizing and restores recovery on denial', async () => {
|
|
let reject;
|
|
const f = setup({ permissions: { ensure: () => new Promise((_, no) => { reject = no; }) } });
|
|
await flush(); f.page.onCameraError();
|
|
f.page.onEnableCamera();
|
|
assert.equal(f.page.data.camError, false); assert.equal(f.page.data.permissionMessage, '');
|
|
reject(Error('相机权限未开启')); await flush();
|
|
assert.equal(f.page.data.camError, true); assert.equal(f.page.data.permissionMessage, '相机权限未开启');
|
|
f.page.onUnload();
|
|
});
|
|
|
|
for (const guest of [true, false]) test((guest ? 'guest' : 'Taobao account') + ' main flow connects login, profile, automatic photos, real upload client, report and product', async () => {
|
|
const config = require('../config'), storage = new Map(), calls = [], navigation = [], opened = [];
|
|
const app = { globalData: { profile: null, photos: null, payload: null } };
|
|
const user = { id: guest ? 'flow-guest' : 'flow-account', kind: guest ? 'taobao_guest' : 'taobao' };
|
|
const fixtureContext = { window: {} };
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, '../h5/assets/mock/report-fixture.js'), 'utf8'), fixtureContext);
|
|
const payload = { ok: true, ...fixtureContext.window.MOCK_FIXTURE };
|
|
const platform = { getStorageSync: ({ key }) => ({ data: storage.get(key) }),
|
|
setStorageSync: ({ key, data }) => storage.set(key, data), removeStorageSync: ({ key }) => storage.delete(key),
|
|
authorize(value) { calls.push({ route: 'authorize', scope: value.scopes }); value.success({}); },
|
|
redirectTo: ({ url }) => navigation.push(url), navigateTo: ({ url }) => navigation.push(url),
|
|
showToast(value) { throw Error('unexpected business error: ' + value.content); },
|
|
tb: { openDetail: value => opened.push(value.itemId) } };
|
|
const module = { exports: {} };
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'utils/api.js'), 'utf8'), {
|
|
module, Promise, Date, Math, setTimeout: fn => setTimeout(fn, 0), getApp: () => app, my: platform,
|
|
require(name) {
|
|
if (name === '../config') return config;
|
|
if (name !== './transport') throw Error('unexpected API dependency: ' + name);
|
|
return { request(route, body, token) {
|
|
route = route.replace('/api/taobao/', ''); calls.push({ route, body, token });
|
|
return Promise.resolve().then(() => {
|
|
if (route === 'challenge') return { ok: true, challenge: 'flow-challenge' };
|
|
if (route === 'guest-session' || route === 'session') return { ok: true, token: 'flow-token', user };
|
|
assert.equal(token, 'flow-token');
|
|
if (route === 'me') return { ok: true, user };
|
|
if (route === 'photo') return { ok: true };
|
|
if (route === 'skin-analyze') return { ok: true, job: 'flow-job' };
|
|
if (route === 'analysis') return { ok: true, status: 'complete', payload };
|
|
if (route === 'logout') return { ok: true };
|
|
throw Error('unexpected business request: ' + route);
|
|
});
|
|
} };
|
|
}
|
|
});
|
|
const api = module.exports;
|
|
function load(route) {
|
|
let definition;
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, route + '.js'), 'utf8'), {
|
|
Page(value) { definition = value; }, Promise, Date, my: platform, getApp: () => app,
|
|
setTimeout: () => 1, clearTimeout() {}, require(name) {
|
|
return /\/utils\/api(?:\.js)?$/.test(name) ? api : require(path.resolve(root, path.dirname(route), name));
|
|
}
|
|
});
|
|
return Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)),
|
|
setData(patch, callback) { Object.assign(this.data, patch); if (callback) callback(); } });
|
|
}
|
|
const login = load('pages/login/login'); login.onLoad({ switch: '1' }); login.onShow(); login.onToggleAgree();
|
|
if (guest) login.onGuest(); else login.onTaobaoLogin();
|
|
await flush(); assert.equal(navigation.at(-1), '/pages/splash/splash');
|
|
assert.equal(calls.filter(v => v.route === 'authorize').length, guest ? 0 : 1);
|
|
const splash = load('pages/splash/splash'); splash.onShow(); await flush();
|
|
splash.onOpenPrivacy(); splash.onToggleAgree(); splash.onAgreeContinue();
|
|
assert.equal(navigation.at(-1), '/pages/profile/profile');
|
|
const profile = load('pages/profile/profile'); profile.onLoad(); await profile.onShow();
|
|
profile.onOpenBirth(); profile.onPickerConfirm(); profile.onOpenGender(); profile.onPickerConfirm();
|
|
profile.onOpenRegion(); profile.onPickerConfirm(); profile.onSubmit(); assert.equal(profile.data.showGuide, true);
|
|
profile.onStartCamera(); assert.equal(navigation.at(-1), '/pages/camera/camera');
|
|
const f = setup({ app, api, nativeFace: true, captureMode: config.captureMode, photoInputMode: config.photoInputMode,
|
|
my: platform, permissions: { settings: async () => ({}) } });
|
|
await flush(); assert.equal(f.page.data.debugMode, false); assert.equal(f.page.data.cameraEnabled, true);
|
|
assert.equal(f.engineOptions.length, 1); assert.deepEqual(f.scopes, ['scope.camera']);
|
|
for (const [index, nose, start] of [[0, 200, 1000], [1, 188, 7000], [2, 212, 13000]]) {
|
|
await armNative(f, nose, start);
|
|
const shot = f.nativeCameras[0].photos[index];
|
|
shot.success({ tempImagePath: 'flow-photo-' + index }); shot.complete(); await flush();
|
|
if (index < 2) f.timer(2000);
|
|
}
|
|
await flush(); assert.equal(navigation.at(-1), '/pages/report/report');
|
|
assert.deepEqual(calls.filter(v => v.route === 'photo').map(v => v.body.view), ['front', 'left', 'right']);
|
|
const submissions = calls.filter(v => v.route === 'skin-analyze'); assert.equal(submissions.length, 1);
|
|
assert.equal(submissions[0].body.profile.gender, '女'); assert(submissions[0].body.request_id);
|
|
assert(app.globalData.payload.result); assert.equal(app.globalData.photos.length, 3);
|
|
const report = load('pages/report/report'); report.onLoad(); await report.onShow();
|
|
assert.equal(report.data.identityError, ''); assert.equal(report.data.cards.length, 3);
|
|
assert.equal(report.data.cards.flatMap(card => card.views || []).length, 7);
|
|
report.onMore(); assert.deepEqual(opened, ['1090510453077']);
|
|
assert.equal([...storage.keys()].some(key => /profile|session/.test(key)), !guest);
|
|
if (guest) { report.onEndExperience(); await flush(); assert.equal(api.user(), null); assert.equal(app.globalData.payload, null); }
|
|
report.onUnload(); f.page.onUnload(); profile.onUnload(); splash.onUnload(); login.onUnload(); api.clear();
|
|
});
|
|
|
|
test('first main-flow camera visit requests camera scope and starts automatic capture without a second enable tap', async () => {
|
|
const f = setup({ nativeFace: true, photoInputMode: require('../config').photoInputMode,
|
|
permissions: { settings: async () => ({}) } });
|
|
await flush();
|
|
assert.deepEqual(f.scopes, ['scope.camera']); assert.equal(f.page.data.cameraEnabled, true);
|
|
assert.equal(f.page.data.debugMode, false); assert.equal(f.nativeCameras[0].starts, 1);
|
|
f.page.onUnload();
|
|
});
|
|
|
|
test('an explicitly denied camera permission is not requested repeatedly by the main flow', async () => {
|
|
const f = setup({ nativeFace: true, permissions: { settings: async () => ({ 'scope.camera': false }) } });
|
|
await flush(); assert.deepEqual(f.scopes, []); assert.equal(f.nativeCameras.length, 0);
|
|
assert.match(f.page.data.permissionMessage, /相机权限未开启/); f.page.onUnload();
|
|
});
|
|
|
|
test('upload-first guest flow never initializes live recognition and submits three resized JPEGs to one report', async () => {
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', photoInputMode: 'upload' }); await flush();
|
|
assert.equal(f.page.data.uploadOnly, true); assert.equal(f.page.data.autoCapture, false);
|
|
assert.equal(f.engineOptions.length, 0); assert.equal(f.nativeCameras.length, 0);
|
|
for (let i = 0; i < 3; i++) { f.page.onAlbum(); await flush(); if (i < 2) f.timer(2000); }
|
|
await flush(); assert.equal(f.submitted.length, 1); assert.equal(f.submitted[0].photos.length, 3);
|
|
assert.deepEqual(f.scopes, ['scope.album', 'scope.album', 'scope.album']);
|
|
assert(f.navigation.includes('/pages/report/report')); assert.equal(f.engineOptions.length, 0);
|
|
});
|
|
|
|
test('upload-first configuration preserves the isolated real-time development probe', async () => {
|
|
const f = setup({ nativeFace: true, photoInputMode: 'upload', query: { debug: 'local-face' } }); await flush();
|
|
assert.equal(f.page.data.uploadOnly, false); assert.equal(f.page.data.localFaceProbe, true);
|
|
assert.equal(f.page.data.autoCapture, true); assert.equal(f.engineOptions.length, 1);
|
|
f.page.onUnload();
|
|
});
|
|
|
|
test('three system-camera images resize on a mounted canvas and reach a report without extra photo confirmation', async () => {
|
|
const f = setup(); await flush();
|
|
for (let i = 0; i < 3; i++) { f.page.onSystemCamera(); await flush(); if (i < 2) { assert(f.page.data.doneTip); f.timer(2000); } }
|
|
await flush();
|
|
assert.deepEqual(f.scopes, ['scope.camera', 'scope.camera', 'scope.camera']);
|
|
assert.equal(f.exported.length, 3); assert(f.exported.every(item => item.destWidth === 1280 && item.destHeight === 960 && item.fileType === 'jpg'));
|
|
assert.equal(f.submitted.length, 1); assert.equal(f.navigation.at(-1), '/pages/report/report');
|
|
assert.equal(f.app.globalData.photos.length, 3);
|
|
assert.equal(f.page.data.analyzing, false);
|
|
});
|
|
test('photo-processing failure leaves an actionable error and retry uses the same three captures', async () => {
|
|
const f = setup(); await flush(); f.failRead(true);
|
|
f.page.photos = ['front', 'right', 'left']; f.page.submitPhotos(); await flush();
|
|
assert.match(f.page.data.analysisError, /读取失败/); assert.equal(f.page.data.analyzing, false); assert.equal(f.submitted.length, 0);
|
|
f.failRead(false); f.page.onRetryAnalysis(); await flush();
|
|
assert.equal(f.submitted.length, 1); assert.equal(f.navigation.at(-1), '/pages/report/report');
|
|
});
|
|
test('foreground resumes the original submitted attempt and never starts a second paid analysis', async () => {
|
|
let complete, resumed = 0;
|
|
const f = setup({ api: {
|
|
analyze(photos, profile, signal, attempt) { attempt.profile = profile; attempt.job = 'original-job'; return new Promise(resolve => { complete = resolve; }); },
|
|
resumeAnalysis: async attempt => { assert.equal(attempt.job, 'original-job'); resumed++; return { result: {} }; }
|
|
} });
|
|
await flush(); f.page.photos = ['front', 'right', 'left']; f.page.submitPhotos(); await flush();
|
|
f.page.onHide(); assert.equal(f.page.data.analyzing, true); f.page.onShow();
|
|
complete({ result: {} }); await flush(); await flush();
|
|
assert.equal(resumed, 1); assert.equal(f.navigation.at(-1), '/pages/report/report');
|
|
});
|
|
test('raw AR pixel buffers require an explicit supported format; unsupported captures never silently upload', async () => {
|
|
const f = setup({ ar: true }); await flush();
|
|
const data = new Uint8Array([1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255]);
|
|
await assert.rejects(f.image.captureFrameToBase64({ width: 2, height: 2, data }, f.page), /格式尚未确认/);
|
|
assert.equal(f.submitted.length, 0);
|
|
const photo = await f.image.captureFrameToBase64({ width: 2, height: 2, pixelFormat: 'RGBA', data }, f.page);
|
|
assert.match(photo, /^data:image\/jpeg;base64,/);
|
|
});
|
|
test('a delayed image-info callback after timeout cannot paint over a newer compression', async () => {
|
|
let info;
|
|
const f = setup({ my: { getImageInfo(value) { info = value; } } }); await flush();
|
|
const pending = f.image.compressToBase64('old-photo', f.page);
|
|
const rejected = assert.rejects(pending, /处理超时/); f.timer(20000); await rejected;
|
|
info.success({ width: 4000, height: 3000 });
|
|
assert.equal(f.exported.length, 0); assert.equal(f.page.data.compressWidth, 1);
|
|
});
|
|
test('native completion cannot unlock the next-step transition after successful capture', async () => {
|
|
let capture;
|
|
const f = setup({ my: { createCameraContext: () => ({ takePhoto(value) { capture = value; } }) } }); await flush();
|
|
f.page.onShutter(); capture.success({ tempImagePath: 'front' }); capture.complete();
|
|
assert.equal(f.page.data.stepIndex, 1); assert(f.page.data.doneTip); assert.equal(f.page.busy, true);
|
|
});
|
|
test('a system chooser return waits for foreground identity verification before accepting the photo', async () => {
|
|
let chosen, verify, restores = 0;
|
|
const f = setup({ api: { restore: () => ++restores === 1 ? Promise.resolve() : new Promise(resolve => { verify = resolve; }) },
|
|
my: { chooseImage(value) { chosen = value; } } });
|
|
await flush(); f.page.onAlbum(); await flush(); f.page.onHide(); f.page.onShow();
|
|
chosen.success({ apFilePaths: ['private-photo'] }); chosen.complete();
|
|
assert.equal(f.page.data.identityPending, true); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.stepIndex, 0);
|
|
verify(); await flush();
|
|
assert.equal(f.page.photos[0], 'private-photo'); assert.equal(f.page.data.stepIndex, 1);
|
|
});
|
|
test('real AR landmarks trigger one countdown and JPEG capture; hidden pages cannot auto-capture', async () => {
|
|
const f = setup({ ar: true }); await flush();
|
|
const landmarks = Array.from({ length: 212 }, (_, index) => index % 2 ? 250 : 200);
|
|
for (const [i, x, y] of [[52,120,150],[61,280,150],[46,200,230],[49,200,261.5],[35,150,130],[40,250,130],[16,200,400]]) { landmarks[i * 2] = x; landmarks[i * 2 + 1] = y; }
|
|
const frame = { width: 400, height: 700, capturedImage: new Uint8Array(400 * 700 * 4).buffer, anchors: [{ rect: [70,90,330,440], landmarks }] };
|
|
f.page.onARFrame({ detail: frame }); f.setClock(1600); f.page.onARFrame({ detail: frame });
|
|
assert.equal(f.page.data.countdown, 3);
|
|
f.timer(1000); f.timer(1000); f.timer(1000); f.page.onARFrame({ detail: frame }); f.timer(300); await flush();
|
|
assert.equal(f.page.data.stepIndex, 1); assert.equal(f.page.photos.length, 1); assert.match(f.page.preparedPhotos[0], /^data:image\/jpeg/);
|
|
f.page.onHide(); const count = f.page.photos.length; f.page.onARFrame({ detail: frame }); assert.equal(f.page.photos.length, count);
|
|
});
|
|
|
|
test('AR requests front by default; switching remounts the requested lens without discarding completed captures', async () => {
|
|
const f = setup({ ar: true }); await flush();
|
|
const markup = fs.readFileSync(path.join(root, 'pages/camera/camera.axml'), 'utf8');
|
|
assert.match(markup, /<ar-camera[\s\S]*?devicePosition="{{arDevicePosition}}"/);
|
|
assert.equal(f.page.data.devicePosition, 'front');
|
|
assert.equal(f.page.data.arDevicePosition, 'front');
|
|
assert.deepEqual(f.cameraIds, ['skin-ar-camera-1']);
|
|
f.page.photos = ['completed-front']; f.page.setData({ stepIndex: 1 });
|
|
f.page.onFlip();
|
|
assert.equal(f.page.data.devicePosition, 'back'); assert.equal(f.page.data.cameraEnabled, true);
|
|
assert.equal(f.page.data.arDevicePosition, 'back');
|
|
assert.deepEqual(f.cameraIds, ['skin-ar-camera-1', 'skin-ar-camera-2']);
|
|
assert.equal(f.page.photos[0], 'completed-front'); assert.equal(f.page.data.stepIndex, 1);
|
|
f.page.onFlip(); assert.equal(f.page.data.devicePosition, 'front');
|
|
assert.equal(f.page.data.arDevicePosition, 'front');
|
|
assert.equal(f.cameraIds.at(-1), 'skin-ar-camera-3');
|
|
});
|
|
|
|
test('AR compatibility mapping changes the native request while guide mirroring and ordinary camera retain logical lens values', async () => {
|
|
const f = setup({ ar: true, arFrontPosition: 'back' }); await flush();
|
|
const markup = fs.readFileSync(path.join(root, 'pages/camera/camera.axml'), 'utf8');
|
|
assert.equal(f.page.data.devicePosition, 'front'); assert.equal(f.page.data.arDevicePosition, 'back');
|
|
assert.match(markup, /<ar-camera[^>]*devicePosition="{{arDevicePosition}}"/);
|
|
assert.match(markup, /<camera\s[^>]*device-position="{{devicePosition}}"/);
|
|
const guides = [...markup.matchAll(/<(?:image|view)[^>]*class="[^"]*(?:cam-mask-image|cam-live-scan)[^"]*"/g)];
|
|
assert.equal(guides.length, 2);
|
|
for (const element of guides) {
|
|
assert.match(element[0], /devicePosition === 'back'/); assert.doesNotMatch(element[0], /arDevicePosition/);
|
|
}
|
|
f.page.onDiagnose(); await flush();
|
|
assert.match(f.page.data.diagText, /logicalPosition=front\nrequestedPosition=back\nactualPosition=未核验/);
|
|
f.page.onFlip(); assert.equal(f.page.data.devicePosition, 'back'); assert.equal(f.page.data.arDevicePosition, 'front');
|
|
assert.equal(f.cameraIds.at(-1), 'skin-ar-camera-2');
|
|
f.page.onFlip(); assert.equal(f.page.data.devicePosition, 'front'); assert.equal(f.page.data.arDevicePosition, 'back');
|
|
const ordinary = setup({ arFrontPosition: 'back', my: { createCameraContext: () => ({ takePhoto() {} }) } }); await flush();
|
|
assert.equal(ordinary.page.data.useAR, false); assert.equal(ordinary.page.data.devicePosition, 'front');
|
|
ordinary.page.onFlip(); assert.equal(ordinary.page.data.devicePosition, 'back');
|
|
});
|
|
|
|
test('switching cancels countdown and rejects frames and stop events from the previous AR component', async () => {
|
|
const f = setup({ ar: true }); await flush();
|
|
f.page.latestFrame = { old: true }; f.page.holdState = { old: true };
|
|
f.page.startAutoCapture(); assert.equal(f.page.data.countdown, 3);
|
|
const oldSequence = f.page.seq;
|
|
f.page.onFlip();
|
|
assert.equal(f.page.seq, oldSequence + 1); assert.equal(f.page.data.countdown, 0);
|
|
assert.equal(f.page.busy, false); assert.equal(f.page.latestFrame, null); assert.equal(f.page.holdState, null);
|
|
assert(!f.timers.some(row => row.active && row.delay === 1000));
|
|
f.page.onARFrame({ currentTarget: { dataset: { cameraSession: 1 } }, detail: { width: 100, height: 100, anchors: [] } });
|
|
assert.equal(f.page.latestFrame, null);
|
|
f.page.onCameraStop({ currentTarget: { id: 'skin-ar-camera-1' } });
|
|
f.page.onCameraError({ currentTarget: { dataset: { cameraSession: 1 } } });
|
|
assert.equal(f.page.data.cameraEnabled, true); assert.equal(f.page.data.camError, false);
|
|
});
|
|
|
|
function captureFrame() {
|
|
const landmarks = Array.from({ length: 212 }, (_, index) => index % 2 ? 250 : 200);
|
|
for (const [i, x, y] of [[52,120,150],[61,280,150],[46,200,230],[49,200,261.5],[35,150,130],[40,250,130],[16,200,400]]) {
|
|
landmarks[i * 2] = x; landmarks[i * 2 + 1] = y;
|
|
}
|
|
return { width: 400, height: 700, pixelFormat: 'RGBA', data: new Uint8Array(400 * 700 * 4),
|
|
anchors: [{ rect: [70,90,330,440], landmarks }] };
|
|
}
|
|
async function beginNativeExport(f, frame) {
|
|
f.page.onARFrame({ detail: frame }); f.setClock(1600); f.page.onARFrame({ detail: frame });
|
|
assert.equal(f.page.data.countdown, 3);
|
|
f.timer(1000); f.timer(1000); f.timer(1000); f.page.onARFrame({ detail: frame }); f.timer(300); await flush();
|
|
}
|
|
|
|
test('foreground camera remount waits for an unfinished native canvas export and drops its stale photo', async () => {
|
|
const writes = []; let active = 0, peak = 0;
|
|
const f = setup({ ar: true, my: { canvasPutImageData(value) {
|
|
active++; peak = Math.max(peak, active);
|
|
writes.push(() => { active--; value.success(); });
|
|
} } });
|
|
await flush(); const frame = captureFrame();
|
|
await beginNativeExport(f, frame); assert.equal(writes.length, 1); assert.equal(active, 1);
|
|
f.page.onHide(); f.page.onShow(); await flush();
|
|
assert.equal(f.page.data.cameraSession, 2);
|
|
f.page.onARFrame({ detail: frame }); f.page.startAutoCapture();
|
|
assert.equal(f.page.data.countdown, 0, 'new live frames cannot arm another export while native work is pending');
|
|
assert.match(f.page.data.liveHint, /正在准备相机/);
|
|
assert.equal(writes.length, 1); assert(!f.timers.some(t => t.active && t.delay === 1000));
|
|
writes.shift()(); await flush();
|
|
assert.equal(f.page.data.previewUrl, ''); assert.equal(f.page.photos.length, 0);
|
|
assert.equal(f.page.preparedPhotos, undefined, 'the hidden-session export cannot reappear as a captured photo');
|
|
f.page.startAutoCapture(); assert.equal(f.page.data.countdown, 3, 'settling the previous export permits fresh capture');
|
|
f.page.onHide(); assert.equal(peak, 1);
|
|
});
|
|
|
|
test('three chosen photos queue their compression behind an older native export and still complete once', async () => {
|
|
const writes = [];
|
|
const f = setup({ ar: true, my: { canvasPutImageData(value) { writes.push(() => value.success()); } } });
|
|
await flush(); await beginNativeExport(f, captureFrame()); assert.equal(writes.length, 1);
|
|
f.page.onHide(); f.page.onShow(); await flush();
|
|
f.page.photos = ['new-front', 'new-right', 'new-left'];
|
|
f.page.submitPhotos(); await flush();
|
|
assert.equal(f.exported.length, 0, 'ordinary compression must not draw/export on the canvas while the old native write is pending');
|
|
assert.equal(f.submitted.length, 0);
|
|
writes.shift()(); await flush(); await flush();
|
|
assert.equal(f.exported.length, 3); assert.equal(f.submitted.length, 1);
|
|
assert.equal(f.navigation.at(-1), '/pages/report/report');
|
|
assert.deepEqual(f.page.photos, ['new-front', 'new-right', 'new-left']);
|
|
assert.equal(f.page.data.previewUrl, '', 'old raw-frame completion never restores the capture preview');
|
|
});
|
|
|
|
test('camera errors during the fill-light delay cancel the shutter before any native pixel write', async () => {
|
|
let writes = 0;
|
|
const f = setup({ ar: true, my: { canvasPutImageData(value) { writes++; value.success(); } } });
|
|
await flush(); const frame = captureFrame();
|
|
f.page.onARFrame({ detail: frame }); f.setClock(1600); f.page.onARFrame({ detail: frame });
|
|
f.timer(1000); f.timer(1000); f.timer(1000); f.page.onARFrame({ detail: frame });
|
|
assert.equal(f.page.data.fillLight, true);
|
|
const originalSeq = f.page.seq;
|
|
f.page.onCameraError({ currentTarget: { id: 'skin-ar-camera-' + f.page.data.cameraSession } });
|
|
await flush();
|
|
assert.equal(f.page.seq, originalSeq + 1); assert.equal(f.page.latestFrame, null);
|
|
assert.equal(f.page.data.fillLight, false); assert.equal(f.page.data.countdown, 0); assert.equal(f.page.busy, false);
|
|
assert(!f.timers.some(t => t.active && t.delay === 300));
|
|
assert.equal(writes, 0); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.stepIndex, 0);
|
|
});
|
|
|
|
test('native stop while exporting keeps the canvas serialized and cannot accept the late photo', async () => {
|
|
const writes = [];
|
|
const f = setup({ ar: true, my: { canvasPutImageData(value) { writes.push(() => value.success()); } } });
|
|
await flush(); await beginNativeExport(f, captureFrame()); assert.equal(writes.length, 1);
|
|
const exporting = f.page.canvasOperation, originalSeq = f.page.seq;
|
|
f.page.onCameraStop({ currentTarget: { id: 'skin-ar-camera-' + f.page.data.cameraSession } });
|
|
assert.equal(f.page.seq, originalSeq + 1); assert.equal(f.page.canvasOperation, exporting);
|
|
assert.equal(f.page.data.cameraEnabled, false); assert.equal(f.page.busy, false);
|
|
writes.shift()(); await flush();
|
|
assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.stepIndex, 0); assert.equal(f.page.data.previewUrl, '');
|
|
assert.equal(f.page.preparedPhotos, undefined); assert.equal(f.page.canvasOperation, null);
|
|
f.page.onEnableCamera(); await flush(); assert.equal(f.page.data.cameraEnabled, true);
|
|
const currentSession = f.page.data.cameraSession;
|
|
f.page.onCameraError({ currentTarget: { id: 'skin-ar-camera-' + (currentSession - 1) } });
|
|
assert.equal(f.page.data.cameraEnabled, true, 'late native errors cannot stop a restarted component');
|
|
});
|
|
|
|
test('a native camera stop cannot cancel an already-submitted analysis or discard its report', async () => {
|
|
let complete;
|
|
const f = setup({ ar: true, api: { analyze() { return new Promise(resolve => { complete = resolve; }); } } });
|
|
await flush(); f.page.photos = ['front', 'right', 'left']; f.page.submitPhotos(); await flush();
|
|
assert(complete); const seq = f.page.seq, signal = f.page.signal;
|
|
f.page.onCameraStop({ currentTarget: { id: 'skin-ar-camera-' + f.page.data.cameraSession } });
|
|
assert.equal(f.page.seq, seq); assert.equal(signal.canceled, false); assert.equal(f.page.data.analyzing, true);
|
|
complete({ result: {} }); await flush();
|
|
assert.equal(f.navigation.at(-1), '/pages/report/report');
|
|
});
|
|
|
|
test('report generation releases the live camera; errors keep it stopped until explicit retake', async () => {
|
|
let failAnalysis;
|
|
const f = setup({ ar: true, api: { analyze() { return new Promise((resolve, reject) => { failAnalysis = reject; }); } } });
|
|
await flush(); assert.equal(f.page.data.cameraEnabled, true);
|
|
const session = f.page.data.cameraSession;
|
|
f.page.latestFrame = captureFrame(); f.page.photos = ['front', 'right', 'left'];
|
|
f.page.submitPhotos(); await flush();
|
|
assert.equal(f.page.data.cameraEnabled, false); assert.equal(f.page.latestFrame, null);
|
|
assert.equal(f.page.arContext, null); assert.equal(f.page.cameraCtx, null);
|
|
f.page.onARFrame({ detail: captureFrame() }); assert.equal(f.page.latestFrame, null);
|
|
failAnalysis(new Error('temporary network failure')); await flush();
|
|
assert.match(f.page.data.analysisError, /network failure/);
|
|
f.page.mountCamera(); assert.equal(f.page.data.cameraEnabled, false);
|
|
f.page.onRestart(); assert.equal(f.page.data.cameraEnabled, true);
|
|
assert.equal(f.page.data.cameraSession, session + 1); assert.equal(f.page.data.stepIndex, 0);
|
|
assert.equal(f.page.photos.length, 0);
|
|
});
|
|
|
|
function nativeResult(noseX = 200) {
|
|
const frame = captureFrame(); frame.anchors[0].landmarks[46 * 2] = noseX;
|
|
const normalized = require('../utils/ar-camera').normalize(frame);
|
|
return { width: normalized.width, height: normalized.height, detections: normalized.detections, elapsedMs: 10 };
|
|
}
|
|
async function nativeFrame(f, noseX = 200) {
|
|
f.nativeCameras.at(-1).callback({ width: 400, height: 700, data: new Uint8Array(400 * 700 * 4).buffer, noseX });
|
|
await flush();
|
|
}
|
|
async function armNative(f, noseX, start) {
|
|
f.setClock(start); await nativeFrame(f, noseX);
|
|
f.setClock(start + 600); await nativeFrame(f, noseX);
|
|
assert.equal(f.page.data.countdown, 3);
|
|
f.timer(1000); f.timer(1000); f.timer(1000);
|
|
await nativeFrame(f, noseX); f.timer(300); await flush();
|
|
}
|
|
|
|
test('ordinary front frames use local geometry and automatically take three JPEGs before submitting one report', async () => {
|
|
const f = setup({ nativeFace: true, photoInputMode: require('../config').photoInputMode,
|
|
nativePixelFormat: 'BGRA', arPixelFormat: 'ARGB', my: { canvasPutImageData() { throw Error('ordinary automatic capture must use takePhoto, not raw AR export'); } } });
|
|
await flush();
|
|
assert.equal(f.page.data.devicePosition, 'front'); assert.equal(f.page.data.useAR, false); assert.equal(f.page.data.autoCapture, true);
|
|
assert.equal(f.nativeCameras[0].id, 'skin-camera-1'); assert.equal(f.nativeCameras[0].starts, 1);
|
|
assert.equal(f.engineOptions[0].defaultPixelFormat, 'BGRA', 'ordinary raw frames use their own setting, independently of the AR format'); assert.equal(f.engineOptions[0].inputSize, 256);
|
|
assert.equal(f.engineOptions[0].scoreThreshold, 0.25, 'the model does not prefilter faces more strictly than the H5 front gate');
|
|
const record = f.nativeCameras[0];
|
|
for (const [index, noseX, start, file] of [[0, 200, 1000, 'front-jpeg'], [1, 140, 7000, 'right-jpeg'], [2, 260, 13000, 'left-jpeg']]) {
|
|
await armNative(f, noseX, start);
|
|
assert.equal(record.photos.length, index + 1); assert.equal(f.submitted.length, 0);
|
|
const shot = record.photos[index]; assert.equal(shot.quality, 'high');
|
|
const beforeAcceptance = f.updates.length;
|
|
shot.success({ tempImagePath: file }); shot.complete(); await flush();
|
|
const acceptedUpdates = f.updates.slice(beforeAcceptance);
|
|
assert(!acceptedUpdates.some(value => value.previewUrl), 'automatic photos never mount the manual retake/use-photo overlay');
|
|
const firstRender = acceptedUpdates[0];
|
|
assert.equal(firstRender.previewUrl, '');
|
|
assert.equal(firstRender.fillLight, false);
|
|
assert.equal(firstRender.countdown, 0);
|
|
if (index < 2) assert(firstRender.doneTip, 'the first render directly shows the completed-step transition');
|
|
else assert.equal(firstRender.analyzing, true, 'the third photo goes directly to analysis without exposing capture controls');
|
|
if (index < 2) { assert(f.page.data.doneTip); assert.equal(f.page.busy, true); f.timer(2000); }
|
|
}
|
|
await flush();
|
|
assert.deepEqual(Array.from(f.page.photos), ['front-jpeg', 'right-jpeg', 'left-jpeg']);
|
|
assert.equal(f.exported.length, 3); assert.equal(f.submitted.length, 1); assert.equal(f.navigation.at(-1), '/pages/report/report');
|
|
assert.equal(record.stops, 1); assert.equal(f.page.data.cameraEnabled, false);
|
|
assert(!JSON.stringify(f.page.data).includes('landmarks'), 'native face coordinates never enter setData');
|
|
});
|
|
|
|
test('measured dim and bright face warnings reach the existing hint without blocking automatic photos', async () => {
|
|
for (const [luma, hint] of [[50, '建议到光线明亮处拍摄'], [240, '光线过强,建议避开强光']]) {
|
|
const f = setup({ nativeFace: true, detector: { init: async () => ({}),
|
|
detect: async input => ({ ...nativeResult(input.noseX), luma }), dispose() {} } }); await flush();
|
|
await armNative(f, 200, 1000);
|
|
assert.equal(f.page.data.liveHint, hint + ',保持不动即可拍摄');
|
|
assert.equal(f.nativeCameras[0].photos.length, 1); assert.equal(f.page.latestNativeVerdict.observation.luma, luma);
|
|
f.page.onUnload();
|
|
}
|
|
});
|
|
|
|
test('formal automatic capture speaks step and pose guidance without cutting the step for countdown', async () => {
|
|
const spoken = [], stops = [];
|
|
const f = setup({ nativeFace: true, voice: {
|
|
VOICE: { step: ['step1-front', 'step2-rightface', 'step3-leftface'], hint: { '请向左转头': 'hint-turn-left', '保持不动': 'hint-hold' },
|
|
countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail' },
|
|
play(id, opts) { spoken.push({ id, opts: opts || {} }); }, stop(hard) { stops.push(hard); }, dropHint() {}
|
|
} });
|
|
await flush(); assert.equal(spoken[0].id, 'step1-front');
|
|
await armNative(f, 200, 1000);
|
|
assert.equal(spoken.find(v => v.id === 'countdown-321').opts.cut, undefined);
|
|
const shot = f.nativeCameras[0].photos[0]; shot.success({ tempImagePath: 'front-jpeg' }); shot.complete(); await flush(); f.timer(2000);
|
|
assert(spoken.some(v => v.id === 'shot-done')); assert(spoken.some(v => v.id === 'step2-rightface'));
|
|
f.setClock(8000); await nativeFrame(f, 260); f.setClock(8500); await nativeFrame(f, 260);
|
|
assert.equal(spoken.filter(v => v.id === 'hint-turn-left').length, 1);
|
|
assert.equal(spoken.find(v => v.id === 'hint-turn-left').opts.hint, true);
|
|
f.page.onHide(); assert(stops.includes(true)); f.page.onUnload();
|
|
});
|
|
|
|
test('formal countdown lets the step finish, starts once, and canceled or late audio cannot take a photo', async () => {
|
|
const waiting = [], spoken = [];
|
|
const f = setup({ nativeFace: true, voice: { afterCurrent(fn) { waiting.push(fn); }, play(id) { spoken.push(id); },
|
|
VOICE: { step: ['front'], countdown: 'countdown', shot: 'shot' } } }); await flush();
|
|
await nativeFrame(f); f.setClock(1600); await nativeFrame(f);
|
|
assert.equal(f.page.busy, true); assert.equal(f.page.data.countdown, 0); assert.equal(waiting.length, 1);
|
|
waiting[0](); assert.equal(f.page.data.countdown, 3); waiting[0]();
|
|
assert.equal(spoken.filter(v => v === 'countdown').length, 1);
|
|
f.page.onHide(); waiting[0](); assert.equal(f.nativeCameras[0].photos.length, 0); f.page.onUnload();
|
|
const callbacks = [];
|
|
const canceled = setup({ nativeFace: true, voice: { afterCurrent(fn) { callbacks.push(fn); } } }); await flush();
|
|
await nativeFrame(canceled); canceled.setClock(1600); await nativeFrame(canceled);
|
|
canceled.setClock(1900); await nativeFrame(canceled, 260);
|
|
assert.equal(canceled.page.busy, false); callbacks[0](); assert.equal(canceled.page.data.countdown, 0);
|
|
assert.equal(canceled.nativeCameras[0].photos.length, 0); canceled.page.onUnload();
|
|
});
|
|
|
|
test('a stalled step voice cannot block countdown beyond three seconds or start it twice', async () => {
|
|
const callbacks = [], spoken = [];
|
|
const f = setup({ nativeFace: true, voice: { afterCurrent(fn) { callbacks.push(fn); }, play(id) { spoken.push(id); },
|
|
VOICE: { step: ['front'], countdown: 'countdown' } } }); await flush();
|
|
await nativeFrame(f); f.setClock(1600); await nativeFrame(f);
|
|
f.setClock(4500); await nativeFrame(f); f.timer(3000, 4600);
|
|
assert.equal(f.page.data.countdown, 3); callbacks[0]();
|
|
assert.equal(spoken.filter(v => v === 'countdown').length, 1);
|
|
f.page.onUnload();
|
|
});
|
|
|
|
test('front-camera side recognition reverses yaw while guidance, photo labels and rear-camera mapping stay unchanged', async () => {
|
|
for (const rear of [false, true]) {
|
|
const f = setup({ nativeFace: true }); await flush();
|
|
if (rear) { f.page.onFlip(); await flush(); }
|
|
for (const [step, tip, wrong, correct, hint] of [
|
|
[1, '请右脸出镜', rear ? 140 : 260, rear ? 260 : 140, '请向左转头'],
|
|
[2, '请左脸出镜', rear ? 260 : 140, rear ? 140 : 260, '请向右转头']
|
|
]) {
|
|
f.page.setData({ stepIndex: step }); f.page.renderStep();
|
|
assert.equal(f.page.data.stepTip, tip);
|
|
f.setClock(step * 2000);
|
|
await nativeFrame(f, wrong); assert.equal(f.page.latestNativeVerdict.ok, false);
|
|
assert.equal(f.page.data.liveHint, hint); assert.equal(f.page.data.countdown, 0);
|
|
f.setClock(step * 2000 + 500);
|
|
await nativeFrame(f, correct); assert.equal(f.page.latestNativeVerdict.ok, true);
|
|
assert.equal(f.page.data.maskName, step === 1 ? 'right' : 'left');
|
|
assert.equal(f.nativeCameras.at(-1).photos.length, 0, 'one valid frame alone cannot shoot');
|
|
}
|
|
f.page.onUnload();
|
|
}
|
|
});
|
|
|
|
test('native inference remains single-flight across hide/resume and old frame results cannot start a new-session countdown', async () => {
|
|
let finish, detected = 0, disposed = 0;
|
|
const detector = { init: async () => ({}), detect: () => { detected++; return detected === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve(nativeResult()); }, dispose() { disposed++; } };
|
|
const f = setup({ nativeFace: true, detector }); await flush();
|
|
f.nativeCameras[0].callback({ width: 400, height: 700, data: new ArrayBuffer(16) }); await flush();
|
|
assert.equal(detected, 1); f.page.onHide(); assert.equal(f.nativeCameras[0].stops, 1);
|
|
f.page.onShow(); await flush(); assert.equal(f.nativeCameras.length, 2);
|
|
await nativeFrame(f); assert.equal(detected, 1, 'a pending old detector operation blocks parallel inference');
|
|
finish(nativeResult()); await flush();
|
|
assert.equal(f.page.data.countdown, 0); assert.equal(f.page.latestNativeVerdict, null);
|
|
f.setClock(1600); await nativeFrame(f); f.setClock(2200); await nativeFrame(f);
|
|
assert.equal(f.page.data.countdown, 3);
|
|
const old = f.nativeCameras[1]; f.page.onFlip(); await flush();
|
|
assert.equal(old.stops, 1); assert.equal(f.page.data.devicePosition, 'back'); assert.equal(f.page.data.countdown, 0);
|
|
old.callback({ width: 400, height: 700, data: new ArrayBuffer(16) }); await flush(); assert.equal(f.page.latestNativeVerdict, null);
|
|
f.page.onUnload(); assert.equal(disposed, 1); assert.equal(f.nativeCameras.at(-1).stops, 1);
|
|
});
|
|
|
|
test('native takePhoto stays locked across hide/resume and its stale JPEG cannot become a captured step', async () => {
|
|
const f = setup({ nativeFace: true }); await flush(); await armNative(f, 200, 1000);
|
|
const oldShot = f.nativeCameras[0].photos[0]; assert(oldShot);
|
|
f.page.onHide(); f.page.onShow(); await flush();
|
|
await nativeFrame(f); f.setClock(6000); await nativeFrame(f);
|
|
assert.equal(f.page.data.countdown, 0); assert.equal(f.nativeCameras[1].photos.length, 0);
|
|
oldShot.success({ tempImagePath: 'old-private-jpeg' }); oldShot.complete(); await flush();
|
|
assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.previewUrl, ''); assert.equal(f.page.nativePhotoOperation, null);
|
|
await armNative(f, 200, 7000); assert.equal(f.nativeCameras[1].photos.length, 1);
|
|
f.nativeCameras[1].photos[0].fail({}); await flush();
|
|
assert.equal(f.page.data.cameraEnabled, false); assert.match(f.page.data.permissionMessage, /未成功返回照片/); assert.equal(f.submitted.length, 0);
|
|
});
|
|
|
|
test('armed native countdown takes a photo at zero without requiring another recognized frame', async () => {
|
|
const f = setup({ nativeFace: true }); await flush();
|
|
await nativeFrame(f); f.setClock(1600); await nativeFrame(f);
|
|
assert.equal(f.page.data.countdown, 3);
|
|
f.timer(1000); f.timer(1000); f.timer(1000); f.timer(300);
|
|
assert.equal(f.nativeCameras[0].photos.length, 1);
|
|
assert.equal(f.page.nativeCaptureWait, null);
|
|
});
|
|
|
|
test('motion, out-of-frame or missing-face updates cannot cancel an armed countdown', async t => {
|
|
for (const kind of ['angle', 'position', 'confidence', 'no-face']) await t.test(kind, async () => {
|
|
let changed = false, detects = 0;
|
|
const detector = { init: async () => ({}), detect: async () => {
|
|
detects++;
|
|
const result = nativeResult(changed && kind === 'angle' ? 260 : 200);
|
|
if (changed && kind === 'position') result.detections[0].detection.box.x = -50;
|
|
if (changed && kind === 'confidence') result.detections[0].detection.score = .01;
|
|
if (changed && kind === 'no-face') result.detections = [];
|
|
return result;
|
|
}, dispose() {} };
|
|
const f = setup({ nativeFace: true, detector }); await flush();
|
|
await nativeFrame(f); f.setClock(1600); await nativeFrame(f);
|
|
assert.equal(f.page.data.countdown, 3);
|
|
changed = true;
|
|
for (const left of [3, 2, 1]) {
|
|
f.setClock(1800 + (3 - left) * 1000); await nativeFrame(f);
|
|
assert.equal(f.page.data.countdown, left);
|
|
assert.equal(f.page.busy, true); f.timer(1000);
|
|
}
|
|
f.timer(300);
|
|
assert.equal(detects, 2, 'recognition gates pause during the countdown');
|
|
assert.equal(f.nativeCameras[0].photos.length, 1);
|
|
assert.equal(f.page.nativeCaptureWait, null);
|
|
});
|
|
});
|
|
|
|
test('an inference already in flight cannot cancel countdown or trigger an extra photo after the shutter', async () => {
|
|
let finish, detects = 0;
|
|
const detector = { init: async () => ({}), detect: () => ++detects === 2 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve(nativeResult()), dispose() {} };
|
|
const f = setup({ nativeFace: true, detector }); await flush(); await nativeFrame(f);
|
|
f.setClock(1400); await nativeFrame(f);
|
|
f.page.startAutoCapture(); assert.equal(f.page.data.countdown, 3);
|
|
finish({ ...nativeResult(), detections: [] }); await flush();
|
|
assert.equal(f.page.data.countdown, 3); assert.equal(f.page.busy, true);
|
|
f.timer(1000); f.timer(1000); f.timer(1000); f.timer(300);
|
|
assert.equal(f.nativeCameras[0].photos.length, 1);
|
|
});
|
|
|
|
test('hiding, flipping or camera errors still cancel an armed countdown', async t => {
|
|
for (const action of ['hide', 'flip', 'error']) await t.test(action, async () => {
|
|
const f = setup({ nativeFace: true }); await flush();
|
|
await nativeFrame(f); f.setClock(1600); await nativeFrame(f);
|
|
assert.equal(f.page.data.countdown, 3);
|
|
const oldSequence = f.page.seq;
|
|
if (action === 'hide') f.page.onHide(); else if (action === 'flip') f.page.onFlip(); else f.page.onCameraError();
|
|
f.page.finishNativeCountdown(oldSequence); await flush();
|
|
assert(f.nativeCameras.every(camera => !camera.photos.length));
|
|
assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.countdown, 0);
|
|
assert(!f.timers.some(timer => timer.active && timer.delay === 1000));
|
|
});
|
|
});
|
|
|
|
test('three consecutive slow local inferences explicitly stop automatic capture without refreshing their source timestamps', async () => {
|
|
let finish;
|
|
const f = setup({ nativeFace: true, detector: { init: async () => ({}), detect: () => new Promise(resolve => { finish = resolve; }), dispose() {} } });
|
|
await flush();
|
|
for (const inputAt of [1000, 3000, 5000]) {
|
|
f.setClock(inputAt); await nativeFrame(f); f.setClock(inputAt + 1600); finish(nativeResult()); await flush();
|
|
assert.equal(f.page.data.countdown, 0); assert.equal(f.nativeCameras[0].photos.length, 0);
|
|
}
|
|
assert.equal(f.page.data.cameraEnabled, false); assert.equal(f.nativeCameras[0].stops, 1);
|
|
assert.match(f.page.data.permissionMessage, /识别链路连续耗时超过1.5秒/); assert.match(f.page.data.permissionMessage, /未完成自动拍摄/);
|
|
assert.equal(f.page.nativeCaptureWait, null); assert.equal(f.page.busy, false);
|
|
});
|
|
|
|
test('missing listener, local model failure, no real frames and detector errors explicitly stop automatic capture', async t => {
|
|
for (const kind of ['missing', 'start-fail', 'init-fail', 'empty-stream', 'detect-fail']) await t.test(kind, async () => {
|
|
let choices = 0;
|
|
const detector = { init: async () => { if (kind === 'init-fail') throw Error('model unavailable'); },
|
|
detect: async () => { if (kind === 'detect-fail') throw Error('unsupported pixels'); return nativeResult(); }, dispose() {} };
|
|
const f = setup({ nativeFace: true, detector, nativeMissingFrames: kind === 'missing', nativeStartFailure: kind === 'start-fail', my: { chooseImage() { choices++; } } });
|
|
await flush();
|
|
if (kind === 'empty-stream') f.timer(10000);
|
|
if (kind === 'detect-fail') await nativeFrame(f);
|
|
assert.equal(f.page.data.cameraEnabled, false); assert.equal(f.page.data.autoCapture, true); assert.equal(f.page.data.countdown, 0);
|
|
assert.match(f.page.data.permissionMessage, /未完成自动拍摄/); assert.equal(f.page.photos.length, 0);
|
|
f.page.onShutter(); assert.equal(choices, 0, 'the automatic mode never silently falls back to system/manual capture');
|
|
});
|
|
});
|
|
|
|
function probeIsolation() {
|
|
const calls = [];
|
|
const api = Object.fromEntries(['user', 'restore', 'analyze', 'resumeAnalysis', 'createAnalysisAttempt', 'discardAnalysis'].map(name => [name, () => {
|
|
calls.push('api.' + name); throw Error('local probe must not use account or report APIs');
|
|
}]));
|
|
return { calls, api, getApp() { calls.push('getApp'); throw Error('local probe must not access global user data'); },
|
|
voice: { play() { calls.push('voice.play'); }, stop() { calls.push('voice.stop'); } } };
|
|
}
|
|
|
|
test('cloud local-face probe captures three temporary photos with slight side turns without any account/report/global-data or voice calls', async () => {
|
|
const isolated = probeIsolation(), copied = []; let exited = 0;
|
|
const f = setup({ nativeFace: true, transport: 'cloud', query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice,
|
|
my: { setClipboard(value) { copied.push(value.text); value.success(); }, navigateBack() { exited++; },
|
|
chooseImage() { throw Error('local face probe must not use manual capture'); }, request() { throw Error('no network'); }, uploadFile() { throw Error('no upload'); }, setStorageSync() { throw Error('no persistent photos'); } } });
|
|
await flush();
|
|
assert.equal(f.page.debugMode, true); assert.equal(f.page.data.localFaceProbe, true); assert.equal(f.page.debugKind, 'local-face');
|
|
assert.equal(f.page.data.captureProbeVersion, 'LOCAL-FACE-20261102-09'); assert(f.page.data.probeToolbarTop >= 112);
|
|
assert.equal(f.nativeCameras[0].starts, 1, 'no nativeReady event is required when real frames are available');
|
|
f.page.onAlbum(); f.page.onSystemCamera(); f.page.choose(['camera']);
|
|
for (const [index, noseX, start] of [[0, 200, 1000], [1, 188, 7000], [2, 212, 13000]]) {
|
|
if (index) {
|
|
const pose = require('../vendor/head-pose').estimate(nativeResult(noseX).detections[0].landmarks.positions, false);
|
|
assert(Math.abs(pose.yaw) >= 12 && Math.abs(pose.yaw) < 24, 'small turns exercise the relaxed gate');
|
|
}
|
|
await armNative(f, noseX, start);
|
|
const shot = f.nativeCameras[0].photos[index];
|
|
shot.success({ tempImagePath: 'PRIVATE_LOCAL_PHOTO_' + index }); shot.complete(); await flush();
|
|
if (index < 2) { assert.equal(f.page.data.stepIndex, index + 1); f.timer(2000); }
|
|
}
|
|
await flush();
|
|
assert.equal(f.page.data.debugComplete, true); assert.equal(f.exported.length, 3); assert.equal(f.submitted.length, 0);
|
|
assert.deepEqual(Array.from(f.page.data.debugPhotos, item => item.key), ['front', 'right', 'left']);
|
|
assert(f.page.data.debugPhotos.every(item => item.width === 1280 && item.height === 960));
|
|
assert.equal(f.page.probeAutoCaptured, 3); assert.equal(f.navigation.length, 0); assert.deepEqual(isolated.calls, []);
|
|
f.page.onCopyCaptureDiagnostics(); assert.equal(copied.length, 1);
|
|
assert.match(copied[0], /^LOCAL-FACE-20261102-09/); assert.match(copied[0], /autoCapturedPhotoCount=3; done=true/);
|
|
assert.match(copied[0], /stage=complete; failureStage=none/);
|
|
assert.match(copied[0], /framesProcessed=6/); assert.match(copied[0], /quality.reason=ok/);
|
|
assert.doesNotMatch(copied[0], /PRIVATE_|data:image|landmarks|positions|real_age|tempImagePath/);
|
|
f.page.onDebugRestart(); await flush();
|
|
assert.equal(f.page.data.debugComplete, false); assert.equal(f.page.data.debugPhotos.length, 0); assert.equal(f.page.probeAutoCaptured, 0);
|
|
assert.equal(f.nativeCameras.length, 2); assert.equal(f.nativeCameras[1].starts, 1); assert.deepEqual(isolated.calls, []);
|
|
f.cameraConfig.environment = 'production'; const seq = f.page.seq; f.page.onDebugRestart(); assert.equal(f.page.seq, seq, 'restart rechecks the local-face gate');
|
|
f.page.onCancel(); f.page.onUnload();
|
|
assert.equal(exited, 1); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.debugPhotos.length, 0); assert.deepEqual(isolated.calls, []);
|
|
});
|
|
|
|
test('local-face probe cancellation drops a pending native JPEG and cannot create account state or publish a preview', async () => {
|
|
const isolated = probeIsolation();
|
|
const f = setup({ nativeFace: true, query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice });
|
|
await flush(); await armNative(f, 200, 1000);
|
|
const shot = f.nativeCameras[0].photos[0]; assert(shot);
|
|
f.page.onCancel(); f.page.onUnload(); shot.success({ tempImagePath: 'PRIVATE_LATE_PROBE_PHOTO' }); shot.complete(); await flush();
|
|
assert.equal(f.page.photos.length, 0); assert.equal(f.page.probeAutoCaptured, 0); assert.equal(f.page.data.debugPhotos.length, 0);
|
|
assert.equal(f.page.data.previewUrl, ''); assert.equal(f.exported.length, 0); assert.deepEqual(isolated.calls, []);
|
|
});
|
|
|
|
test('local-face probe gate accepts cloud development only with native-face and a native camera, and never widens the legacy camera gate', async () => {
|
|
for (const patch of [{ environment: 'production' }, { captureMode: 'ar' }, { nativeCamera: false }, { cameraDebug: false }]) {
|
|
const f = setup({ nativeFace: true, query: { debug: 'local-face' }, api: { user: () => null }, ...patch }); await flush();
|
|
assert.equal(f.page.debugMode, false); assert.equal(f.page.data.localFaceProbe, false);
|
|
assert.equal(f.navigation[0], '/pages/splash/splash'); assert.equal(f.nativeCameras.length, 0);
|
|
}
|
|
const legacyCloud = setup({ nativeFace: true, cameraDebug: true, transport: 'cloud', query: { debug: 'camera' }, api: { user: () => null } }); await flush();
|
|
assert.equal(legacyCloud.page.debugMode, false); assert.equal(legacyCloud.navigation[0], '/pages/splash/splash');
|
|
assert.equal(legacyCloud.nativeCameras.length, 0);
|
|
});
|
|
|
|
test('unsupported local probe still exposes independent safe capability diagnostics without accessing permissions or identity', async () => {
|
|
const isolated = probeIsolation(), copied = [];
|
|
const f = setup({ nativeFace: true, cameraUnsupported: true, query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice,
|
|
my: { canIUse: name => name !== 'camera', setClipboard(value) { copied.push(value.text); }, getSystemInfoSync: () => ({ statusBarHeight: 64, safeArea: { top: 68 }, titleBarHeight: 48 }) } });
|
|
await flush();
|
|
assert.equal(f.page.data.cameraEnabled, false); assert.match(f.page.data.permissionMessage, /能力检查/);
|
|
assert(f.page.data.probeToolbarTop >= 128); assert.equal(copied.length, 0);
|
|
f.page.onCopyCaptureDiagnostics();
|
|
assert.match(copied[0], /typeof.createCameraContext=function/); assert.match(copied[0], /canIUse.camera=no; canIUse.createCameraContext=yes/);
|
|
assert.match(copied[0], /framesProcessed=0; inflight=false/); assert.equal(f.nativeCameras.length, 0); assert.deepEqual(isolated.calls, []);
|
|
});
|
|
|
|
test('local capture diagnostics distinguish model initialization and listener failures without copying native error text', async () => {
|
|
for (const kind of ['init', 'listener']) {
|
|
const isolated = probeIsolation();
|
|
const f = setup({ nativeFace: true, query: { debug: 'local-face' }, nativeMissingFrames: kind === 'listener', api: isolated.api, getApp: isolated.getApp, voice: isolated.voice,
|
|
detector: { init: async () => { const error = Error('PRIVATE_NATIVE_ERROR'); error.code = 'NATIVE_FACE_BACKEND'; throw error; }, dispose() {}, detect() {} } });
|
|
await flush(); f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, new RegExp('stage=failed; failureStage=' + kind));
|
|
assert.match(f.page.data.diagText, /framesProcessed=0/); assert.doesNotMatch(f.page.data.diagText, /PRIVATE_NATIVE_ERROR/);
|
|
if (kind === 'init') assert.match(f.page.data.diagText, /quality.code=NATIVE_FACE_BACKEND/);
|
|
assert.deepEqual(isolated.calls, []);
|
|
}
|
|
});
|
|
|
|
test('capture probe reports actual runtime/config/timings and counts high-rate skipped frames without per-frame UI work', async () => {
|
|
let finish;
|
|
const secret = 'PRIVATE_ENGINE_OR_PIXEL', isolated = probeIsolation();
|
|
const detector = { init: async () => ({ backend: 'cpu', inputSize: 128, maxSide: 160, landmarkModel: 'tiny', cpuOptimization: 'pad-copy', private: secret }),
|
|
detect: () => new Promise(resolve => { finish = resolve; }), dispose() {} };
|
|
const f = setup({ nativeFace: true, query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice, detector,
|
|
nativeFrameSize: 'small', nativeFaceInputSize: 128, nativeFaceMaxSide: 160, nativeFaceLandmarkModel: 'tiny',
|
|
my: { SDKVersion: '2.8.0', getSystemInfoSync: () => ({ model: 'Phone-test', version: '11.2.0', userId: secret }),
|
|
createOffscreenCanvas() { throw Error('diagnostics must not initialize graphics'); }, createCanvas: undefined,
|
|
createWorker() { throw Error('diagnostics must not start workers'); } } });
|
|
await flush();
|
|
assert.equal(f.page.data.nativeFrameSize, 'small');
|
|
assert.equal(f.engineOptions[0].inputSize, 128); assert.equal(f.engineOptions[0].maxSide, 160); assert.equal(f.engineOptions[0].landmarkModel, 'tiny');
|
|
const raw = { width: 540, height: 960, data: new ArrayBuffer(2073600), rotation: 0, private: secret };
|
|
const baseline = f.updates.length;
|
|
for (let index = 0; index < 101; index++) f.nativeCameras[0].callback(raw);
|
|
assert.equal(f.updates.length, baseline, 'accepted inference and skipped frames perform no setData until a result is available');
|
|
assert.equal(f.page.nativeAcceptedCalls, 1); assert.equal(f.page.nativeFramesSkippedInFlight, 100);
|
|
f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, /frameEvents.total=101; skippedInFlight=100; acceptedCalls=1/);
|
|
assert.match(f.page.data.diagText, /runtime.SDKVersion=2.8.0/); assert.match(f.page.data.diagText, /frameSize.requested=small/);
|
|
assert.match(f.page.data.diagText, /typeof.createOffscreenCanvas=function; typeof.createCanvas=undefined; typeof.createWorker=function/);
|
|
assert.match(f.page.data.diagText, /engine.actual.backend=cpu; inputSize=128; maxSide=160; landmarkModel=tiny/);
|
|
assert.match(f.page.data.diagText, /cpuOptimization=pad-copy/);
|
|
f.setClock(1200); finish({ ...nativeResult(), elapsedMs: 199, backend: 'cpu', inputSize: 128, maxSide: 160, landmarkModel: 'tiny', cpuOptimization: 'pad-copy',
|
|
timings: { snapshotMs: 2, modelMs: 0, tensorMs: 1, detectorMs: 160, landmarkMs: 36, totalMs: 199, private: secret } }); await flush();
|
|
f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText;
|
|
assert.match(report, /currentDetection.sourceAgeMs=200/); assert.match(report, /timings.snapshotMs=2/);
|
|
assert.match(report, /timings.detectorMs=160/); assert.match(report, /timings.landmarkMs=36/); assert.match(report, /timings.totalMs=199/);
|
|
assert.match(report, /frame.width=540; frame.height=960; frame.byteLength=2073600/);
|
|
assert.doesNotMatch(report, /PRIVATE_|data:image|positions|landmarks|userId/); assert.deepEqual(isolated.calls, []);
|
|
});
|
|
|
|
test('invalid performance config and nonnumeric timing fields remain safe, without changing freshness policy', async () => {
|
|
const isolated = probeIsolation(), secret = 'PRIVATE_TIMING';
|
|
const f = setup({ nativeFace: true, query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice,
|
|
nativeFrameSize: secret, nativeFaceInputSize: secret, nativeFaceMaxSide: -1, nativeFaceLandmarkModel: secret,
|
|
detector: { init: async () => ({ backend: secret, landmarkModel: secret, cpuOptimization: 'none' }), detect: async () => ({ ...nativeResult(), backend: secret, landmarkModel: secret, cpuOptimization: secret,
|
|
timings: { snapshotMs: secret, detectorMs: -1, landmarkMs: NaN, totalMs: 17 } }), dispose() {} } });
|
|
await flush(); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /cpuOptimization=none/);
|
|
await nativeFrame(f); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /cpuOptimization=not_reported/);
|
|
assert.equal(f.page.data.nativeFrameSize, 'medium'); assert.equal(f.engineOptions[0].inputSize, 256); assert.equal(f.engineOptions[0].maxSide, 320); assert.equal(f.engineOptions[0].landmarkModel, 'full');
|
|
assert.match(f.page.data.diagText, /timings.snapshotMs=not_reported/); assert.match(f.page.data.diagText, /timings.detectorMs=not_reported/);
|
|
assert.match(f.page.data.diagText, /timings.landmarkMs=not_reported/); assert.match(f.page.data.diagText, /timings.totalMs=17/);
|
|
assert.doesNotMatch(f.page.data.diagText, /PRIVATE_TIMING/); assert.deepEqual(isolated.calls, []);
|
|
});
|
|
|
|
function gpuSummary(overrides = {}) {
|
|
return { requested: true, selftest: 'passed', stage: 'ready', code: 'NONE', contextLost: false,
|
|
matmulDraws: 2, convDraws: 3, pixelReads: 4, selftestMs: 45, warmupMs: 80, nativeStrategy: 'standard', nativeAttempts: 1,
|
|
nativeInitialStage: 'not_run', nativeInitialCode: 'NONE', drawStrategy: 'indexed', quadStripDraws: 0, ...overrides };
|
|
}
|
|
function gpuInit(overrides = {}) { return { backend: 'webgl', inputSize: 160, maxSide: 320, landmarkModel: 'tiny', gpu: gpuSummary(), ...overrides }; }
|
|
function nativeGpuProbe(overrides = {}) {
|
|
return { stage: 'passed', code: 'NONE', clear: 'passed', constantArrays: 'passed', constantIndexed: 'passed', constantQuad: 'not_run', shader: 'passed', byteTexture: 'passed', floatTexture: 'passed',
|
|
changedBytes: 16, mismatchCount: 0, expectedChecksum: 2400, actualChecksum: 2400, glError: 0, framebufferStatus: 36053, readReturn: 'undefined',
|
|
vertexBufferBytes: 32, indexBufferBytes: 12, pixel0: 0, pixel1: 4294967295, pixel2: 123456, pixel3: 654321, ...overrides };
|
|
}
|
|
|
|
test('GPU startup diagnostics cache byte-readback profile and fixed tensor values without refreshing from later real detections or stats', async () => {
|
|
const synthetic = { actualLength: 256, nonFiniteCount: 0, mismatchCount: 0, maxAbsError: 0,
|
|
firstExpected: 0, firstActual: 0, expectedChecksum: 2805, actualChecksum: 2805 };
|
|
let started = false, getterReads = 0;
|
|
const initGPU = gpuSummary({ profile: 'packed_byte', attemptCount: 2, nativeStrategy: 'buffer_copy_framebuffer_y', nativeAttempts: 8, drawStrategy: 'quad_strip', quadStripDraws: 474,
|
|
nativeInitialStage: 'constant_arrays', nativeInitialCode: 'VALUE_MISMATCH',
|
|
floatReads: 0, byteReads: 5, glError: 0, framebufferStatus: 36053, nativeProbe: nativeGpuProbe({ constantIndexed: 'not_run', constantQuad: 'passed' }),
|
|
matmul: synthetic, conv: { ...synthetic, actualLength: 128, firstExpected: 10, firstActual: 10, expectedChecksum: 1920, actualChecksum: 1920 } });
|
|
const detectionGPU = gpuSummary({ profile: 'unpacked_byte', attemptCount: 3, matmulDraws: 8181, convDraws: 8181,
|
|
pixelReads: 8181, floatReads: 8181, byteReads: 8181, selftestMs: 8181, warmupMs: 8181, glError: 8181, framebufferStatus: 8181,
|
|
nativeStrategy: 'framebuffer_y', nativeAttempts: 3, nativeInitialStage: 'float_texture', nativeInitialCode: 'API_FAILED', drawStrategy: 'indexed', quadStripDraws: 8181,
|
|
nativeProbe: nativeGpuProbe({ changedBytes: 8181, actualChecksum: 8181, readReturn: 'PRIVATE_READ_RETURN',
|
|
constantArrays: 'PRIVATE_ARRAYS', constantIndexed: 'PRIVATE_INDEXED', constantQuad: 'PRIVATE_QUAD', vertexBufferBytes: 8181, indexBufferBytes: 8181, pixel0: 8181, pixel1: 8181, pixel2: 8181, pixel3: 8181 }),
|
|
matmul: { ...synthetic, firstActual: 8181, actualChecksum: 8181 }, conv: { ...synthetic, firstActual: 8181 } });
|
|
const lateStats = gpuSummary();
|
|
for (const key of ['profile', 'attemptCount', 'nativeStrategy', 'nativeAttempts', 'nativeInitialStage', 'nativeInitialCode', 'drawStrategy', 'quadStripDraws', 'matmulDraws', 'convDraws', 'pixelReads', 'floatReads', 'byteReads', 'selftestMs', 'warmupMs', 'matmul', 'conv', 'glError', 'framebufferStatus', 'nativeProbe'])
|
|
Object.defineProperty(lateStats, key, { get() { getterReads++; return 'PRIVATE_REAL_FRAME'; } });
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => gpuInit({ gpu: initGPU }), stats: () => ({ gpu: started ? lateStats : initGPU }),
|
|
detect: async () => ({ ...nativeResult(), ...gpuInit({ gpu: detectionGPU }) }), dispose() {}
|
|
} });
|
|
await flush(); started = true;
|
|
await nativeFrame(f); f.page.onCopyCaptureDiagnostics();
|
|
const report = f.page.data.diagText;
|
|
assert.match(report, /gpu.profile=packed_byte; attemptCount=2/);
|
|
assert.match(report, /gpu.nativeStrategy=buffer_copy_framebuffer_y; nativeAttempts=8/);
|
|
assert.match(report, /gpu.drawStrategy=quad_strip; quadStripDraws=474/);
|
|
assert.match(report, /gpu.nativeInitialStage=constant_arrays; nativeInitialCode=VALUE_MISMATCH/);
|
|
assert.match(report, /gpu.synthetic.floatReads=0; byteReads=5/);
|
|
assert.match(report, /gpu.matmulDraws=2; convDraws=3; pixelReads=4; selftestMs=45; warmupMs=80/);
|
|
assert.match(report, /gpu.synthetic.matmul.actualLength=256/);
|
|
assert.match(report, /gpu.synthetic.matmul.actualChecksum=2805/);
|
|
assert.match(report, /gpu.synthetic.conv.actualLength=128/);
|
|
assert.match(report, /gpu.synthetic.conv.firstActual=10/);
|
|
assert.match(report, /gpu.synthetic.glError=0; framebufferStatus=36053/);
|
|
assert.match(report, /gpu.native.stage=passed; code=NONE; clear=passed; shader=passed; byteTexture=passed; floatTexture=passed/);
|
|
assert.match(report, /gpu.native.changedBytes=16; mismatchCount=0; expectedChecksum=2400; actualChecksum=2400/);
|
|
assert.match(report, /gpu.native.glError=0; framebufferStatus=36053; readReturn=undefined/);
|
|
assert.match(report, /gpu.native.constantArrays=passed; constantIndexed=not_run; constantQuad=passed/);
|
|
assert.match(report, /gpu.native.vertexBufferBytes=32; indexBufferBytes=12/);
|
|
assert.match(report, /gpu.native.pixel0=0; pixel1=4294967295; pixel2=123456; pixel3=654321/);
|
|
assert.equal(getterReads, 0);
|
|
assert.doesNotMatch(report, /8181|PRIVATE_REAL_FRAME|PRIVATE_READ_RETURN|PRIVATE_ARRAYS|PRIVATE_INDEXED|PRIVATE_QUAD/);
|
|
});
|
|
|
|
test('GPU startup diagnostics reject invalid counters, unknown profiles and unsafe synthetic values without reading getters', async () => {
|
|
let getterReads = 0;
|
|
const metrics = { actualLength: -1, nonFiniteCount: 0.5, mismatchCount: Infinity, maxAbsError: -1,
|
|
firstExpected: NaN, firstActual: null, expectedChecksum: 'PRIVATE_CHECKSUM', actualChecksum: {} };
|
|
Object.defineProperty(metrics, 'firstExpected', { get() { getterReads++; return 'PRIVATE_GETTER'; } });
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => gpuInit({ gpu: gpuSummary({ profile: 'PRIVATE_PROFILE', attemptCount: 4,
|
|
floatReads: -1, byteReads: 2.5, matmul: metrics, conv: null }) }), detect: async () => nativeResult(), dispose() {}
|
|
} });
|
|
await flush(); f.page.onCopyCaptureDiagnostics();
|
|
const report = f.page.data.diagText;
|
|
assert.match(report, /gpu.profile=not_reported; attemptCount=not_reported/);
|
|
assert.match(report, /gpu.synthetic.floatReads=not_reported; byteReads=not_reported/);
|
|
for (const key of ['actualLength', 'nonFiniteCount', 'mismatchCount', 'maxAbsError', 'firstExpected', 'expectedChecksum', 'actualChecksum'])
|
|
assert.match(report, new RegExp('gpu\\.synthetic\\.matmul\\.' + key + '=not_reported'));
|
|
assert.match(report, /gpu.synthetic.matmul.firstActual=null/);
|
|
assert.equal(getterReads, 0); assert.doesNotMatch(report, /PRIVATE_|\[object Object\]/);
|
|
});
|
|
|
|
test('native GPU preflight failure preserves the fixed-input failure stage and never starts real camera frames', async () => {
|
|
let disposed = 0;
|
|
const failedProbe = nativeGpuProbe({ stage: 'clear', code: 'READ_UNCHANGED', clear: 'not_run', shader: 'not_run', byteTexture: 'not_run', floatTexture: 'not_run',
|
|
changedBytes: 0, mismatchCount: 16, expectedChecksum: 2400, actualChecksum: 2640, readReturn: 'typedarray' });
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => { const error = Error('PRIVATE_NATIVE_PREFLIGHT'); error.code = 'NATIVE_FACE_WEBGL_PREFLIGHT_FAILED';
|
|
error.gpu = gpuSummary({ selftest: 'failed', stage: 'native_preflight', code: 'PREFLIGHT_FAILED', nativeProbe: failedProbe, glError: 1282, framebufferStatus: 36054 }); throw error; },
|
|
detect() { throw Error('preflight must stop before real frame detection'); }, dispose() { disposed++; }
|
|
} });
|
|
await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText;
|
|
assert.equal(f.nativeCameras[0].starts, 0); assert.equal(f.page.data.cameraEnabled, false); assert.equal(disposed, 1);
|
|
assert.match(report, /stage=failed; failureStage=init/);
|
|
assert.match(report, /quality.code=NATIVE_FACE_WEBGL_PREFLIGHT_FAILED/);
|
|
assert.match(report, /selftest=failed; stage=native_preflight; code=PREFLIGHT_FAILED/);
|
|
assert.match(report, /gpu.native.stage=clear; code=READ_UNCHANGED; clear=not_run; shader=not_run; byteTexture=not_run; floatTexture=not_run/);
|
|
assert.match(report, /gpu.native.changedBytes=0; mismatchCount=16; expectedChecksum=2400; actualChecksum=2640/);
|
|
assert.match(report, /gpu.synthetic.glError=1282; framebufferStatus=36054/);
|
|
assert.match(report, /framesProcessed=0/); assert.match(report, /autoCapturedPhotoCount=0/);
|
|
assert.doesNotMatch(report, /PRIVATE_NATIVE_PREFLIGHT/);
|
|
});
|
|
|
|
test('native GPU diagnostics whitelist startup enums and finite integer metadata without evaluating getters', async () => {
|
|
let getters = 0;
|
|
const probe = nativeGpuProbe({ stage: 'PRIVATE_STAGE', code: 'PRIVATE_CODE', clear: {}, constantArrays: 'PRIVATE_ARRAYS', constantIndexed: {}, shader: 'wrong', byteTexture: 'PRIVATE_TEXTURE', floatTexture: null,
|
|
changedBytes: -1, mismatchCount: 0.5, expectedChecksum: Infinity, actualChecksum: NaN, glError: 'PRIVATE_ERROR', framebufferStatus: {}, readReturn: 'PRIVATE_RETURN',
|
|
vertexBufferBytes: -1, indexBufferBytes: 12.5, pixel0: -1, pixel1: 1.5, pixel2: Infinity, pixel3: 4294967296 });
|
|
Object.defineProperty(probe, 'changedBytes', { get() { getters++; return 'PRIVATE_GETTER'; } });
|
|
Object.defineProperty(probe, 'pixels', { get() { getters++; return 'PRIVATE_PIXELS'; } });
|
|
Object.defineProperty(probe, 'vertexBufferBytes', { get() { getters++; return 'PRIVATE_BUFFER'; } });
|
|
Object.defineProperty(probe, 'pixel0', { get() { getters++; return 'PRIVATE_PIXEL'; } });
|
|
const gpu = gpuSummary({ nativeProbe: probe, glError: -1, framebufferStatus: 36053.5, nativeStrategy: 'PRIVATE_STRATEGY', nativeAttempts: 9,
|
|
nativeInitialStage: 'PRIVATE_INITIAL_STAGE', nativeInitialCode: {}, drawStrategy: 'PRIVATE_DRAW', quadStripDraws: 0.5 });
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => gpuInit({ gpu }), detect: async () => ({ ...nativeResult(), ...gpuInit() }), dispose() {}
|
|
} });
|
|
await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText;
|
|
assert.match(report, /gpu.synthetic.glError=not_reported; framebufferStatus=not_reported/);
|
|
assert.match(report, /gpu.native.stage=not_reported; code=not_reported; clear=not_reported; shader=not_reported; byteTexture=not_reported; floatTexture=not_reported/);
|
|
assert.match(report, /gpu.native.changedBytes=not_reported; mismatchCount=not_reported; expectedChecksum=not_reported; actualChecksum=not_reported/);
|
|
assert.match(report, /gpu.native.glError=not_reported; framebufferStatus=not_reported; readReturn=not_reported/);
|
|
assert.match(report, /gpu.nativeStrategy=not_reported; nativeAttempts=not_reported/);
|
|
assert.match(report, /gpu.drawStrategy=not_reported; quadStripDraws=not_reported/);
|
|
assert.match(report, /gpu.nativeInitialStage=not_reported; nativeInitialCode=not_reported/);
|
|
assert.match(report, /gpu.native.constantArrays=not_reported; constantIndexed=not_reported/);
|
|
assert.match(report, /gpu.native.vertexBufferBytes=not_reported; indexBufferBytes=not_reported/);
|
|
assert.match(report, /gpu.native.pixel0=not_reported; pixel1=not_reported; pixel2=not_reported; pixel3=not_reported/);
|
|
assert.equal(getters, 0); assert.doesNotMatch(report, /PRIVATE_|\[object Object\]/);
|
|
});
|
|
|
|
test('constant array and indexed draw failures preserve startup stages and fixed synthetic pixels without starting the real listener', async t => {
|
|
for (const stage of ['constant_arrays', 'constant_indexed']) await t.test(stage, async () => {
|
|
const failedProbe = nativeGpuProbe({ stage, code: 'VALUE_MISMATCH', constantArrays: stage === 'constant_arrays' ? 'not_run' : 'passed', constantIndexed: 'not_run', shader: 'not_run',
|
|
byteTexture: 'not_run', floatTexture: 'not_run', actualChecksum: 1020 });
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => { const error = Error('PRIVATE_DRAW_EXCEPTION'); error.code = 'NATIVE_FACE_WEBGL_PREFLIGHT_FAILED';
|
|
error.gpu = gpuSummary({ selftest: 'failed', stage: 'native_preflight', code: 'PREFLIGHT_FAILED', nativeStrategy: 'buffer_copy', nativeAttempts: 2, nativeProbe: failedProbe }); throw error; },
|
|
detect() { throw Error('failed synthetic draw cannot start actual model detection'); }, dispose() {}
|
|
} });
|
|
await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText;
|
|
assert.match(report, new RegExp('gpu\\.native\\.stage=' + stage + '; code=VALUE_MISMATCH'));
|
|
assert.match(report, /gpu.nativeStrategy=buffer_copy; nativeAttempts=2/);
|
|
assert.match(report, /gpu.native.constantArrays=(?:not_run|passed); constantIndexed=not_run/);
|
|
assert.match(report, /gpu.native.vertexBufferBytes=32; indexBufferBytes=12/);
|
|
assert.match(report, /gpu.native.pixel0=0; pixel1=4294967295; pixel2=123456; pixel3=654321/);
|
|
assert.equal(f.nativeCameras[0].starts, 0); assert.match(report, /framesProcessed=0/);
|
|
assert.doesNotMatch(report, /PRIVATE_DRAW_EXCEPTION/);
|
|
});
|
|
});
|
|
|
|
test('native strategy startup getters and noninteger attempts remain withheld from copied diagnostics', async () => {
|
|
let getters = 0;
|
|
const gpu = gpuSummary({ nativeAttempts: 1.5 });
|
|
Object.defineProperty(gpu, 'nativeStrategy', { get() { getters++; return 'PRIVATE_NATIVE_STRATEGY'; } });
|
|
Object.defineProperty(gpu, 'nativeInitialStage', { get() { getters++; return 'PRIVATE_INITIAL_STAGE'; } });
|
|
Object.defineProperty(gpu, 'nativeInitialCode', { get() { getters++; return 'PRIVATE_INITIAL_CODE'; } });
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => gpuInit({ gpu }), detect: async () => nativeResult(), dispose() {}
|
|
} });
|
|
await flush(); f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, /gpu.nativeStrategy=not_reported; nativeAttempts=not_reported/);
|
|
assert.match(f.page.data.diagText, /gpu.nativeInitialStage=not_reported; nativeInitialCode=not_reported/);
|
|
assert.equal(getters, 0); assert.doesNotMatch(f.page.data.diagText, /PRIVATE_/);
|
|
});
|
|
|
|
test('native API exceptions expose only the fixed preflight code without copying native exception text', async () => {
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => { const error = Error('PRIVATE_NATIVE_EXCEPTION'); error.code = 'NATIVE_FACE_WEBGL_PREFLIGHT_FAILED';
|
|
error.gpu = gpuSummary({ selftest: 'failed', stage: 'native_preflight', code: 'PREFLIGHT_FAILED',
|
|
nativeProbe: nativeGpuProbe({ stage: 'byte_texture', code: 'API_FAILED', byteTexture: 'not_run', floatTexture: 'not_run' }) }); throw error; },
|
|
detect() { throw Error('failed native preflight cannot detect'); }, dispose() {}
|
|
} });
|
|
await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText;
|
|
assert.match(report, /gpu.native.stage=byte_texture; code=API_FAILED/);
|
|
assert.equal(f.nativeCameras[0].starts, 0); assert.doesNotMatch(report, /PRIVATE_NATIVE_EXCEPTION/);
|
|
});
|
|
|
|
test('WebGL initialization waits for numerical selftests and model warmup, exposing only safe GPU progress before any real-frame listener', async () => {
|
|
let finishInit, ready = false;
|
|
const isolated = probeIsolation();
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice,
|
|
detector: { init: () => new Promise(resolve => { finishInit = resolve; }), detect: async () => ({ ...nativeResult(), ...gpuInit() }), dispose() {},
|
|
stats: () => ({ backend: ready ? 'webgl' : '', gpu: ready ? gpuSummary() : gpuSummary({ selftest: 'running', stage: 'warmup', code: 'NONE', matmulDraws: 2, convDraws: 3, pixelReads: 4 }) }) } });
|
|
await flush();
|
|
assert.equal(f.engineOptions[0].backend, 'webgl'); assert.equal(f.engineOptions[0].platform, f.platform);
|
|
assert.match(f.page.data.liveHint, /校验图形加速并预热模型/);
|
|
assert.equal(f.page.data.captureProbeVersion, 'LOCAL-FACE-20261102-09');
|
|
assert.equal(f.nativeCameras[0].starts, 0); assert.equal(f.nativeCameras[0].callback, undefined);
|
|
f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, /engine.requested.backend=webgl/); assert.match(f.page.data.diagText, /selftest=running; stage=warmup/);
|
|
assert.match(f.page.data.diagText, /gpu.matmulDraws=2; convDraws=3; pixelReads=4/); assert.match(f.page.data.diagText, /framesProcessed=0/);
|
|
ready = true; finishInit(gpuInit()); await flush();
|
|
assert.equal(f.nativeCameras[0].starts, 1); f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, /engine.actual.backend=webgl/); assert.match(f.page.data.diagText, /selftest=passed; stage=ready/);
|
|
assert.deepEqual(isolated.calls, []);
|
|
});
|
|
|
|
test('required WebGL rejects silent CPU fallback, incomplete numerical proofs and private GPU failure fields', async t => {
|
|
for (const kind of ['cpu-fallback', 'missing-readback', 'selftest-failed']) await t.test(kind, async () => {
|
|
let disposed = 0, getterReads = 0;
|
|
const failedGPU = gpuSummary({ selftest: 'failed', stage: 'readback', code: 'READBACK_FAILED', private: 'PRIVATE_GPU_ERROR' });
|
|
Object.defineProperty(failedGPU, 'warmupMs', { get() { getterReads++; return 'PRIVATE_GETTER'; } });
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => {
|
|
if (kind === 'selftest-failed') { const error = Error('PRIVATE_NATIVE_GL_ERROR'); error.code = 'NATIVE_FACE_WEBGL_READBACK_FAILED'; error.gpu = failedGPU; throw error; }
|
|
return kind === 'cpu-fallback' ? gpuInit({ backend: 'cpu' }) : gpuInit({ gpu: gpuSummary({ pixelReads: 0 }) });
|
|
}, detect() { throw Error('GPU failure must not run a frame'); }, dispose() { disposed++; }
|
|
} });
|
|
await flush(); assert.equal(f.nativeCameras[0].starts, 0); assert.equal(f.page.data.cameraEnabled, false);
|
|
assert.equal(disposed, 1); assert.equal(f.page.nativeInitPromise, null); f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, /stage=failed; failureStage=init/);
|
|
assert.match(f.page.data.diagText, /quality.code=NATIVE_FACE_WEBGL_(?:BACKEND|READBACK)_FAILED/);
|
|
assert.doesNotMatch(f.page.data.diagText, /PRIVATE_|getParameter|shaderSource/); assert.equal(getterReads, 0);
|
|
});
|
|
});
|
|
|
|
test('unverified strip geometry is diagnosed as a guarded drawing refusal before actual camera frames start', async () => {
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: {
|
|
init: async () => { const error = Error('PRIVATE_INDEX_BUFFER'); error.code = 'NATIVE_FACE_WEBGL_DRAW_UNSUPPORTED';
|
|
error.gpu = gpuSummary({ code: 'DRAW_UNSUPPORTED', stage: 'matmul', selftest: 'failed', drawStrategy: 'quad_strip', quadStripDraws: 6,
|
|
nativeProbe: nativeGpuProbe({ constantIndexed: 'not_run', constantQuad: 'passed' }) }); throw error; },
|
|
detect() { throw Error('guard refusal must stop before real frames'); }, dispose() {}
|
|
} });
|
|
await flush(); assert.equal(f.nativeCameras[0].starts, 0); f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, /quality.code=NATIVE_FACE_WEBGL_DRAW_UNSUPPORTED/);
|
|
assert.match(f.page.data.diagText, /gpu.drawStrategy=quad_strip; quadStripDraws=6/);
|
|
assert.match(f.page.data.diagText, /constantIndexed=not_run; constantQuad=passed/);
|
|
assert.doesNotMatch(f.page.data.diagText, /PRIVATE_INDEX_BUFFER/);
|
|
});
|
|
|
|
test('WebGL context loss while backgrounded retires the resolved engine and rebuilds only after its pending detection settles', async () => {
|
|
const engines = []; let loseContext;
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detectorFactory: options => {
|
|
const record = { options, disposed: 0, detects: 0 }; engines.push(record);
|
|
return { init: async () => gpuInit(), dispose() { record.disposed++; }, detect: () => {
|
|
record.detects++;
|
|
return engines.length === 1 ? new Promise((resolve, reject) => { loseContext = reject; }) : Promise.resolve({ ...nativeResult(), ...gpuInit() });
|
|
} };
|
|
} });
|
|
await flush(); await nativeFrame(f); assert.equal(engines[0].detects, 1);
|
|
const oldCallback = f.nativeCameras[0].callback;
|
|
f.page.onHide(); f.page.onShow(); await flush();
|
|
assert.equal(engines.length, 1); assert.equal(f.nativeCameras[1].starts, 0, 'foreground must not restart a listener on an engine still checking an old frame');
|
|
const error = Error('PRIVATE_CONTEXT_LOST'); error.code = 'NATIVE_FACE_WEBGL_CONTEXT_LOST';
|
|
error.gpu = gpuSummary({ selftest: 'failed', stage: 'context_lost', code: 'CONTEXT_LOST', contextLost: true });
|
|
loseContext(error); await flush(); await flush();
|
|
assert.equal(engines[0].disposed, 1); assert.equal(engines.length, 2); assert.equal(engines[1].options.backend, 'webgl');
|
|
assert.equal(f.nativeCameras[1].starts, 1); assert.equal(f.page.data.cameraEnabled, true);
|
|
f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, /selftest=passed; stage=ready; code=NONE; contextLost=false/);
|
|
assert.doesNotMatch(f.page.data.diagText, /PRIVATE_CONTEXT_LOST|NATIVE_FACE_WEBGL_CONTEXT_LOST/);
|
|
await flush(); assert.equal(engines.length, 2, 'a stale record cannot schedule an extra rebuild or overwrite the new diagnostics');
|
|
oldCallback({ width: 400, height: 700, data: new ArrayBuffer(16) }); assert.equal(engines[1].detects, 0);
|
|
f.setClock(1600); await nativeFrame(f); assert.equal(engines[1].detects, 1); assert.equal(f.page.latestNativeVerdict.ok, true);
|
|
f.page.onUnload(); assert.equal(engines[1].disposed, 1);
|
|
});
|
|
|
|
test('an initialization timeout remains diagnosable and does not build a parallel GPU engine while old init is pending', async () => {
|
|
const engines = []; let finishOldInit;
|
|
const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detectorFactory: () => {
|
|
const record = { disposed: 0 }; engines.push(record);
|
|
return { init: () => engines.length === 1 ? new Promise(resolve => { finishOldInit = resolve; }) : Promise.resolve(gpuInit()),
|
|
stats: () => ({ gpu: gpuSummary({ selftest: 'running', stage: 'warmup' }) }), detect: async () => ({ ...nativeResult(), ...gpuInit() }), dispose() { record.disposed++; } };
|
|
} });
|
|
await flush(); f.timer(30000);
|
|
assert.equal(f.page.data.cameraEnabled, false); assert.equal(engines[0].disposed, 1); f.page.onCopyCaptureDiagnostics();
|
|
assert.match(f.page.data.diagText, /quality.code=NATIVE_FACE_INIT_TIMEOUT/); assert.match(f.page.data.diagText, /selftest=running; stage=warmup/);
|
|
f.page.onEnableCamera(); await flush(); assert.equal(engines.length, 1); assert.equal(f.nativeCameras[1].starts, 0);
|
|
finishOldInit(gpuInit()); await flush(); await flush();
|
|
assert.equal(engines.length, 2); assert.equal(f.nativeCameras[1].starts, 1);
|
|
assert.equal(f.page.data.countdown, 0); assert.equal(f.page.photos.length, 0);
|
|
});
|