1003 行
31 KiB
TypeScript
1003 行
31 KiB
TypeScript
import type { Page, Route } from "@playwright/test";
|
|
|
|
type MockUser = {
|
|
id: number;
|
|
openId: string;
|
|
email: string;
|
|
name: string;
|
|
loginMethod: string;
|
|
role: string;
|
|
skillLevel: string;
|
|
trainingGoals: string | null;
|
|
ntrpRating: number;
|
|
manualNtrpRating: number | null;
|
|
manualNtrpCapturedAt: string | null;
|
|
heightCm: number | null;
|
|
weightKg: number | null;
|
|
sprintSpeedScore: number | null;
|
|
explosivePowerScore: number | null;
|
|
agilityScore: number | null;
|
|
enduranceScore: number | null;
|
|
flexibilityScore: number | null;
|
|
coreStabilityScore: number | null;
|
|
shoulderMobilityScore: number | null;
|
|
hipMobilityScore: number | null;
|
|
assessmentNotes: string | null;
|
|
totalSessions: number;
|
|
totalMinutes: number;
|
|
totalShots: number;
|
|
currentStreak: number;
|
|
longestStreak: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
lastSignedIn: string;
|
|
};
|
|
|
|
type MockMediaSession = {
|
|
id: string;
|
|
userId: string;
|
|
title: string;
|
|
status: string;
|
|
archiveStatus: string;
|
|
format: string;
|
|
mimeType: string;
|
|
qualityPreset: string;
|
|
facingMode: string;
|
|
deviceKind: string;
|
|
reconnectCount: number;
|
|
uploadedSegments: number;
|
|
uploadedBytes: number;
|
|
durationMs: number;
|
|
streamConnected: boolean;
|
|
viewerCount?: number;
|
|
playback: {
|
|
webmUrl?: string;
|
|
mp4Url?: string;
|
|
webmSize?: number;
|
|
mp4Size?: number;
|
|
ready: boolean;
|
|
previewUrl?: string;
|
|
};
|
|
markers: Array<{
|
|
id: string;
|
|
type: string;
|
|
label: string;
|
|
timestampMs: number;
|
|
confidence?: number;
|
|
createdAt: string;
|
|
}>;
|
|
};
|
|
|
|
type MockAppState = {
|
|
authenticated: boolean;
|
|
user: MockUser;
|
|
videos: any[];
|
|
analyses: any[];
|
|
tasks: any[];
|
|
activePlan: {
|
|
id: number;
|
|
title: string;
|
|
skillLevel: string;
|
|
durationDays: number;
|
|
exercises: Array<{
|
|
day: number;
|
|
name: string;
|
|
category: string;
|
|
duration: number;
|
|
description: string;
|
|
tips: string;
|
|
sets: number;
|
|
reps: number;
|
|
}>;
|
|
version: number;
|
|
adjustmentNotes: string | null;
|
|
} | null;
|
|
mediaSession: MockMediaSession | null;
|
|
liveRuntime: {
|
|
role: "idle" | "owner" | "viewer";
|
|
runtimeSession: any | null;
|
|
};
|
|
nextVideoId: number;
|
|
nextTaskId: number;
|
|
authMeNullResponsesAfterLogin: number;
|
|
viewerSignalConflictRemaining: number;
|
|
};
|
|
|
|
function trpcResult(json: unknown) {
|
|
return { result: { data: { json } } };
|
|
}
|
|
|
|
function nowIso() {
|
|
return new Date("2026-03-14T12:00:00.000Z").toISOString();
|
|
}
|
|
|
|
function buildUser(name = "TestPlayer"): MockUser {
|
|
return {
|
|
id: 1,
|
|
openId: "username_test_001",
|
|
email: "test@example.com",
|
|
name,
|
|
loginMethod: "username",
|
|
role: "user",
|
|
skillLevel: "beginner",
|
|
trainingGoals: null,
|
|
ntrpRating: 2.8,
|
|
manualNtrpRating: 2.5,
|
|
manualNtrpCapturedAt: nowIso(),
|
|
heightCm: 178,
|
|
weightKg: 68,
|
|
sprintSpeedScore: 4,
|
|
explosivePowerScore: 4,
|
|
agilityScore: 4,
|
|
enduranceScore: 3,
|
|
flexibilityScore: 3,
|
|
coreStabilityScore: 4,
|
|
shoulderMobilityScore: 3,
|
|
hipMobilityScore: 4,
|
|
assessmentNotes: "每周可练 3 次,右肩偶尔偏紧。",
|
|
totalSessions: 12,
|
|
totalMinutes: 320,
|
|
totalShots: 280,
|
|
currentStreak: 5,
|
|
longestStreak: 12,
|
|
createdAt: nowIso(),
|
|
updatedAt: nowIso(),
|
|
lastSignedIn: nowIso(),
|
|
};
|
|
}
|
|
|
|
function buildStats(user: MockUser) {
|
|
const hasSystemNtrp = user.ntrpRating != null;
|
|
return {
|
|
ntrpRating: user.ntrpRating,
|
|
totalSessions: user.totalSessions,
|
|
totalMinutes: user.totalMinutes,
|
|
totalShots: user.totalShots,
|
|
ratingHistory: [
|
|
{ createdAt: nowIso(), rating: 2.4, dimensionScores: {} },
|
|
{ createdAt: nowIso(), rating: 2.6, dimensionScores: {} },
|
|
{ createdAt: nowIso(), rating: 2.8, dimensionScores: {} },
|
|
],
|
|
recentAnalyses: [
|
|
{
|
|
id: 10,
|
|
createdAt: nowIso(),
|
|
overallScore: 82,
|
|
exerciseType: "forehand",
|
|
shotCount: 18,
|
|
},
|
|
],
|
|
recentLiveSessions: [],
|
|
dailyTraining: [
|
|
{
|
|
trainingDate: "2026-03-13",
|
|
totalMinutes: 48,
|
|
sessionCount: 2,
|
|
effectiveActions: 36,
|
|
averageScore: 80,
|
|
},
|
|
{
|
|
trainingDate: "2026-03-14",
|
|
totalMinutes: 32,
|
|
sessionCount: 1,
|
|
effectiveActions: 18,
|
|
averageScore: 84,
|
|
},
|
|
],
|
|
achievements: [
|
|
{
|
|
key: "training_day_1",
|
|
name: "开练",
|
|
description: "完成首个训练日",
|
|
progressPct: 100,
|
|
unlocked: true,
|
|
},
|
|
{
|
|
key: "analyses_1",
|
|
name: "分析首秀",
|
|
description: "完成首个分析会话",
|
|
progressPct: 100,
|
|
unlocked: true,
|
|
},
|
|
{
|
|
key: "live_analysis_5",
|
|
name: "实时观察者",
|
|
description: "完成 5 次实时分析",
|
|
progressPct: 40,
|
|
unlocked: false,
|
|
},
|
|
],
|
|
latestNtrpSnapshot: {
|
|
rating: user.ntrpRating,
|
|
createdAt: nowIso(),
|
|
dimensionScores: {
|
|
poseAccuracy: 82,
|
|
strokeConsistency: 78,
|
|
footwork: 74,
|
|
fluidity: 79,
|
|
timing: 77,
|
|
matchReadiness: 70,
|
|
},
|
|
},
|
|
trainingProfileStatus: {
|
|
hasSystemNtrp,
|
|
isComplete: true,
|
|
missingFields: [],
|
|
effectiveNtrp: user.ntrpRating,
|
|
ntrpSource: hasSystemNtrp ? "system" : "manual",
|
|
assessmentSnapshot: {
|
|
heightCm: user.heightCm,
|
|
weightKg: user.weightKg,
|
|
sprintSpeedScore: user.sprintSpeedScore,
|
|
explosivePowerScore: user.explosivePowerScore,
|
|
agilityScore: user.agilityScore,
|
|
enduranceScore: user.enduranceScore,
|
|
flexibilityScore: user.flexibilityScore,
|
|
coreStabilityScore: user.coreStabilityScore,
|
|
shoulderMobilityScore: user.shoulderMobilityScore,
|
|
hipMobilityScore: user.hipMobilityScore,
|
|
assessmentNotes: user.assessmentNotes,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function readTrpcInput(route: Route, operationIndex: number) {
|
|
const url = new URL(route.request().url());
|
|
const rawSearchInput = url.searchParams.get("input");
|
|
|
|
if (rawSearchInput) {
|
|
const parsed = JSON.parse(rawSearchInput);
|
|
return parsed?.json ?? parsed?.[operationIndex]?.json ?? null;
|
|
}
|
|
|
|
const postData = route.request().postData();
|
|
if (!postData) return null;
|
|
|
|
const parsed = JSON.parse(postData);
|
|
return parsed?.json ?? parsed?.[operationIndex]?.json ?? parsed?.[String(operationIndex)]?.json ?? null;
|
|
}
|
|
|
|
function buildMediaSession(user: MockUser, title: string): MockMediaSession {
|
|
return {
|
|
id: "session-e2e",
|
|
userId: String(user.id),
|
|
title,
|
|
status: "created",
|
|
archiveStatus: "idle",
|
|
format: "webm",
|
|
mimeType: "video/webm",
|
|
qualityPreset: "balanced",
|
|
facingMode: "environment",
|
|
deviceKind: "mobile",
|
|
reconnectCount: 0,
|
|
uploadedSegments: 0,
|
|
uploadedBytes: 0,
|
|
durationMs: 0,
|
|
streamConnected: true,
|
|
playback: {
|
|
ready: false,
|
|
},
|
|
markers: [],
|
|
};
|
|
}
|
|
|
|
function createTask(state: MockAppState, input: {
|
|
type: string;
|
|
title: string;
|
|
status?: string;
|
|
progress?: number;
|
|
message?: string;
|
|
result?: any;
|
|
error?: string | null;
|
|
}) {
|
|
const task = {
|
|
id: `task-${state.nextTaskId++}`,
|
|
userId: state.user.id,
|
|
type: input.type,
|
|
status: input.status ?? "succeeded",
|
|
title: input.title,
|
|
message: input.message ?? "任务执行完成",
|
|
progress: input.progress ?? 100,
|
|
result: input.result ?? null,
|
|
error: input.error ?? null,
|
|
attempts: input.status === "failed" ? 2 : 1,
|
|
maxAttempts: input.type === "media_finalize" ? 90 : 3,
|
|
startedAt: nowIso(),
|
|
completedAt: input.status === "queued" || input.status === "running" ? null : nowIso(),
|
|
createdAt: nowIso(),
|
|
updatedAt: nowIso(),
|
|
};
|
|
state.tasks = [task, ...state.tasks];
|
|
return task;
|
|
}
|
|
|
|
async function fulfillJson(route: Route, body: unknown) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function handleTrpc(route: Route, state: MockAppState) {
|
|
const url = new URL(route.request().url());
|
|
const operations = url.pathname.replace("/api/trpc/", "").split(",");
|
|
const results = await Promise.all(operations.map(async (operation, operationIndex) => {
|
|
switch (operation) {
|
|
case "auth.me":
|
|
if (state.authenticated && state.authMeNullResponsesAfterLogin > 0) {
|
|
state.authMeNullResponsesAfterLogin -= 1;
|
|
return trpcResult(null);
|
|
}
|
|
return trpcResult(state.authenticated ? state.user : null);
|
|
case "auth.loginWithUsername":
|
|
state.authenticated = true;
|
|
return trpcResult({ user: state.user, isNew: false });
|
|
case "profile.stats":
|
|
return trpcResult(buildStats(state.user));
|
|
case "profile.update": {
|
|
const input = await readTrpcInput(route, operationIndex);
|
|
state.user = {
|
|
...state.user,
|
|
...input,
|
|
updatedAt: nowIso(),
|
|
manualNtrpCapturedAt:
|
|
input?.manualNtrpRating !== undefined
|
|
? input.manualNtrpRating == null
|
|
? null
|
|
: nowIso()
|
|
: state.user.manualNtrpCapturedAt,
|
|
};
|
|
return trpcResult({ success: true });
|
|
}
|
|
case "plan.active":
|
|
return trpcResult(state.activePlan);
|
|
case "plan.list":
|
|
return trpcResult(state.activePlan ? [state.activePlan] : []);
|
|
case "plan.generate": {
|
|
const input = await readTrpcInput(route, operationIndex);
|
|
const durationDays = Number(input?.durationDays ?? 7);
|
|
const skillLevel = input?.skillLevel ?? state.user.skillLevel;
|
|
state.activePlan = {
|
|
id: 200,
|
|
title: `${state.user.name} 的训练计划`,
|
|
skillLevel,
|
|
durationDays,
|
|
version: 1,
|
|
adjustmentNotes: null,
|
|
exercises: [
|
|
{
|
|
day: 1,
|
|
name: "正手影子挥拍",
|
|
category: "影子挥拍",
|
|
duration: 15,
|
|
description: "练习完整引拍和收拍动作。",
|
|
tips: "保持重心稳定,击球点在身体前侧。",
|
|
sets: 3,
|
|
reps: 12,
|
|
},
|
|
{
|
|
day: 1,
|
|
name: "交叉步移动",
|
|
category: "脚步移动",
|
|
duration: 12,
|
|
description: "强化启动和回位节奏。",
|
|
tips: "每次移动后快速回到准备姿势。",
|
|
sets: 4,
|
|
reps: 10,
|
|
},
|
|
],
|
|
};
|
|
return trpcResult({
|
|
taskId: createTask(state, {
|
|
type: "training_plan_generate",
|
|
title: `${durationDays}天训练计划生成`,
|
|
result: {
|
|
kind: "training_plan_generate",
|
|
planId: state.activePlan.id,
|
|
plan: state.activePlan,
|
|
},
|
|
}).id,
|
|
});
|
|
}
|
|
case "plan.adjust":
|
|
return trpcResult({
|
|
taskId: createTask(state, {
|
|
type: "training_plan_adjust",
|
|
title: "训练计划调整",
|
|
result: {
|
|
kind: "training_plan_adjust",
|
|
adjustmentNotes: "已根据最近分析结果调整训练重点。",
|
|
},
|
|
}).id,
|
|
});
|
|
case "video.list":
|
|
return trpcResult(state.videos);
|
|
case "video.upload": {
|
|
const input = await readTrpcInput(route, operationIndex);
|
|
const video = {
|
|
id: state.nextVideoId++,
|
|
title: input?.title || `实时分析录像 ${state.nextVideoId}`,
|
|
url: `/uploads/${state.nextVideoId}.${input?.format || "webm"}`,
|
|
format: input?.format || "webm",
|
|
fileSize: input?.fileSize || 1024 * 1024,
|
|
duration: input?.duration || 60,
|
|
exerciseType: input?.exerciseType || "live_analysis",
|
|
analysisStatus: "completed",
|
|
createdAt: nowIso(),
|
|
};
|
|
state.videos = [video, ...state.videos];
|
|
return trpcResult({ videoId: video.id, url: video.url });
|
|
}
|
|
case "analysis.list":
|
|
return trpcResult(state.analyses);
|
|
case "analysis.liveSessionList":
|
|
return trpcResult([]);
|
|
case "analysis.runtimeGet":
|
|
return trpcResult(state.liveRuntime);
|
|
case "analysis.runtimeAcquire":
|
|
if (state.liveRuntime.runtimeSession?.status === "active" && state.liveRuntime.role === "viewer") {
|
|
return trpcResult(state.liveRuntime);
|
|
}
|
|
state.liveRuntime = {
|
|
role: "owner",
|
|
runtimeSession: {
|
|
id: 501,
|
|
title: "实时分析 正手",
|
|
sessionMode: "practice",
|
|
mediaSessionId: state.mediaSession?.id || null,
|
|
status: "active",
|
|
startedAt: nowIso(),
|
|
endedAt: null,
|
|
lastHeartbeatAt: nowIso(),
|
|
snapshot: {
|
|
phase: "analyzing",
|
|
currentAction: "forehand",
|
|
rawAction: "forehand",
|
|
visibleSegments: 1,
|
|
unknownSegments: 0,
|
|
durationMs: 1500,
|
|
feedback: ["节奏稳定"],
|
|
},
|
|
},
|
|
};
|
|
return trpcResult(state.liveRuntime);
|
|
case "analysis.runtimeHeartbeat": {
|
|
const input = await readTrpcInput(route, operationIndex);
|
|
if (state.liveRuntime.runtimeSession) {
|
|
state.liveRuntime.runtimeSession = {
|
|
...state.liveRuntime.runtimeSession,
|
|
mediaSessionId: input?.mediaSessionId ?? state.liveRuntime.runtimeSession.mediaSessionId,
|
|
snapshot: input?.snapshot ?? state.liveRuntime.runtimeSession.snapshot,
|
|
lastHeartbeatAt: nowIso(),
|
|
};
|
|
}
|
|
return trpcResult(state.liveRuntime);
|
|
}
|
|
case "analysis.runtimeRelease":
|
|
state.liveRuntime = { role: "idle", runtimeSession: null };
|
|
return trpcResult({ success: true, runtimeSession: null });
|
|
case "analysis.liveSessionSave":
|
|
return trpcResult({ sessionId: 1, trainingRecordId: 1 });
|
|
case "task.list":
|
|
return trpcResult(state.tasks);
|
|
case "task.get": {
|
|
const rawInput = url.searchParams.get("input");
|
|
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
|
|
const taskId = parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
|
|
return trpcResult(state.tasks.find((task) => task.id === taskId) || null);
|
|
}
|
|
case "task.retry": {
|
|
const rawInput = url.searchParams.get("input");
|
|
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
|
|
const taskId = parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
|
|
const task = state.tasks.find((item) => item.id === taskId);
|
|
if (task) {
|
|
task.status = "succeeded";
|
|
task.progress = 100;
|
|
task.error = null;
|
|
task.message = "任务执行完成";
|
|
}
|
|
return trpcResult({ task });
|
|
}
|
|
case "task.createMediaFinalize": {
|
|
if (state.mediaSession) {
|
|
state.mediaSession.status = "archived";
|
|
state.mediaSession.archiveStatus = "completed";
|
|
state.mediaSession.playback = {
|
|
ready: true,
|
|
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
|
|
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
|
|
webmSize: 2_400_000,
|
|
mp4Size: 1_800_000,
|
|
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
|
|
};
|
|
state.videos = [
|
|
{
|
|
id: state.nextVideoId++,
|
|
title: state.mediaSession.title,
|
|
url: state.mediaSession.playback.webmUrl,
|
|
format: "webm",
|
|
fileSize: state.mediaSession.playback.webmSize,
|
|
exerciseType: "recording",
|
|
analysisStatus: "completed",
|
|
createdAt: nowIso(),
|
|
},
|
|
...state.videos,
|
|
];
|
|
}
|
|
return trpcResult({
|
|
taskId: createTask(state, {
|
|
type: "media_finalize",
|
|
title: "录制归档",
|
|
result: {
|
|
kind: "media_finalize",
|
|
sessionId: state.mediaSession?.id,
|
|
videoId: state.videos[0]?.id,
|
|
url: state.videos[0]?.url,
|
|
},
|
|
}).id,
|
|
});
|
|
}
|
|
case "analysis.getCorrections":
|
|
return trpcResult({
|
|
taskId: createTask(state, {
|
|
type: "pose_correction_multimodal",
|
|
title: "动作纠正",
|
|
result: {
|
|
corrections: "## 动作概览\n整体节奏稳定,建议继续优化击球点前置。",
|
|
report: {
|
|
priorityFixes: [
|
|
{
|
|
title: "击球点前置",
|
|
why: "击球点略靠后会影响挥拍连贯性。",
|
|
howToPractice: "每组 8 次影子挥拍,刻意在身体前侧完成触球动作。",
|
|
successMetric: "连续 3 组都能稳定在身体前侧完成挥拍。",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}).id,
|
|
});
|
|
case "video.registerExternal":
|
|
if (state.mediaSession?.playback.webmUrl || state.mediaSession?.playback.mp4Url) {
|
|
state.videos = [
|
|
{
|
|
id: state.nextVideoId++,
|
|
title: state.mediaSession.title,
|
|
url: state.mediaSession.playback.webmUrl || state.mediaSession.playback.mp4Url,
|
|
format: "webm",
|
|
fileSize: state.mediaSession.playback.webmSize || 1024 * 1024,
|
|
exerciseType: "recording",
|
|
analysisStatus: "completed",
|
|
createdAt: nowIso(),
|
|
},
|
|
...state.videos,
|
|
];
|
|
}
|
|
return trpcResult({ videoId: state.nextVideoId, url: state.mediaSession?.playback.webmUrl });
|
|
case "achievement.list":
|
|
return trpcResult(buildStats(state.user).achievements);
|
|
case "rating.current":
|
|
return trpcResult({
|
|
rating: state.user.ntrpRating,
|
|
latestSnapshot: buildStats(state.user).latestNtrpSnapshot,
|
|
});
|
|
case "rating.history":
|
|
return trpcResult([
|
|
{
|
|
id: 1,
|
|
rating: 2.4,
|
|
triggerType: "daily",
|
|
createdAt: nowIso(),
|
|
dimensionScores: {
|
|
poseAccuracy: 72,
|
|
strokeConsistency: 70,
|
|
footwork: 66,
|
|
fluidity: 69,
|
|
timing: 68,
|
|
matchReadiness: 60,
|
|
},
|
|
sourceSummary: { analyses: 1, liveSessions: 0, totalEffectiveActions: 12, totalPk: 0, activeDays: 1 },
|
|
},
|
|
{
|
|
id: 2,
|
|
rating: state.user.ntrpRating,
|
|
triggerType: "daily",
|
|
createdAt: nowIso(),
|
|
dimensionScores: buildStats(state.user).latestNtrpSnapshot.dimensionScores,
|
|
sourceSummary: { analyses: 2, liveSessions: 1, totalEffectiveActions: 36, totalPk: 0, activeDays: 2 },
|
|
},
|
|
]);
|
|
default:
|
|
return trpcResult(null);
|
|
}
|
|
}));
|
|
|
|
await fulfillJson(route, results);
|
|
}
|
|
|
|
async function handleMedia(route: Route, state: MockAppState) {
|
|
const url = new URL(route.request().url());
|
|
const path = url.pathname;
|
|
|
|
if (path === "/media/health") {
|
|
await fulfillJson(route, { ok: true, timestamp: nowIso() });
|
|
return;
|
|
}
|
|
|
|
if (path === "/media/sessions" && route.request().method() === "POST") {
|
|
state.mediaSession = buildMediaSession(state.user, "E2E 录制");
|
|
await fulfillJson(route, { session: state.mediaSession });
|
|
return;
|
|
}
|
|
|
|
if (!state.mediaSession) {
|
|
await route.fulfill({ status: 404, body: "not found" });
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith("/viewer-signal")) {
|
|
if (state.viewerSignalConflictRemaining > 0) {
|
|
state.viewerSignalConflictRemaining -= 1;
|
|
await route.fulfill({
|
|
status: 409,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "viewer stream not ready" }),
|
|
});
|
|
return;
|
|
}
|
|
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1;
|
|
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" });
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith("/signal")) {
|
|
state.mediaSession.status = "recording";
|
|
await fulfillJson(route, { type: "answer", sdp: "mock-answer" });
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith("/segments")) {
|
|
const buffer = (await route.request().postDataBuffer()) || Buffer.from("");
|
|
state.mediaSession.uploadedSegments += 1;
|
|
state.mediaSession.uploadedBytes += buffer.length || 1024;
|
|
state.mediaSession.durationMs += 60_000;
|
|
state.mediaSession.status = "recording";
|
|
await fulfillJson(route, { session: state.mediaSession });
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith("/markers")) {
|
|
state.mediaSession.markers.push({
|
|
id: `marker-${state.mediaSession.markers.length + 1}`,
|
|
type: "manual",
|
|
label: "手动剪辑点",
|
|
timestampMs: 12_000,
|
|
createdAt: nowIso(),
|
|
});
|
|
await fulfillJson(route, { session: state.mediaSession });
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith("/finalize")) {
|
|
state.mediaSession.status = "finalizing";
|
|
state.mediaSession.archiveStatus = "queued";
|
|
await fulfillJson(route, { session: state.mediaSession });
|
|
return;
|
|
}
|
|
|
|
if (path === `/media/sessions/${state.mediaSession.id}`) {
|
|
state.mediaSession.status = "archived";
|
|
state.mediaSession.archiveStatus = "completed";
|
|
state.mediaSession.playback = {
|
|
ready: true,
|
|
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
|
|
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
|
|
webmSize: 2_400_000,
|
|
mp4Size: 1_800_000,
|
|
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
|
|
};
|
|
await fulfillJson(route, { session: state.mediaSession });
|
|
return;
|
|
}
|
|
|
|
if (path.startsWith("/media/assets/")) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: path.endsWith(".mp4") ? "video/mp4" : "video/webm",
|
|
body: "",
|
|
});
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({ status: 404, body: "not found" });
|
|
}
|
|
|
|
export async function installAppMocks(
|
|
page: Page,
|
|
options?: {
|
|
authenticated?: boolean;
|
|
videos?: any[];
|
|
analyses?: any[];
|
|
userName?: string;
|
|
authMeNullResponsesAfterLogin?: number;
|
|
liveViewerMode?: boolean;
|
|
viewerSignalConflictOnce?: boolean;
|
|
}
|
|
) {
|
|
const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null;
|
|
const state: MockAppState = {
|
|
authenticated: options?.authenticated ?? false,
|
|
user: buildUser(options?.userName),
|
|
videos: options?.videos ?? [
|
|
{
|
|
id: 1,
|
|
title: "正手训练样例",
|
|
url: "/media/assets/sessions/demo/recording.webm",
|
|
format: "webm",
|
|
fileSize: 3_400_000,
|
|
exerciseType: "forehand",
|
|
analysisStatus: "completed",
|
|
createdAt: nowIso(),
|
|
},
|
|
],
|
|
analyses: options?.analyses ?? [
|
|
{
|
|
id: 8,
|
|
videoId: 1,
|
|
overallScore: 84,
|
|
shotCount: 16,
|
|
avgSwingSpeed: 6.2,
|
|
strokeConsistency: 82,
|
|
framesAnalyzed: 180,
|
|
keyMoments: [
|
|
{ frame: 45, type: "shot", description: "建议保留:正手启动" },
|
|
{ frame: 110, type: "shot", description: "建议保留:击球后收拍" },
|
|
],
|
|
createdAt: nowIso(),
|
|
},
|
|
],
|
|
tasks: [],
|
|
activePlan: null,
|
|
mediaSession: seededViewerSession,
|
|
liveRuntime: options?.liveViewerMode
|
|
? {
|
|
role: "viewer",
|
|
runtimeSession: {
|
|
id: 777,
|
|
title: "其他设备实时分析",
|
|
sessionMode: "practice",
|
|
mediaSessionId: seededViewerSession?.id || null,
|
|
status: "active",
|
|
startedAt: nowIso(),
|
|
endedAt: null,
|
|
lastHeartbeatAt: nowIso(),
|
|
snapshot: {
|
|
phase: "analyzing",
|
|
title: "其他设备实时分析",
|
|
sessionMode: "practice",
|
|
qualityPreset: "balanced",
|
|
facingMode: "environment",
|
|
deviceKind: "mobile",
|
|
avatarEnabled: true,
|
|
avatarKey: "gorilla",
|
|
avatarLabel: "猩猩",
|
|
updatedAt: Date.parse(nowIso()),
|
|
currentAction: "forehand",
|
|
rawAction: "forehand",
|
|
durationMs: 3200,
|
|
visibleSegments: 2,
|
|
unknownSegments: 0,
|
|
archivedVideoCount: 1,
|
|
feedback: ["同步观看测试数据"],
|
|
liveScore: {
|
|
overall: 82,
|
|
posture: 80,
|
|
balance: 78,
|
|
technique: 84,
|
|
footwork: 76,
|
|
consistency: 79,
|
|
confidence: 88,
|
|
},
|
|
stabilityMeta: {
|
|
windowFrames: 24,
|
|
windowShare: 1,
|
|
windowProgress: 1,
|
|
switchCount: 1,
|
|
stableMs: 1800,
|
|
rawVolatility: 0.12,
|
|
pending: false,
|
|
candidateMs: 0,
|
|
},
|
|
recentSegments: [
|
|
{
|
|
actionType: "forehand",
|
|
isUnknown: false,
|
|
startMs: 800,
|
|
endMs: 2800,
|
|
durationMs: 2000,
|
|
confidenceAvg: 0.82,
|
|
score: 84,
|
|
peakScore: 88,
|
|
frameCount: 24,
|
|
issueSummary: ["击球点略靠后"],
|
|
keyFrames: [1000, 1800, 2600],
|
|
clipLabel: "正手挥拍 00:00 - 00:02",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}
|
|
: {
|
|
role: "idle",
|
|
runtimeSession: null,
|
|
},
|
|
nextVideoId: 100,
|
|
nextTaskId: 1,
|
|
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0,
|
|
viewerSignalConflictRemaining: options?.viewerSignalConflictOnce ? 1 : 0,
|
|
};
|
|
|
|
await page.addInitScript(() => {
|
|
const buildFakeLandmarks = () => {
|
|
const points = Array.from({ length: 33 }, () => ({
|
|
x: 0.5,
|
|
y: 0.5,
|
|
z: 0,
|
|
visibility: 0.99,
|
|
}));
|
|
|
|
points[0] = { x: 0.5, y: 0.15, z: 0, visibility: 0.99 };
|
|
points[11] = { x: 0.42, y: 0.28, z: 0, visibility: 0.99 };
|
|
points[12] = { x: 0.58, y: 0.28, z: 0, visibility: 0.99 };
|
|
points[13] = { x: 0.36, y: 0.42, z: 0, visibility: 0.99 };
|
|
points[14] = { x: 0.64, y: 0.42, z: 0, visibility: 0.99 };
|
|
points[15] = { x: 0.3, y: 0.54, z: 0, visibility: 0.99 };
|
|
points[16] = { x: 0.7, y: 0.52, z: 0, visibility: 0.99 };
|
|
points[23] = { x: 0.45, y: 0.58, z: 0, visibility: 0.99 };
|
|
points[24] = { x: 0.55, y: 0.58, z: 0, visibility: 0.99 };
|
|
points[25] = { x: 0.44, y: 0.76, z: 0, visibility: 0.99 };
|
|
points[26] = { x: 0.56, y: 0.76, z: 0, visibility: 0.99 };
|
|
points[27] = { x: 0.42, y: 0.94, z: 0, visibility: 0.99 };
|
|
points[28] = { x: 0.58, y: 0.94, z: 0, visibility: 0.99 };
|
|
|
|
return points;
|
|
};
|
|
|
|
class FakePose {
|
|
callback = null;
|
|
|
|
constructor(_config: unknown) {}
|
|
|
|
setOptions() {}
|
|
|
|
onResults(callback: (results: { poseLandmarks: ReturnType<typeof buildFakeLandmarks> }) => void) {
|
|
this.callback = callback;
|
|
}
|
|
|
|
async send() {
|
|
this.callback?.({ poseLandmarks: buildFakeLandmarks() });
|
|
}
|
|
|
|
close() {}
|
|
}
|
|
|
|
Object.defineProperty(window, "__TEST_MEDIAPIPE_FACTORY__", {
|
|
configurable: true,
|
|
value: async () => ({ Pose: FakePose }),
|
|
});
|
|
|
|
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
|
configurable: true,
|
|
value: async () => undefined,
|
|
});
|
|
|
|
Object.defineProperty(HTMLCanvasElement.prototype, "captureStream", {
|
|
configurable: true,
|
|
value: () => new MediaStream(),
|
|
});
|
|
|
|
class FakeMediaRecorder extends EventTarget {
|
|
state = "inactive";
|
|
mimeType = "video/webm";
|
|
|
|
constructor(_stream: MediaStream, options?: { mimeType?: string }) {
|
|
super();
|
|
this.mimeType = options?.mimeType || "video/webm";
|
|
}
|
|
|
|
static isTypeSupported() {
|
|
return true;
|
|
}
|
|
|
|
start() {
|
|
this.state = "recording";
|
|
}
|
|
|
|
requestData() {
|
|
if (this.state !== "recording") return;
|
|
const event = new Event("dataavailable") as Event & { data?: Blob };
|
|
event.data = new Blob(["segment"], { type: this.mimeType });
|
|
const handler = (this as unknown as { ondataavailable?: (evt: Event & { data?: Blob }) => void }).ondataavailable;
|
|
handler?.(event);
|
|
this.dispatchEvent(event);
|
|
}
|
|
|
|
stop() {
|
|
if (this.state === "inactive") return;
|
|
this.requestData();
|
|
this.state = "inactive";
|
|
const stopEvent = new Event("stop");
|
|
const handler = (this as unknown as { onstop?: () => void }).onstop;
|
|
handler?.();
|
|
this.dispatchEvent(stopEvent);
|
|
}
|
|
}
|
|
|
|
class FakeRTCPeerConnection extends EventTarget {
|
|
connectionState = "new";
|
|
iceGatheringState = "new";
|
|
localDescription: { type: string; sdp: string } | null = null;
|
|
remoteDescription: { type: string; sdp: string } | null = null;
|
|
onconnectionstatechange: (() => void) | null = null;
|
|
ontrack: ((event: { streams: MediaStream[] }) => void) | null = null;
|
|
|
|
addTrack() {}
|
|
|
|
addTransceiver() {}
|
|
|
|
async createOffer() {
|
|
return { type: "offer", sdp: "mock-offer" };
|
|
}
|
|
|
|
async setLocalDescription(description: { type: string; sdp: string }) {
|
|
this.localDescription = description;
|
|
this.iceGatheringState = "complete";
|
|
this.dispatchEvent(new Event("icegatheringstatechange"));
|
|
}
|
|
|
|
async setRemoteDescription(description: { type: string; sdp: string }) {
|
|
this.remoteDescription = description;
|
|
this.connectionState = "connected";
|
|
this.ontrack?.({ streams: [new MediaStream()] });
|
|
this.onconnectionstatechange?.();
|
|
}
|
|
|
|
close() {
|
|
this.connectionState = "closed";
|
|
this.onconnectionstatechange?.();
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(window, "MediaRecorder", {
|
|
configurable: true,
|
|
value: FakeMediaRecorder,
|
|
});
|
|
|
|
Object.defineProperty(window, "RTCPeerConnection", {
|
|
configurable: true,
|
|
value: FakeRTCPeerConnection,
|
|
});
|
|
|
|
Object.defineProperty(navigator, "mediaDevices", {
|
|
configurable: true,
|
|
value: {
|
|
getUserMedia: async () => new MediaStream(),
|
|
enumerateDevices: async () => [
|
|
{ deviceId: "cam-1", kind: "videoinput", label: "Front Camera", groupId: "g1" },
|
|
{ deviceId: "cam-2", kind: "videoinput", label: "Back Camera", groupId: "g1" },
|
|
],
|
|
addEventListener: () => undefined,
|
|
removeEventListener: () => undefined,
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/trpc/**", (route) => handleTrpc(route, state));
|
|
await page.route("**/media/**", (route) => handleMedia(route, state));
|
|
|
|
return state;
|
|
}
|