420 行
11 KiB
TypeScript
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;
|
|
}
|