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, }, ], }; } 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, 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 "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 }); 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, 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 }) => 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; }