461 lines
32 KiB
JavaScript
461 lines
32 KiB
JavaScript
'use strict';
|
|
const { test } = require('node:test'), assert = require('node:assert/strict');
|
|
const AR = require('../utils/ar-camera'), HeadPose = require('../../h5/js/head-pose'), Quality = require('../../h5/js/capture-quality');
|
|
|
|
// Independent semantic landmark locations from the official 106-point diagram.
|
|
// Other landmarks do not participate in H5's pure head-pose calculation.
|
|
function frame(noseX = 200) {
|
|
const landmarks = Array.from({ length: 212 }, (_, index) => index % 2 ? 250 : 200);
|
|
for (const [index, x, y] of [[52, 120, 150], [61, 280, 150], [46, noseX, 230], [49, 200, 261.5], [35, 150, 130], [40, 250, 130], [16, 200, 400]]) {
|
|
landmarks[index * 2] = x; landmarks[index * 2 + 1] = y;
|
|
}
|
|
return { width: 400, height: 700, anchors: [{ rect: [70, 90, 330, 440], landmarks, yaw: 999, pitch: -999, roll: 999 }] };
|
|
}
|
|
test('official 106-point semantic locations map to the unchanged H5 68-point geometry', () => {
|
|
const input = Array.from({ length: 212 }, (_, index) => index);
|
|
const points = AR.points68(input);
|
|
assert.equal(points.length, 68);
|
|
for (const [h5, native] of [[0, 0], [8, 16], [16, 32], [19, 35], [24, 40], [30, 46], [33, 49], [36, 52], [45, 61], [48, 84], [67, 103]]) {
|
|
assert.deepEqual(points[h5], { x: native * 2, y: native * 2 + 1 });
|
|
}
|
|
assert.equal(AR.points68(input.slice(0, 211)), null);
|
|
input[92] = NaN;
|
|
assert.equal(AR.points68(input), null);
|
|
assert.deepEqual(AR.poseConfig, HeadPose.config);
|
|
assert.deepEqual(AR.config, Quality.config);
|
|
});
|
|
test('AR gates use geometric pose, not undocumented native Euler units; front/right/left semantics match H5', () => {
|
|
const front = AR.evaluate({ detail: frame() }, 'front', false);
|
|
assert.equal(front.ok, true); assert.equal(front.observation.yaw, 0);
|
|
assert.equal(front.observation.luma, null, 'native frames with no luminance do not fail the advisory lighting gate');
|
|
assert.equal(AR.evaluate(frame(260), 'right', false).ok, true);
|
|
assert.equal(AR.evaluate(frame(140), 'left', false).ok, true);
|
|
assert.equal(AR.evaluate(frame(260), 'left', false).ok, false);
|
|
assert.equal(AR.evaluate(frame(260), 'left', true).ok, true, 'known frame mirroring remains explicit');
|
|
assert.equal(front.frameReceived, true); assert.equal(front.faceCount, 1);
|
|
assert(!('anchors' in front) && !('detections' in front), 'UI verdicts never carry a frame bitmap or landmarks');
|
|
});
|
|
test('missing frames, malformed anchors, multiple faces and framing errors never trigger an automatic capture', () => {
|
|
assert.equal(AR.evaluate({ detail: {} }, 'front', false).frameReceived, false);
|
|
assert.equal(AR.evaluate({ ...frame(), anchors: [] }, 'front', false).ok, false);
|
|
const multiple = frame(); multiple.anchors.push(frame().anchors[0]);
|
|
assert.equal(AR.evaluate(multiple, 'front', false).hint, '请保持单人出镜');
|
|
const malformed = frame(); malformed.anchors[0].landmarks = [];
|
|
assert.equal(AR.evaluate(malformed, 'front', false).ok, false);
|
|
const edge = frame(); edge.anchors[0].rect = [-1, 90, 259, 440];
|
|
assert.equal(AR.evaluate(edge, 'front', false).ok, false);
|
|
const distant = frame(); distant.anchors[0].rect = [150, 220, 250, 320];
|
|
assert.equal(AR.evaluate(distant, 'front', false).hint, '请靠近一点');
|
|
const cropped = frame(); cropped.anchors[0].rect = [140, 90, 400, 440];
|
|
assert.equal(AR.evaluate(cropped, 'front', false, { viewWidth: 375, viewHeight: 726 }).ok, false);
|
|
});
|
|
test('automatic capture requires H5 stability duration and stops across a missed frame', () => {
|
|
const observation = AR.evaluate(frame(), 'front', false).observation;
|
|
let held = AR.hold(null, observation, 1000);
|
|
assert.equal(held.ready, false);
|
|
held = AR.hold(held.state, observation, 1300); assert.equal(held.ready, false);
|
|
held = AR.hold(held.state, observation, 1450); assert.equal(held.ready, true);
|
|
assert.equal(AR.hold(held.state, null, 1500).state, null);
|
|
assert.equal(AR.hold(held.state, { ...observation, yaw: 10 }, 1600).ready, false);
|
|
});
|
|
test('capability signals never claim verified tracking, and diagnostics expose buffer structure without biometric bytes', () => {
|
|
const claimed = AR.capability({ canIUse: () => true, createArCameraContext: () => ({}) });
|
|
assert.equal(claimed.frameVerified, false); assert.equal(claimed.componentSignal, 'yes');
|
|
assert.throws(() => AR.context({}, 'camera'), /未提供/);
|
|
const marker = {};
|
|
assert.equal(AR.context({ createArCameraContext: id => { assert.equal(id, 'camera'); return marker; } }, 'camera'), marker);
|
|
const source = frame(); source.someNativeBuffer = new Uint8Array([13, 57, 231, 88]);
|
|
const diagnostics = AR.inspect({ detail: source }).join('\n');
|
|
assert.match(diagnostics, /someNativeBuffer: Uint8Array\(4 bytes\)/);
|
|
assert(!diagnostics.includes('13,57') && !diagnostics.includes('landmarks'));
|
|
});
|
|
|
|
function probePage({ ensure = async () => {}, capture = async () => 'data:image/jpeg;base64,/9j/', commit = callback => callback(), platform = {}, now = () => Date.now(), ar = AR, imageHelpers = {}, arPixelFormat = 'RGBA', arFrontPosition = 'front' } = {}) {
|
|
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
|
let definition;
|
|
const timers = [], contextIds = [], copies = [], navigations = [], updates = [];
|
|
let confirmedFormat = '';
|
|
const imageApi = Object.assign({ captureFrameToBase64: capture,
|
|
freezeCalibrationFrame: value => structuredClone(value),
|
|
frameCalibrationFormats: () => ['RGBA', 'BGRA', 'ARGB', 'ABGR'],
|
|
confirmFramePixelFormat: (value, format) => { confirmedFormat = format; return format; },
|
|
confirmedFramePixelFormat: () => confirmedFormat
|
|
}, imageHelpers);
|
|
const host = {
|
|
canIUse: () => true,
|
|
createArCameraContext: id => { contextIds.push(id); return { faceBeauty(options) { assert.equal(options.level, 0); } }; },
|
|
setClipboard: options => { copies.push(options.text); options.success(); },
|
|
redirectTo: options => { navigations.push(options.url); }
|
|
};
|
|
Object.defineProperties(host, Object.getOwnPropertyDescriptors(platform));
|
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../pages/camtest/camtest.js'), 'utf8'), {
|
|
Page: value => { definition = value; }, Date: { now }, Promise, JSON,
|
|
setTimeout: fn => { timers.push(fn); return timers.length; }, clearTimeout: id => { timers[id - 1] = null; },
|
|
my: host,
|
|
require: name => {
|
|
if (name === '../../utils/ar-camera') return ar;
|
|
if (name === '../../utils/permissions') return { ensure };
|
|
if (name === '../../config') return { arPixelFormat, arFrontPosition };
|
|
if (name === '../../utils/image') return imageApi;
|
|
throw Error('Probe must not access accounts, uploads or external services: ' + name);
|
|
}
|
|
});
|
|
return { timers, contextIds, copies, navigations, updates, page: Object.assign({}, definition, { data: structuredClone(definition.data), setData(patch, callback) { updates.push(patch); Object.assign(this.data, patch); if (callback) commit(callback); } }) };
|
|
}
|
|
|
|
test('AR diagnostic starts with the host-specific selfie request and flips its native request value', async () => {
|
|
const { page: p } = probePage({ arFrontPosition: 'back' });
|
|
p.onLoad(); assert.equal(p.data.devicePosition, 'back'); assert.equal(p.data.running, false);
|
|
await p.onStart(); assert.equal(p.data.devicePosition, 'back'); assert.equal(p.data.running, true);
|
|
await p.onFlip(); assert.equal(p.data.devicePosition, 'front');
|
|
assert.match(p.diagnosticReport(), /requestedPosition=front/);
|
|
});
|
|
test('device probe needs explicit camera permission and real frames; stale exports cannot restore a hidden face preview', async () => {
|
|
const scopes = []; let finishExport;
|
|
const { page: p } = probePage({ ensure: async scope => { scopes.push(scope); }, capture: () => new Promise(resolve => { finishExport = resolve; }) });
|
|
p.onLoad(); assert.equal(scopes.length, 0); assert.equal(p.data.running, false);
|
|
await p.onSnapshot(); assert.match(p.data.errorText, /尚未收到真实相机帧/);
|
|
await p.onStart(); assert.deepEqual(scopes, ['scope.camera']);
|
|
p.onARInit(); assert.equal(p.data.frameCount, 0, 'component init cannot pass a live-frame check');
|
|
p.onARFrame({ detail: frame() }); assert.equal(p.data.frameCount, 1);
|
|
assert.match(p.data.status, /仍需点击导出照片/);
|
|
const exporting = p.onSnapshot();
|
|
p.onHide(); finishExport('data:image/jpeg;base64,/9j/'); await exporting;
|
|
assert.equal(p.data.running, false); assert.equal(p.data.snapshotUrl, '');
|
|
assert.equal(p.lastFrame, null); assert.equal(p.data.fieldText, '');
|
|
p.onARFrame({ detail: frame() }); assert.equal(p.lastFrame, null, 'late frame callbacks cannot revive a hidden probe');
|
|
});
|
|
test('device probe reports permission, missing-frame and unsupported-export failures without claiming live capture passed', async () => {
|
|
const denied = probePage({ ensure: async () => { throw Error('permission denied'); } }).page;
|
|
denied.onLoad(); await denied.onStart(); assert.equal(denied.data.running, false); assert.equal(denied.data.errorText, 'permission denied');
|
|
const { page: p, timers } = probePage({ capture: async () => { throw Error('unknown image format'); } });
|
|
p.onLoad(); await p.onStart(); timers.find(Boolean)();
|
|
assert.match(p.data.status, /未收到帧事件/);
|
|
p.onARFrame({ detail: frame() }); await p.onSnapshot();
|
|
assert.equal(p.data.snapshotUrl, ''); assert.equal(p.data.errorText, 'unknown image format'); assert.equal(p.data.busy, false);
|
|
const circular = {}; circular.circular = circular;
|
|
p.onError({ detail: circular }); assert.match(p.data.errorText, /AR 错误事件/); assert.equal(p.data.running, false);
|
|
});
|
|
|
|
test('device probe requests front first and remounts on flip without accepting old native frames or exports', async () => {
|
|
let finishExport;
|
|
const { page: p, contextIds } = probePage({ capture: () => new Promise(resolve => { finishExport = resolve; }) });
|
|
const fs = require('node:fs'), path = require('node:path');
|
|
const markup = fs.readFileSync(path.join(__dirname, '../pages/camtest/camtest.axml'), 'utf8');
|
|
assert.match(markup, /devicePosition="\{\{devicePosition\}\}"/);
|
|
assert.match(markup, /data-session="\{\{cameraSession\}\}"/);
|
|
p.onLoad(); await p.onStart();
|
|
assert.equal(p.data.devicePosition, 'front'); assert.match(p.data.status, /请求前置/);
|
|
const original = p.data.cameraSession;
|
|
const event = session => ({ currentTarget: { id: 'test-ar-camera-' + session, dataset: { session: String(session) } }, detail: frame(260) });
|
|
p.setData({ target: 'right' });
|
|
p.onARInit(event(original)); p.onARFrame(event(original));
|
|
assert.match(p.data.status, /判断通过/, 'front hardware requests do not imply mirrored native landmark coordinates');
|
|
const exporting = p.onSnapshot();
|
|
await p.onFlip();
|
|
assert.equal(p.data.devicePosition, 'back'); assert.match(p.data.status, /请求后置/);
|
|
assert.notEqual(p.data.cameraSession, original); assert.equal(p.data.frameCount, 0);
|
|
assert.equal(p.lastFrame, null); assert.equal(p.data.poseText, ''); assert.equal(p.data.fieldText, '');
|
|
assert.equal(p.data.snapshotUrl, ''); assert.equal(p.data.busy, false);
|
|
p.onARFrame(event(original)); p.onARInit(event(original)); p.onARStop(event(original));
|
|
p.onError({ ...event(original), detail: { message: 'old camera error' } });
|
|
p.onARFrame({ currentTarget: { id: 'test-ar-camera-' + original }, detail: frame() });
|
|
assert.equal(p.data.running, true); assert.equal(p.data.frameCount, 0); assert.equal(p.data.errorText, '');
|
|
assert.deepEqual(contextIds, ['test-ar-camera-' + original]);
|
|
finishExport('data:image/jpeg;base64,old'); await exporting;
|
|
assert.equal(p.data.snapshotUrl, '', 'old export cannot reappear after a facing change');
|
|
p.onARInit(event(p.data.cameraSession)); p.onARFrame(event(p.data.cameraSession));
|
|
assert.equal(p.data.frameCount, 1); assert.match(p.data.status, /判断通过/);
|
|
assert.equal(contextIds[1], 'test-ar-camera-' + p.data.cameraSession);
|
|
await p.onFlip(); assert.equal(p.data.devicePosition, 'front');
|
|
assert.equal(p.lastFrame, null); assert.equal(p.data.frameCount, 0);
|
|
});
|
|
|
|
test('device probe cannot remount after hiding while a facing-change render commit is pending', async () => {
|
|
const commits = [];
|
|
const { page: p } = probePage({ commit: callback => commits.push(callback) });
|
|
p.onLoad(); await p.onStart();
|
|
const flipping = p.onFlip();
|
|
assert.equal(p.data.running, false); assert.equal(commits.length, 1);
|
|
p.onHide(); commits.shift()(); await flipping;
|
|
assert.equal(p.data.running, false); assert.equal(p.data.busy, false); assert.equal(p.lastFrame, null);
|
|
});
|
|
|
|
test('facing changes and restarts retain the shared-canvas export lock until the previous image operation settles', async () => {
|
|
const pending = []; let active = 0, peak = 0, calls = 0;
|
|
const { page: p } = probePage({ capture: () => {
|
|
calls++; active++; peak = Math.max(peak, active);
|
|
return new Promise(resolve => pending.push(value => { active--; resolve(value); }));
|
|
} });
|
|
p.onLoad(); await p.onStart(); p.onARFrame({ detail: frame() });
|
|
const oldExport = p.onSnapshot();
|
|
assert.equal(calls, 1); assert.equal(p.data.exporting, true);
|
|
await p.onFlip(); p.onARFrame({ detail: frame() });
|
|
await p.onSnapshot();
|
|
assert.equal(calls, 1, 'new camera frames cannot start a concurrent draw on the shared canvas');
|
|
assert.match(p.data.errorText, /仍在导出/); assert.equal(p.data.exporting, true);
|
|
p.onStop(); assert.equal(p.data.exporting, true, 'stopping a camera cannot cancel an image callback already in flight');
|
|
await p.onStart(); p.onError({ detail: { message: 'current camera failure' } });
|
|
assert.equal(p.data.exporting, true, 'a camera error cannot release the canvas lock');
|
|
await p.onStart(); p.onARFrame({ detail: frame() });
|
|
await p.onSnapshot(); assert.equal(calls, 1);
|
|
pending.shift()('data:image/jpeg;base64,stale'); await oldExport;
|
|
assert.equal(p.data.exporting, false); assert.equal(p.data.snapshotUrl, '');
|
|
const freshExport = p.onSnapshot(); assert.equal(calls, 2); assert.equal(peak, 1);
|
|
pending.shift()('data:image/jpeg;base64,current'); await freshExport;
|
|
assert.equal(p.data.snapshotUrl, 'data:image/jpeg;base64,current'); assert.equal(p.data.exporting, false);
|
|
});
|
|
|
|
test('diagnostics enumerate public data-descriptor methods without executing getters or native actions', () => {
|
|
let getters = 0, actions = 0;
|
|
const proto = { startTrack() { actions++; }, shadowed() { actions++; } };
|
|
const context = Object.create(proto);
|
|
Object.defineProperties(context, {
|
|
faceBeauty: { get() { getters++; throw Error('native getter must not execute'); } },
|
|
shadowed: { get() { getters++; return () => { actions++; }; } },
|
|
switchCamera: { value() { actions++; }, enumerable: false },
|
|
_privateMethod: { value() { actions++; } },
|
|
deviceId: { value: 'private-device-marker' }
|
|
});
|
|
assert.deepEqual(AR.diagnostics.methods(context), ['startTrack', 'switchCamera']);
|
|
const { page: p } = probePage({ platform: { createArCameraContext: () => context } });
|
|
p.onLoad(); p.setData({ running: true }); p.onARInit({ type: 'init', detail: {} });
|
|
assert.equal(p.data.contextText, 'startTrack, switchCamera');
|
|
assert.equal(getters, 0); assert.equal(actions, 0);
|
|
});
|
|
|
|
test('event and version diagnostics whitelist scalar metadata and never read image, identity or landmark getters', () => {
|
|
let getters = 0;
|
|
const secret = 'do-not-export-this-identity-or-pixel';
|
|
const detail = { width: 400, height: 700, cameraPosition: 'back', pixelFormat: 'RGBA', anchors: [{ landmarks: [secret] }], imageData: secret, openId: secret };
|
|
Object.defineProperties(detail, {
|
|
frame: { get() { getters++; return { width: 600 }; } },
|
|
cameraInfo: { get() { getters++; return { devicePosition: 'front' }; } }
|
|
});
|
|
const event = { type: 'arFrame', detail, token: secret };
|
|
const diagnostics = JSON.stringify(AR.diagnostics.event(event));
|
|
assert.match(diagnostics, /cameraPosition=back/); assert.match(diagnostics, /anchors.count=1/);
|
|
assert(!diagnostics.includes(secret) && !diagnostics.includes('landmarks') && !diagnostics.includes('imageData') && !diagnostics.includes('openId'));
|
|
const info = { model: 'Test phone', system: 'Android 15', version: '10.45.0', deviceId: secret, userId: secret };
|
|
Object.defineProperty(info, 'appVersion', { get() { getters++; return secret; } });
|
|
const host = { SDKVersion: '2.9.4', getSystemInfoSync: () => info };
|
|
Object.defineProperty(host, 'appxVersion', { get() { getters++; return secret; } });
|
|
const system = AR.diagnostics.system(host).join('\n');
|
|
assert.match(system, /system.version=10.45.0/); assert.match(system, /runtime.SDKVersion=2.9.4/);
|
|
assert(!system.includes(secret) && !system.includes('deviceId') && !system.includes('userId'));
|
|
assert.equal(getters, 0);
|
|
});
|
|
|
|
test('nonstandard native frames are counted and copied only by explicit user action without private payloads', async () => {
|
|
const secret = 'private-marker-should-never-be-copied', scopes = [];
|
|
const { page: p, copies, navigations } = probePage({ ensure: async scope => { scopes.push(scope); }, platform: {
|
|
SDKVersion: '2.9.4', getSystemInfoSync: () => ({ model: 'Test phone', version: '10.45.0', app: 'TB', openId: secret })
|
|
} });
|
|
p.onLoad(); assert.equal(p.data.running, false); assert.equal(scopes.length, 0); assert.equal(copies.length, 0);
|
|
assert.equal(p.data.probeVersion, 'AR-DIAG-20261102-05');
|
|
await p.onStart(); p.onARInit({ type: 'init', detail: {} });
|
|
const strange = { type: 'nativeARFrame', detail: { frame: { imageWidth: 640, imageHeight: 480, cameraPosition: 'back', pixelFormat: 'RGBA', imageData: 'data:image/jpeg;base64,' + secret, landmarks: [secret], userId: secret } } };
|
|
p.onARFrame(strange); p.onARFrame(strange);
|
|
assert.equal(p.frames, 2); assert.equal(p.data.frameCount, 1); assert.equal(p.data.standardFrameCount, 0);
|
|
assert.match(p.data.status, /已收到 onARFrame 回调/); assert.match(p.data.eventText, /type=nativeARFrame/);
|
|
assert.match(p.data.fieldText, /detail.frame.cameraPosition=back/);
|
|
assert(!p.data.fieldText.includes(secret) && !p.data.fieldText.includes('landmarks'));
|
|
p.setData({ snapshotUrl: 'data:image/jpeg;base64,' + secret, errorText: secret });
|
|
assert.equal(copies.length, 0, 'native callbacks never copy automatically');
|
|
p.onCopyDiagnostics();
|
|
assert.equal(copies.length, 1); assert.match(copies[0], /AR-DIAG-/); assert.match(copies[0], /system.version=10.45.0/);
|
|
assert.match(copies[0], /runtime.SDKVersion=2.9.4/); assert.match(copies[0], /frameEvents=2; parsableFrames=0/);
|
|
assert.match(copies[0], /context.methods=faceBeauty/); assert.match(copies[0], /cameraPosition=back/);
|
|
assert.equal(p.data.diagnosticText, copies[0], 'visible selectable text is the same privacy-filtered report as the clipboard');
|
|
assert(!copies[0].includes(secret) && !copies[0].includes('data:image') && !copies[0].includes('landmarks') && !copies[0].includes('openId'));
|
|
p.onError({ type: 'error', detail: { error: 13, photo: secret, landmarks: [secret] } });
|
|
assert.match(p.data.errorText, /detail.error=13/); assert(!p.data.errorText.includes(secret));
|
|
p.onOpenNativeProbe(); assert.deepEqual(navigations, ['/pages/camnative/camnative']);
|
|
});
|
|
|
|
test('native frame diagnostics count every callback but scan metadata and update UI at most once per 400ms', async () => {
|
|
let time = 0, scans = 0;
|
|
const ar = { ...AR, diagnostics: { ...AR.diagnostics, event: event => { scans++; return AR.diagnostics.event(event); } } };
|
|
const { page: p, updates, copies } = probePage({ now: () => time, ar });
|
|
p.onLoad(); await p.onStart(); p.onARInit({ type: 'init', detail: {} });
|
|
const baselineUpdates = updates.length, baselineScans = scans;
|
|
const standard = { type: 'ARFrame', detail: frame() };
|
|
const unusual = { type: 'nativeNonstandard', detail: { frame: { cameraPosition: 'back', imageWidth: 640 } } };
|
|
for (let index = 0; index < 100; index++) p.onARFrame(standard);
|
|
assert.equal(updates.length - baselineUpdates, 1, 'the first frame is shown immediately; a burst does not flood setData');
|
|
assert.equal(scans - baselineScans, 1, 'throttled callbacks do not rescan event descriptors');
|
|
time = 399;
|
|
for (let index = 0; index < 50; index++) p.onARFrame(standard);
|
|
assert.equal(updates.length - baselineUpdates, 1);
|
|
time = 400;
|
|
for (let index = 0; index < 50; index++) p.onARFrame(standard);
|
|
assert.equal(updates.length - baselineUpdates, 2); assert.equal(scans - baselineScans, 2);
|
|
time = 799;
|
|
for (let index = 0; index < 100; index++) p.onARFrame(unusual);
|
|
assert.equal(updates.length - baselineUpdates, 2, 'nonstandard native frames use the same UI throttle');
|
|
assert.equal(p.frames, 300); assert.equal(p.standardFrames, 200); assert.equal(p.data.frameCount, 151);
|
|
p.onCopyDiagnostics();
|
|
assert.match(copies[0], /frameEvents=300; parsableFrames=200/);
|
|
assert.match(copies[0], /type=nativeNonstandard; count=300/);
|
|
assert.match(copies[0], /detail.frame.cameraPosition=back/);
|
|
assert.equal(scans - baselineScans, 3, 'an explicit copy refreshes only the pending latest metadata');
|
|
const afterCopy = updates.length;
|
|
time = 800; p.onARFrame(unusual);
|
|
assert.equal(updates.length - afterCopy, 1); assert.equal(scans - baselineScans, 4);
|
|
assert.equal(p.data.frameCount, 301); assert.equal(p.data.standardFrameCount, 200);
|
|
assert.match(p.data.status, /已收到 onARFrame 回调/);
|
|
});
|
|
|
|
test('copy diagnostics has one fixed yellow toolbar entry and a native selectable-text fallback', () => {
|
|
const fs = require('node:fs'), path = require('node:path');
|
|
const markup = fs.readFileSync(path.join(__dirname, '../pages/camtest/camtest.axml'), 'utf8');
|
|
const styles = fs.readFileSync(path.join(__dirname, '../pages/camtest/camtest.acss'), 'utf8');
|
|
assert.equal((markup.match(/onTap="onCopyDiagnostics"/g) || []).length, 1);
|
|
assert.match(markup, /class="copy-diagnostics" onTap="onCopyDiagnostics">复制诊断信息<\/button>/);
|
|
assert.equal((markup.match(/onTap="onSnapshot"/g) || []).length, 1);
|
|
assert.match(markup, /class="export-photo" onTap="onSnapshot" disabled="\{\{!running \|\| busy \|\| exporting\}\}">导出照片<\/button>/);
|
|
assert(markup.indexOf('class="export-photo"') < markup.indexOf('class="probe"'));
|
|
assert(markup.indexOf('class="calibration-optional"') > markup.indexOf('class="probe"'));
|
|
assert(markup.indexOf('class="diag-toolbar"') < markup.indexOf('class="probe"'));
|
|
assert.match(styles, /\.diag-toolbar\s*\{[^}]*position:\s*fixed[^}]*top:\s*112px[^}]*background:\s*#ffe56c/s);
|
|
assert.match(markup, /<text class="copy-text" selectable="\{\{true\}\}">\{\{diagnosticText\}\}<\/text>/);
|
|
});
|
|
|
|
test('missing or failing clipboard still exposes safe diagnostic text only after the user requests it', async t => {
|
|
for (const mode of ['missing', 'fail', 'throw']) await t.test(mode, () => {
|
|
let clipboardCalls = 0, permissionCalls = 0, p;
|
|
const secret = 'private-photo-or-identity-marker';
|
|
const clipboard = mode === 'missing' ? undefined : options => {
|
|
clipboardCalls++;
|
|
assert.equal(p.data.diagnosticText, options.text, 'fallback text is generated before invoking the native clipboard');
|
|
if (mode === 'throw') throw Error('clipboard unavailable');
|
|
options.fail({});
|
|
};
|
|
({ page: p } = probePage({ ensure: () => { permissionCalls++; return Promise.resolve(); }, platform: {
|
|
setClipboard: clipboard, getSystemInfoSync: () => ({ model: 'Test phone', version: '10.45.0', openId: secret })
|
|
} }));
|
|
p.onLoad();
|
|
assert.equal(clipboardCalls, 0); assert.equal(permissionCalls, 0); assert.equal(p.data.running, false);
|
|
assert.equal(p.data.diagnosticText, '');
|
|
p.setData({ snapshotUrl: 'data:image/jpeg;base64,' + secret, errorText: secret });
|
|
p.onCopyDiagnostics();
|
|
assert.match(p.data.diagnosticText, /^AR-DIAG-20261102-05\n/);
|
|
assert.match(p.data.diagnosticText, /system.version=10.45.0/);
|
|
assert(!p.data.diagnosticText.includes(secret) && !p.data.diagnosticText.includes('data:image') && !p.data.diagnosticText.includes('openId'));
|
|
assert.match(p.data.copyStatus, /长按下方诊断文字复制/);
|
|
assert.equal(clipboardCalls, mode === 'missing' ? 0 : 1); assert.equal(permissionCalls, 0); assert.equal(p.data.running, false);
|
|
});
|
|
});
|
|
|
|
const calibrationTurn = () => new Promise(resolve => setImmediate(resolve));
|
|
function calibrationChoice(p, format, batchId = p.calibrationBatch && p.calibrationBatch.id) {
|
|
return { currentTarget: { dataset: { format, batch: batchId } } };
|
|
}
|
|
|
|
test('color calibration freezes one user-requested frame and serially previews candidates before an explicit format confirmation', async () => {
|
|
const frozen = { frozen: true }, previewMarker = 'local-preview-private-marker';
|
|
const calls = [], pending = [], confirmations = []; let freezes = 0, active = 0, peak = 0, confirmed = '';
|
|
const { page: p } = probePage({ capture: (source, page, options) => {
|
|
calls.push({ source, options });
|
|
if (!options || !options.pixelFormat) return Promise.resolve('data:image/jpeg;base64,ordinary');
|
|
assert.equal(source, frozen); assert.equal(options.maxSide, 480);
|
|
active++; peak = Math.max(peak, active);
|
|
return new Promise(resolve => pending.push(() => { active--; resolve('data:image/jpeg;base64,' + previewMarker + options.pixelFormat); }));
|
|
}, imageHelpers: {
|
|
freezeCalibrationFrame: source => { freezes++; assert.equal(source, firstFrame); return frozen; },
|
|
frameCalibrationFormats: source => { assert.equal(source, frozen); return ['RGBA', 'BGRA', 'ARGB', 'ABGR']; },
|
|
confirmFramePixelFormat: (source, format) => { confirmations.push({ source, format }); confirmed = format; return format; },
|
|
confirmedFramePixelFormat: () => confirmed
|
|
} });
|
|
const firstFrame = frame();
|
|
p.onLoad(); await p.onStart(); p.onARFrame({ detail: firstFrame });
|
|
assert.equal(freezes, 0); assert.equal(calls.length, 0); assert.equal(confirmations.length, 0);
|
|
assert.equal(p.data.calibrationItems.length, 0);
|
|
const calibrating = p.onCalibrate(); await calibrationTurn();
|
|
assert.equal(freezes, 1); assert.equal(calls.length, 1); assert.equal(p.data.calibrationReady, false);
|
|
const batchId = p.calibrationBatch.id;
|
|
p.onConfirmCalibration(calibrationChoice(p, 'RGBA')); assert.equal(confirmations.length, 0);
|
|
p.onARFrame({ detail: frame(260) });
|
|
for (let index = 0; index < 4; index++) {
|
|
assert.equal(calls.length, index + 1); pending.shift()(); await calibrationTurn();
|
|
if (index < 3) {
|
|
assert.equal(p.data.calibrationReady, false);
|
|
p.onConfirmCalibration(calibrationChoice(p, 'RGBA')); assert.equal(confirmations.length, 0);
|
|
await p.onSnapshot(); assert.equal(calls.length, index + 2, 'ordinary export cannot overlap the calibration batch');
|
|
}
|
|
}
|
|
await calibrating;
|
|
assert.deepEqual(calls.map(call => call.options.pixelFormat), ['RGBA', 'BGRA', 'ARGB', 'ABGR']);
|
|
assert.equal(peak, 1); assert.equal(freezes, 1); assert.equal(p.data.exporting, false); assert.equal(p.data.calibrationReady, true);
|
|
assert.equal(p.data.calibrationItems.length, 4); assert.equal(confirmations.length, 0);
|
|
p.onConfirmCalibration(calibrationChoice(p, 'RGB')); p.onConfirmCalibration(calibrationChoice(p, 'BGRA', 'old-batch'));
|
|
assert.equal(confirmations.length, 0);
|
|
p.setData({ busy: true }); // A finished batch does not use the unrelated export/busy UI flag as confirmation authority.
|
|
p.onConfirmCalibration(calibrationChoice(p, 'BGRA', batchId));
|
|
assert.deepEqual(confirmations, [{ source: frozen, format: 'BGRA' }]);
|
|
assert.equal(p.data.calibrationConfirmed, 'BGRA'); assert.match(p.data.calibrationText, /本次相机格式已确认/);
|
|
assert.equal(p.data.calibrationItems.length, 0);
|
|
const report = p.diagnosticReport(); assert.match(report, /pixelFormat.confirmed=BGRA/);
|
|
assert(!report.includes(previewMarker) && !report.includes('data:image'));
|
|
p.onConfirmCalibration(calibrationChoice(p, 'RGBA', batchId)); assert.equal(confirmations.length, 1);
|
|
p.setData({ busy: false }); await p.onSnapshot();
|
|
assert.equal(calls.at(-1).options.defaultPixelFormat, 'RGBA');
|
|
assert.equal(Object.hasOwn(calls.at(-1).options, 'pixelFormat'), false, 'normal snapshots supply only the config fallback, preserving metadata/confirmation priority');
|
|
});
|
|
|
|
test('photos export directly with the configured default before any optional color calibration or confirmation', async () => {
|
|
const calls = []; let freezes = 0, confirmations = 0;
|
|
const { page: p } = probePage({ arPixelFormat: 'BGRA', capture: async (source, page, options) => {
|
|
calls.push(options); return 'data:image/jpeg;base64,direct-default';
|
|
}, imageHelpers: {
|
|
freezeCalibrationFrame: () => { freezes++; throw Error('optional calibration must not start'); },
|
|
confirmFramePixelFormat: () => { confirmations++; throw Error('no user format confirmation'); },
|
|
confirmedFramePixelFormat: () => ''
|
|
} });
|
|
p.onLoad(); await p.onStart(); p.onARFrame({ detail: frame() });
|
|
await p.onSnapshot();
|
|
assert.equal(calls.length, 1); assert.equal(calls[0].defaultPixelFormat, 'BGRA');
|
|
assert.equal(Object.hasOwn(calls[0], 'pixelFormat'), false);
|
|
assert.equal(freezes, 0); assert.equal(confirmations, 0); assert.equal(p.data.calibrationConfirmed, '');
|
|
assert.equal(p.data.snapshotUrl, 'data:image/jpeg;base64,direct-default');
|
|
const diagnostic = p.diagnosticReport();
|
|
assert.match(diagnostic, /pixelFormat.default=BGRA; defaultIsUserConfirmed=false/);
|
|
assert.match(diagnostic, /pixelFormat.confirmed=\(unconfirmed\)/);
|
|
assert(!diagnostic.includes('data:image'));
|
|
});
|
|
|
|
test('camera switches, hiding and stopping expire calibration previews and never allow a late candidate to be confirmed', async t => {
|
|
for (const action of ['flip', 'hide', 'stop']) await t.test(action, async () => {
|
|
let resolveExport, freezes = 0, exports = 0, confirms = 0;
|
|
const { page: p } = probePage({ capture: () => { exports++; return new Promise(resolve => { resolveExport = resolve; }); }, imageHelpers: {
|
|
freezeCalibrationFrame: () => { freezes++; return { frozen: true }; },
|
|
confirmFramePixelFormat: () => { confirms++; return 'RGBA'; }
|
|
} });
|
|
p.onLoad(); await p.onStart(); p.onARFrame({ detail: frame() });
|
|
const calibrating = p.onCalibrate(); await calibrationTurn(); const batchId = p.calibrationBatch.id;
|
|
if (action === 'flip') await p.onFlip(); else if (action === 'hide') p.onHide(); else p.onStop();
|
|
p.onConfirmCalibration(calibrationChoice(p, 'RGBA', batchId)); assert.equal(confirms, 0);
|
|
assert.equal(p.data.calibrationItems.length, 0); assert.equal(p.data.exporting, true, 'expired native image callbacks retain the shared canvas lock');
|
|
if (action === 'flip') { p.onARFrame({ detail: frame() }); await p.onCalibrate(); assert.equal(freezes, 1); }
|
|
resolveExport('data:image/jpeg;base64,late-private-preview'); await calibrating;
|
|
assert.equal(exports, 1, 'remaining candidate exports are skipped after the batch expires');
|
|
assert.equal(p.data.exporting, false); assert.equal(p.data.calibrationItems.length, 0); assert.equal(p.data.calibrationReady, false);
|
|
assert(!JSON.stringify(p.data).includes('late-private-preview'));
|
|
p.onConfirmCalibration(calibrationChoice(p, 'RGBA', batchId)); assert.equal(confirms, 0);
|
|
});
|
|
});
|
|
|
|
test('failed color previews release the batch lock without confirming partial output, and unsupported frames start no export', async () => {
|
|
let exports = 0, confirms = 0;
|
|
const { page: p } = probePage({ capture: async () => { exports++; if (exports === 2) throw Error('canvas conversion failed'); return 'data:image/jpeg;base64,partial'; },
|
|
imageHelpers: { confirmFramePixelFormat: () => { confirms++; return 'RGBA'; } } });
|
|
p.onLoad(); await p.onStart(); p.onARFrame({ detail: frame() });
|
|
await p.onCalibrate();
|
|
assert.equal(exports, 2); assert.equal(p.exportInFlight, null); assert.equal(p.data.exporting, false); assert.equal(p.data.busy, false);
|
|
assert.equal(p.data.calibrationItems.length, 0); assert.equal(p.data.calibrationReady, false); assert.equal(confirms, 0);
|
|
assert.match(p.data.errorText, /canvas conversion failed/); assert.match(p.data.calibrationText, /导出失败/);
|
|
const unsupported = probePage({ capture: async () => { throw Error('must not export'); }, imageHelpers: { frameCalibrationFormats: () => [] } }).page;
|
|
unsupported.onLoad(); await unsupported.onStart(); unsupported.onARFrame({ detail: frame() }); await unsupported.onCalibrate();
|
|
assert.match(unsupported.data.errorText, /不是可校验/); assert.equal(unsupported.data.exporting, false);
|
|
});
|