'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const path = require('node:path'); const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8'); function extract(name) { return source.split('/* ' + name + '-begin */')[1].split('/* ' + name + '-end */')[0]; } const context = {}; vm.runInNewContext(extract('camera-outline-pure') + extract('camera-haptic-pure') + extract('camera-hint-stage-pure'), context); for (const hint of ['请向左转头', '请向右转头']) { assert.equal(context.captureStageHint(hint, 'front', false), '请正对屏幕'); assert.equal(context.captureStageHint(hint, 'front', true), '请正对屏幕'); assert.equal(context.captureStageHint(hint, 'right', false), '请正对屏幕'); assert.equal(context.captureStageHint(hint, 'right', true), hint); } assert.equal(context.captureStageHint('请靠近一点', 'front', false), '请靠近一点'); const geometry = { window: {} }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../js/camera-geometry.js'), 'utf8'), geometry); const original = JSON.stringify(geometry.window.CameraGeometry); for (const row of geometry.window.CameraGeometry) { const rounded = context.smoothCameraOutline(row.outline); assert(rounded.includes('Q') && rounded.endsWith('Z')); assert(!/NaN|Infinity/.test(rounded)); // Curve endpoints/control points remain within the exported contour bounds. const oldNumbers = row.outline.match(/[-+]?\d*\.?\d+/g).map(Number); const newNumbers = rounded.match(/[-+]?\d*\.?\d+/g).map(Number); for (let axis = 0; axis < 2; axis++) { const oldAxis = oldNumbers.filter((_, i) => i % 2 === axis), newAxis = newNumbers.filter((_, i) => i % 2 === axis); assert(Math.min(...newAxis) >= Math.min(...oldAxis) - .001); assert(Math.max(...newAxis) <= Math.max(...oldAxis) + .001); assert(Math.abs(Math.min(...newAxis) - Math.min(...oldAxis)) <= 2); assert(Math.abs(Math.max(...newAxis) - Math.max(...oldAxis)) <= 2); } } assert.equal(JSON.stringify(geometry.window.CameraGeometry), original); for (const value of [null, '', 'M--Z', 'M1,2L1,2Z', 'M0,0L0,0L0,0Z', 'M0,0Q1,2 3,4Z']) assert.equal(context.smoothCameraOutline(value), value); for (const nav of [null, {}, { vibrate: false }, { vibrate: () => false }, { vibrate: () => { throw Error('denied'); } }]) assert.equal(context.captureHaptic(nav), false); let pattern; assert(context.captureHaptic({ vibrate: p => { pattern = p; return true; } })); assert.equal(pattern, 30); // Exercise the actual readiness state machine; only camera/pose inputs and clock // are stubs; the relaxed shared hold policy now requires 450ms/2 frames. let now = 10000, buzzes = 0, captures = 0; const state = { missFrames: 0, MISS_FRAMES_MAX: 3, camVideo: { videoHeight: 600 }, FACE_NEAR: .30, FACE_FAR: .68, faceOffsetX: () => 0, CENTER_TOL: .18, CAM_STEPS: [{ target: 'front' }], camStep: 0, HeadPose: { check: () => ({ ok: true }) }, sideYawFlip: false, faceLuma: () => 100, LUMA_DARK: 65, LUMA_BRIGHT: 235, window: {}, setMask: () => {}, setHint: () => {}, okSince: 0, OK_HOLD_MS: 450, Date: { now: () => now }, shutterLock: false, capturing: false, camBusy: false, camLive: true, currentScreen: 's-cam', document: { hidden: false }, holdState: null, CaptureQuality: { evaluate: d => ({ ok: d.detection.box.height >= 100, hint: '保持不动', observation: { yaw: 0, pitch: 0, center_x: .5, center_y: .5, ratio: .5 } }), hold: require('../js/capture-quality').hold }, navigator: { vibrate: () => { buzzes++; return true; } }, doCapture: () => { captures++; } }; vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function onDetect(d)'), source.indexOf(' /* ─── 拍照流程')), state); const face = { detection: { box: { height: 250 } }, landmarks: { positions: [] } }; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 0); now += 200; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 0); now += 250; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 1); now += 100; state.onDetect({ ...face, detection: { box: { height: 20 } } }); now += 100; state.onDetect(face); assert.equal(buzzes, 0, 'good angles must not buzz'); now += 2100; state.onDetect({ ...face, detection: { box: { height: 20 } } }); state.onDetect(face); assert.equal(buzzes, 0); state.navigator.vibrate = () => { throw Error('blocked'); }; now += 300; state.onDetect(face); now += 150; state.onDetect(face); assert.equal(captures, 2, 'vibration failure must not prevent capture'); for (const manual of [true, false]) { let manualBuzzes = 0, scheduled = []; const shutter = { shutterLock: false, camLive: true, capturing: false, camSeq: 1, isManualMode: () => manual, navigator: { vibrate: () => { manualBuzzes++; throw Error('blocked'); } }, clearTimeout: () => {}, nudgeTimer: null, hideNudge: () => {}, $: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) }; vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function doCapture()'), source.indexOf(' // 三张拍完:按视角重排')), shutter); shutter.doCapture(); assert.equal(manualBuzzes, 0, 'neither auto nor manual shutter should buzz'); assert(shutter.shutterLock && shutter.capturing); assert.deepEqual(scheduled, [300], 'blocked haptic must still schedule camera capture'); } // Drive actual onDetect with angle-only failures, not UI hint string matching. const events = []; state.navigator.vibrate = p => { events.push({ p, at: now }); return true; }; state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle', hint: '请正对屏幕' }); state.onDetect(face); now += 999; state.onDetect(face); assert.equal(events.length, 0); now += 1; state.onDetect(face); assert.equal(events.length, 1); assert.equal(events[0].p, 30); for (let i = 0; i < 3; i++) { now += 1000; state.onDetect(face); } assert.equal(events.length, 1, 'no repeat before 4 seconds'); now += 1000; state.onDetect(face); assert.equal(events.length, 2); state.onDetect(null); assert.equal(events.at(-1).p, 0, 'missing face cancels vibration immediately'); const positives = () => events.filter(e => e.p > 0).length; state.onDetect(face); now += 999; state.onDetect(face); assert.equal(positives(), 2, 'new continuous interval required'); state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'quality', hint: '请靠近一点' }); now += 10000; state.onDetect(face); assert.equal(positives(), 2, 'distance/lighting failure does not buzz'); state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle', hint: '请正对屏幕' }); state.onDetect(face); now += 1000; state.onDetect(face); assert.equal(positives(), 3); state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'quality', hint: '请到光线充足处' }); state.onDetect(face); assert.equal(events.at(-1).p, 0, 'valid angle cancels even when lighting fails'); for (const [key, value] of [['camBusy', true], ['shutterLock', true], ['capturing', true], ['camLive', false], ['currentScreen', 's-report']]) { const previous = state[key]; state[key] = value; state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle' }); state.onDetect(face); now += 5000; state.onDetect(face); assert.equal(positives(), 3, key + ' blocks reminders'); state[key] = previous; } state.document.hidden = true; state.onDetect(face); now += 5000; state.onDetect(face); assert.equal(positives(), 3); state.document.hidden = false; for (const nav of [{}, { vibrate: () => false }, { vibrate: () => { throw Error('blocked'); } }]) { state.navigator = nav; state.resetAngleHaptic(); state.onDetect(face); now += 1000; assert.doesNotThrow(() => state.onDetect(face)); } console.log('PASS: rounded contours; 1s invalid-angle cue, 4s throttle, cancellation/lifecycle, unsupported safety; unchanged 450ms/2-frame capture hold');