'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 = Array.from(p); return true; } })); assert.deepEqual(pattern, [70, 40, 70]); // 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, readyHapticAt: 0, OK_HOLD_MS: 450, Date: { now: () => now }, holdState: null, CaptureQuality: { evaluate: d => ({ ok: d.detection.box.height >= 100, hint: '保持不动', observation: { yaw: 0, pitch: 0, center_x: .5, center_y: .5, ratio: .5 } }), hold: require('../js/capture-quality').hold }, navigator: { vibrate: () => { buzzes++; return true; } }, doCapture: () => { captures++; } }; vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function onDetect(d)'), source.indexOf(' /* ─── 拍照流程')), state); const face = { detection: { box: { height: 250 } }, landmarks: { positions: [] } }; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0); now += 200; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0); now += 250; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 1); now += 100; state.onDetect({ ...face, detection: { box: { height: 20 } } }); now += 100; state.onDetect(face); assert.equal(buzzes, 1, 'jitter must not repeatedly buzz'); now += 2100; state.onDetect({ ...face, detection: { box: { height: 20 } } }); state.onDetect(face); assert.equal(buzzes, 2); state.navigator.vibrate = () => { throw Error('blocked'); }; now += 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, manual ? 1 : 0, 'auto must not double buzz; manual must retain its cue'); assert(shutter.shutterLock && shutter.capturing); assert.deepEqual(scheduled, [300], 'blocked haptic must still schedule camera capture'); } console.log('PASS: three bounded rounded contours; haptic safety, pre-capture cue, jitter throttling; relaxed 450ms/2-frame hold');