Files
zyt/TUICallKit-Vue3/training/pose/__tests__/counters.test.ts
T
long 4932ed7651 refactor: 移除 AI 跟练中没有作用的代码
- 删除从未显示的 idle 手势帧(小程序包内 png 和视频用高清帧),训练分包减少 17KB
- 删除骨架连线 BONES、镜像函数 mirrorPose(骨架已去掉)和未使用的 Bgm 类型
- 只给单测用的 sampleMotion 移到测试目录
- 删除训练菜单里未使用的 .menu-icon-text 样式
- 补全 history 单测数据缺少的 adviceKind 字段(严格类型检查报错)
2026-10-31 10:33:25 +08:00

134 lines
5.3 KiB
TypeScript

/**
* 计数器单测:用示范动作(motions.ts)合成姿态序列来验证。
* 运行:node --test training/pose/__tests__/
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createCounter, type CoachEvent } from '../counters.ts'
import { MISTAKE_POSES, MOTIONS, type MoveId } from '../motions.ts'
import { sampleMotion } from './sampleMotion.ts'
import type { Pose } from '../keypoints.ts'
const MOVES: MoveId[] = ['squat', 'march', 'jack', 'raise']
/** 固定种子的伪随机,模拟关键点抖动 */
function rng(seed: number) {
let s = seed
return () => ((s = (s * 16807) % 2147483647) / 2147483647) - 0.5
}
function run(move: MoveId, frames: Pose[]) {
const c = createCounter(move)
const events: CoachEvent[] = []
for (const f of frames) events.push(...c.update(f))
return { count: c.count, events, reps: events.filter((e) => e.type === 'rep'), tips: events.map((e) => (e.type === 'tip' ? e.text : e.tip)).filter(Boolean) }
}
function mistakeFrames(move: MoveId, seconds: number, fps = 12): Pose[] {
const spec = MOTIONS[move]
return Array.from({ length: Math.round(seconds * fps) }, (_, i) => {
const points = MISTAKE_POSES[move](i / fps / spec.cycleSeconds)
return { points, scores: points.map(() => 0.9), t: (i / fps) * 1000 }
})
}
/** 同一姿态放进 3:4 竖屏画面:x 归一化坐标被拉伸,aspect 修正后几何不变 */
function toPortrait(frames: Pose[], aspect = 0.75): Pose[] {
return frames.map((f) => ({ ...f, aspect, points: f.points.map((p) => ({ x: 0.5 + (p.x - 0.5) / aspect, y: p.y })) }))
}
for (const move of MOVES) {
const cycles = 5
const expected = cycles * MOTIONS[move].repsPerCycle
const frames = sampleMotion(move, MOTIONS[move].cycleSeconds * cycles + 0.3)
test(`${move}: 标准示范动作计数准确且评分高`, () => {
const r = run(move, frames)
assert.equal(r.count, expected)
for (const rep of r.reps) if (rep.type === 'rep') assert.ok(rep.score >= 85, `score ${rep.score}`)
assert.deepEqual(r.tips, [])
})
test(`${move}: 关键点抖动下计数仍准确`, () => {
const noise = rng(7)
const noisy = frames.map((f) => ({ ...f, points: f.points.map((p) => ({ x: p.x + noise() * 0.008, y: p.y + noise() * 0.008 })) }))
assert.equal(run(move, noisy).count, expected)
})
test(`${move}: 竖屏画面比例下结果一致`, () => {
assert.equal(run(move, toPortrait(frames)).count, expected)
})
test(`${move}: 低帧率(6fps)下不漏计`, () => {
assert.equal(run(move, sampleMotion(move, MOTIONS[move].cycleSeconds * cycles + 0.3, 6)).count, expected)
})
}
test('squat: 膝盖内扣会被提示', () => {
const r = run('squat', mistakeFrames('squat', MOTIONS.squat.cycleSeconds * 3 + 0.3))
assert.equal(r.count, 3)
assert.ok(r.tips.some((t) => t!.includes('膝盖向外')), JSON.stringify(r.tips))
})
test('march: 抬腿太低不计数并提示', () => {
const r = run('march', mistakeFrames('march', MOTIONS.march.cycleSeconds * 4))
assert.equal(r.count, 0)
assert.ok(r.tips.some((t) => t!.includes('抬高')), JSON.stringify(r.tips))
})
test('jack: 手臂没举过头不计数并提示', () => {
const r = run('jack', mistakeFrames('jack', MOTIONS.jack.cycleSeconds * 3))
assert.equal(r.count, 0)
assert.ok(r.tips.some((t) => t!.includes('举过头顶')), JSON.stringify(r.tips))
})
test('raise: 耸肩或抬太高会被提示', () => {
const r = run('raise', mistakeFrames('raise', MOTIONS.raise.cycleSeconds * 3 + 0.3))
assert.equal(r.count, 3)
assert.ok(r.tips.some((t) => t!.includes('耸肩') || t!.includes('与肩同高')), JSON.stringify(r.tips))
})
test('关键点不可见时不计数', () => {
const frames = sampleMotion('squat', 10).map((f) => ({ ...f, scores: f.scores.map(() => 0.1) }))
assert.equal(run('squat', frames).count, 0)
})
test('同一提示在冷却时间内不重复', () => {
const r = run('march', mistakeFrames('march', 3))
const n = r.tips.filter((t) => t!.includes('抬高')).length
assert.ok(n <= 1, `tips ${n}`)
})
test('live 幅度跟随动作:到位时接近 1,站立时接近 0', () => {
for (const move of ['squat', 'march', 'jack', 'raise'] as MoveId[]) {
const c = createCounter(move)
let max = 0
let min = 1
for (const f of sampleMotion(move, MOTIONS[move].cycleSeconds * 2)) {
c.update(f)
max = Math.max(max, c.live.amount)
min = Math.min(min, c.live.amount)
}
assert.ok(max > 0.85, `${move} max ${max}`)
assert.ok(min < 0.12, `${move} min ${min}`)
}
})
test('live 左右侧判断正确(高抬腿)', () => {
const c = createCounter('march')
const spec = MOTIONS.march
const left = spec.poseAt(0.25)
c.update({ points: left, scores: left.map(() => 0.9), t: 0 })
assert.equal(c.live.side, 'L')
const right = spec.poseAt(0.75)
c.update({ points: right, scores: right.map(() => 0.9), t: 100 })
assert.equal(c.live.side, 'R')
})
test('幅度条目标线:到达目标幅度才计数', () => {
for (const m of ['squat', 'march', 'jack', 'raise'] as const) {
const c = createCounter(m)
assert.ok(c.goal > 0.2 && c.goal < 0.9, `${m} goal=${c.goal}`)
}
})