文件
tennis-training-hub/tests/e2e/helpers/mockApp.ts
2026-03-15 08:05:37 +08:00

755 行
22 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;
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;
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;
nextVideoId: number;
nextTaskId: number;
authMeNullResponsesAfterLogin: 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,
totalSessions: 12,
totalMinutes: 320,
totalShots: 280,
currentStreak: 5,
longestStreak: 12,
createdAt: nowIso(),
updatedAt: nowIso(),
lastSignedIn: nowIso(),
};
}
function buildStats(user: MockUser) {
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,
},
},
};
}
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 = operations.map((operation) => {
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 "plan.active":
return trpcResult(state.activePlan);
case "plan.list":
return trpcResult(state.activePlan ? [state.activePlan] : []);
case "plan.generate":
state.activePlan = {
id: 200,
title: `${state.user.name} 的训练计划`,
skillLevel: "beginner",
durationDays: 7,
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: "7天训练计划生成",
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 "analysis.list":
return trpcResult(state.analyses);
case "analysis.liveSessionList":
return trpcResult([]);
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("/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;
}
) {
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: null,
nextVideoId: 100,
nextTaskId: 1,
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 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,
});
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;
addTrack() {}
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.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;
}