文件
tennis-training-hub/tests/e2e/helpers/mockApp.ts
2026-03-14 21:45:31 +08:00

420 行
11 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[];
mediaSession: MockMediaSession | null;
nextVideoId: 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: [],
};
}
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":
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(null);
case "plan.list":
return trpcResult([]);
case "video.list":
return trpcResult(state.videos);
case "analysis.list":
return trpcResult(state.analyses);
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;
}
) {
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(),
},
],
mediaSession: null,
nextVideoId: 100,
};
await page.addInitScript(() => {
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;
}