feat(photoValidation): validatePose TDD 3 케이스 (v0-plan Task 5)

src/features/photoValidation/validatePose.ts:
- 필수 6개 joint(어깨/골반/발목) 신뢰도 0.5↑ 검사
- 어깨 y 차이 30px 초과 시 기울어짐 사유 추가
- score = 100 - reasons × 25, passed = score ≥ 80
- 한국어 격조사 정확히 ('어깨가', '골반이', '발목이')

src/features/photoValidation/__tests__/validatePose.test.ts:
- 3 케이스 TDD: 정상 / 어깨 누락 / 기울어짐
- RED(Cannot find module) → GREEN(3 passed) 사이클 확인

검증:
- npx jest src/features/photoValidation: 3 passed
- npx tsc --noEmit: 무에러

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:53:05 +09:00
parent 0f12be57cf
commit 6f2998ef5f
3 changed files with 95 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { validatePose } from '../validatePose';
import type { PoseResult } from '../../pose';
describe('validatePose', () => {
it('필수 6개 joint(어깨/골반/발목)가 모두 신뢰도 0.5↑이면 통과한다', () => {
const pose: PoseResult = {
left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 },
right_shoulder_joint: { x: 300, y: 200, confidence: 0.9 },
left_hip_joint: { x: 120, y: 400, confidence: 0.85 },
right_hip_joint: { x: 280, y: 400, confidence: 0.85 },
left_ankle_joint: { x: 130, y: 700, confidence: 0.7 },
right_ankle_joint: { x: 270, y: 700, confidence: 0.7 },
};
const result = validatePose(pose);
expect(result.score).toBeGreaterThanOrEqual(80);
expect(result.passed).toBe(true);
expect(result.reasons).toEqual([]);
});
it('어깨가 누락되면 실패하고 어깨 사유가 포함된다', () => {
const pose: PoseResult = {
left_hip_joint: { x: 120, y: 400, confidence: 0.85 },
right_hip_joint: { x: 280, y: 400, confidence: 0.85 },
};
const result = validatePose(pose);
expect(result.passed).toBe(false);
expect(result.reasons).toContain('어깨가 보이지 않습니다');
});
it('어깨 y 차이가 30px 초과하면 기울어짐 사유가 포함된다', () => {
const pose: PoseResult = {
left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 },
right_shoulder_joint: { x: 300, y: 250, confidence: 0.9 },
left_hip_joint: { x: 120, y: 400, confidence: 0.85 },
right_hip_joint: { x: 280, y: 400, confidence: 0.85 },
left_ankle_joint: { x: 130, y: 700, confidence: 0.7 },
right_ankle_joint: { x: 270, y: 700, confidence: 0.7 },
};
const result = validatePose(pose);
expect(result.passed).toBe(false);
expect(result.reasons).toContain('몸이 기울어져 있습니다');
});
});

View File

@@ -0,0 +1,52 @@
import type { PoseResult, Joint } from '../pose';
export interface ValidationResult {
score: number;
passed: boolean;
reasons: string[];
}
const REQUIRED_JOINTS: readonly Joint[] = [
'left_shoulder_joint',
'right_shoulder_joint',
'left_hip_joint',
'right_hip_joint',
'left_ankle_joint',
'right_ankle_joint',
] as const;
const MIN_CONFIDENCE = 0.5;
const MAX_SHOULDER_TILT_PX = 30;
const PASS_SCORE = 80;
const REASON_PENALTY = 25;
function reasonForMissingJoint(joint: Joint): string {
if (joint.includes('shoulder')) return '어깨가 보이지 않습니다';
if (joint.includes('hip')) return '골반이 보이지 않습니다';
if (joint.includes('ankle')) return '발목이 보이지 않습니다';
return '관절이 보이지 않습니다';
}
export function validatePose(pose: PoseResult): ValidationResult {
const reasons: string[] = [];
for (const joint of REQUIRED_JOINTS) {
const kp = pose[joint];
if (!kp || kp.confidence < MIN_CONFIDENCE) {
const msg = reasonForMissingJoint(joint);
if (!reasons.includes(msg)) reasons.push(msg);
}
}
const ls = pose.left_shoulder_joint;
const rs = pose.right_shoulder_joint;
if (ls && rs) {
const yDiff = Math.abs(ls.y - rs.y);
if (yDiff > MAX_SHOULDER_TILT_PX) {
reasons.push('몸이 기울어져 있습니다');
}
}
const score = Math.max(0, 100 - reasons.length * REASON_PENALTY);
return { score, passed: score >= PASS_SCORE, reasons };
}