Add auto archived overlay recordings for live analysis
这个提交包含在:
@@ -68,6 +68,28 @@ test("live camera starts analysis and produces scores", async ({ page }) => {
|
||||
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
||||
});
|
||||
|
||||
test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true, videos: [] });
|
||||
|
||||
await page.goto("/live-camera");
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await page.getByRole("button", { name: /启用摄像头/ }).click();
|
||||
|
||||
await expect(page.getByTestId("live-camera-analyze-button")).toBeVisible();
|
||||
await page.getByTestId("live-camera-analyze-button").click();
|
||||
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "结束分析" }).click();
|
||||
await expect(page.getByText("分析结果已保存")).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
await page.goto("/videos");
|
||||
await expect(page.getByTestId("video-card")).toHaveCount(1);
|
||||
await expect(page.getByText("实时分析录像").first()).toBeVisible();
|
||||
await expect(page.getByText("实时分析").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("recorder flow archives a session and exposes it in videos", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true, videos: [] });
|
||||
|
||||
|
||||
@@ -10,6 +10,19 @@ type MockUser = {
|
||||
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;
|
||||
@@ -103,6 +116,19 @@ function buildUser(name = "TestPlayer"): MockUser {
|
||||
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,
|
||||
@@ -115,6 +141,7 @@ function buildUser(name = "TestPlayer"): MockUser {
|
||||
}
|
||||
|
||||
function buildStats(user: MockUser) {
|
||||
const hasSystemNtrp = user.ntrpRating != null;
|
||||
return {
|
||||
ntrpRating: user.ntrpRating,
|
||||
totalSessions: user.totalSessions,
|
||||
@@ -186,9 +213,45 @@ function buildStats(user: MockUser) {
|
||||
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",
|
||||
@@ -254,7 +317,7 @@ async function fulfillJson(route: Route, body: unknown) {
|
||||
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) => {
|
||||
const results = await Promise.all(operations.map(async (operation, operationIndex) => {
|
||||
switch (operation) {
|
||||
case "auth.me":
|
||||
if (state.authenticated && state.authMeNullResponsesAfterLogin > 0) {
|
||||
@@ -267,16 +330,34 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
||||
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":
|
||||
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: "beginner",
|
||||
durationDays: 7,
|
||||
skillLevel,
|
||||
durationDays,
|
||||
version: 1,
|
||||
adjustmentNotes: null,
|
||||
exercises: [
|
||||
@@ -305,7 +386,7 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
||||
return trpcResult({
|
||||
taskId: createTask(state, {
|
||||
type: "training_plan_generate",
|
||||
title: "7天训练计划生成",
|
||||
title: `${durationDays}天训练计划生成`,
|
||||
result: {
|
||||
kind: "training_plan_generate",
|
||||
planId: state.activePlan.id,
|
||||
@@ -313,6 +394,7 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
||||
},
|
||||
}).id,
|
||||
});
|
||||
}
|
||||
case "plan.adjust":
|
||||
return trpcResult({
|
||||
taskId: createTask(state, {
|
||||
@@ -326,6 +408,22 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
||||
});
|
||||
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":
|
||||
@@ -465,7 +563,7 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
||||
default:
|
||||
return trpcResult(null);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
await fulfillJson(route, results);
|
||||
}
|
||||
@@ -655,6 +753,11 @@ export async function installAppMocks(
|
||||
value: async () => undefined,
|
||||
});
|
||||
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "captureStream", {
|
||||
configurable: true,
|
||||
value: () => new MediaStream(),
|
||||
});
|
||||
|
||||
class FakeMediaRecorder extends EventTarget {
|
||||
state = "inactive";
|
||||
mimeType = "video/webm";
|
||||
|
||||
在新工单中引用
屏蔽一个用户