- 删除从未显示的 idle 手势帧(小程序包内 png 和视频用高清帧),训练分包减少 17KB - 删除骨架连线 BONES、镜像函数 mirrorPose(骨架已去掉)和未使用的 Bgm 类型 - 只给单测用的 sampleMotion 移到测试目录 - 删除训练菜单里未使用的 .menu-icon-text 样式 - 补全 history 单测数据缺少的 adviceKind 字段(严格类型检查报错)
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { TrainingSession, summarize, type SessionEvent } from '../session.ts'
|
|
import { MOTIONS } from '../motions.ts'
|
|
import { sampleMotion } from './sampleMotion.ts'
|
|
import type { Pose } from '../keypoints.ts'
|
|
|
|
const standing = (t: number): Pose => {
|
|
const points = MOTIONS.squat.poseAt(0)
|
|
return { points, scores: points.map(() => 0.9), t }
|
|
}
|
|
|
|
/** 站好 → 倒计时 → 开始训练,返回训练开始时刻 */
|
|
function warmUp(s: TrainingSession, events: SessionEvent[]) {
|
|
let t = 0
|
|
for (; t <= 5000; t += 100) events.push(...s.feed(standing(t), t))
|
|
return t
|
|
}
|
|
|
|
test('站好保持 1.5 秒后进入倒计时,3 秒后开始训练', () => {
|
|
const s = new TrainingSession('squat', 5)
|
|
const events: SessionEvent[] = []
|
|
warmUp(s, events)
|
|
const phases = events.filter((e) => e.type === 'phase').map((e) => (e as any).phase)
|
|
assert.deepEqual(phases, ['countdown', 'training'])
|
|
const counts = events.filter((e) => e.type === 'countdown').map((e) => (e as any).n)
|
|
assert.deepEqual(counts, [3, 2, 1])
|
|
})
|
|
|
|
test('没站进画面不会开始', () => {
|
|
const s = new TrainingSession('squat', 5)
|
|
for (let t = 0; t < 5000; t += 100) s.feed(null, t)
|
|
assert.equal(s.phase, 'framing')
|
|
assert.equal(s.framingIssue, 'no_body')
|
|
})
|
|
|
|
test('完成目标次数后结束,并统计时长与连击', () => {
|
|
const s = new TrainingSession('squat', 4)
|
|
const events: SessionEvent[] = []
|
|
const t0 = warmUp(s, events)
|
|
for (const f of sampleMotion('squat', MOTIONS.squat.cycleSeconds * 5, 12, t0)) {
|
|
events.push(...s.feed(f, f.t))
|
|
if (s.phase === 'done') break
|
|
}
|
|
assert.equal(s.phase, 'done')
|
|
assert.equal(s.count, 4)
|
|
assert.equal(s.bestCombo, 4)
|
|
assert.ok(events.some((e) => e.type === 'combo' && e.combo === 3))
|
|
const sum = summarize(s, 4)
|
|
assert.equal(sum.stars, 3)
|
|
assert.equal(sum.adviceKind, 'praise')
|
|
assert.ok(sum.durationSec >= 9 && sum.durationSec <= 14, `duration ${sum.durationSec}`)
|
|
assert.ok(sum.kcal > 0)
|
|
})
|
|
|
|
test('离开画面自动进入 lost,回来后继续,离开期间不计时', () => {
|
|
const s = new TrainingSession('squat', 10)
|
|
const t0 = warmUp(s, [])
|
|
s.feed(standing(t0 + 100), t0 + 100)
|
|
const before = s.activeMs
|
|
let t = t0 + 200
|
|
for (; t < t0 + 6000; t += 100) s.feed(null, t)
|
|
assert.equal(s.phase, 'lost')
|
|
assert.ok(s.activeMs - before < 2000, `active grew ${s.activeMs - before}`)
|
|
s.feed(standing(t), t)
|
|
assert.equal(s.phase, 'training')
|
|
})
|
|
|
|
test('暂停与恢复', () => {
|
|
const s = new TrainingSession('raise', 10)
|
|
const t0 = warmUp(s, [])
|
|
s.pause()
|
|
assert.equal(s.phase, 'paused')
|
|
const before = s.activeMs
|
|
s.tick(t0 + 10000)
|
|
assert.equal(s.activeMs, before)
|
|
s.resume(t0 + 10000)
|
|
assert.equal(s.phase, 'training')
|
|
})
|
|
|
|
test('领练模式:跳过站位、按节奏计数、不会因无人而暂停', () => {
|
|
const s = new TrainingSession('march', 3, { detectLost: false })
|
|
s.startCountdown(0)
|
|
assert.equal(s.phase, 'countdown')
|
|
for (let t = 0; t <= 3200; t += 200) s.tick(t)
|
|
assert.equal(s.phase, 'training')
|
|
for (let t = 3400; t < 9000; t += 200) s.tick(t)
|
|
assert.equal(s.phase, 'training')
|
|
s.addGuidedRep()
|
|
s.addGuidedRep()
|
|
const last = s.addGuidedRep()
|
|
assert.equal(s.count, 3)
|
|
assert.equal(s.phase, 'done')
|
|
assert.ok(last.some((e) => e.type === 'phase' && e.phase === 'done'))
|
|
assert.equal(s.reps.length, 0)
|
|
})
|