Implement live analysis achievements and admin console
这个提交包含在:
@@ -3,6 +3,7 @@ import { appRouter } from "./routers";
|
||||
import { COOKIE_NAME } from "../shared/const";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import * as db from "./db";
|
||||
import * as trainingAutomation from "./trainingAutomation";
|
||||
import { ENV } from "./_core/env";
|
||||
import { sdk } from "./_core/sdk";
|
||||
|
||||
@@ -957,3 +958,173 @@ describe("vision.seedLibrary", () => {
|
||||
await expect(caller.vision.seedLibrary()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("achievement.list", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns achievement progress for authenticated users", async () => {
|
||||
const user = createTestUser({ id: 12 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const listSpy = vi.spyOn(db, "listUserAchievements").mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
key: "training_day_1",
|
||||
name: "开练",
|
||||
description: "完成首个训练日",
|
||||
category: "consistency",
|
||||
rarity: "common",
|
||||
icon: "🎾",
|
||||
metricKey: "training_days",
|
||||
targetValue: 1,
|
||||
tier: 1,
|
||||
isHidden: 0,
|
||||
isActive: 1,
|
||||
sortOrder: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentValue: 1,
|
||||
progressPct: 100,
|
||||
unlockedAt: new Date(),
|
||||
unlocked: true,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await caller.achievement.list();
|
||||
|
||||
expect(listSpy).toHaveBeenCalledWith(12);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as any).key).toBe("training_day_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis.liveSessionSave", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("persists a live session and syncs training data", async () => {
|
||||
const user = createTestUser({ id: 5 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const createSessionSpy = vi.spyOn(db, "createLiveAnalysisSession").mockResolvedValueOnce(101);
|
||||
const createSegmentsSpy = vi.spyOn(db, "createLiveActionSegments").mockResolvedValueOnce();
|
||||
const syncSpy = vi.spyOn(trainingAutomation, "syncLiveTrainingData").mockResolvedValueOnce({
|
||||
recordId: 88,
|
||||
unlocked: ["training_day_1"],
|
||||
});
|
||||
|
||||
const result = await caller.analysis.liveSessionSave({
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
startedAt: Date.now() - 4_000,
|
||||
endedAt: Date.now(),
|
||||
durationMs: 4_000,
|
||||
dominantAction: "forehand",
|
||||
overallScore: 84,
|
||||
postureScore: 82,
|
||||
balanceScore: 78,
|
||||
techniqueScore: 86,
|
||||
footworkScore: 75,
|
||||
consistencyScore: 80,
|
||||
totalActionCount: 3,
|
||||
effectiveSegments: 2,
|
||||
totalSegments: 3,
|
||||
unknownSegments: 1,
|
||||
feedback: ["节奏稳定"],
|
||||
metrics: { sampleCount: 12 },
|
||||
segments: [
|
||||
{
|
||||
actionType: "forehand",
|
||||
isUnknown: false,
|
||||
startMs: 500,
|
||||
endMs: 2_500,
|
||||
durationMs: 2_000,
|
||||
confidenceAvg: 0.82,
|
||||
score: 84,
|
||||
peakScore: 90,
|
||||
frameCount: 24,
|
||||
issueSummary: ["击球点前移"],
|
||||
keyFrames: [500, 1500, 2500],
|
||||
clipLabel: "正手挥拍 00:00 - 00:02",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(createSessionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(createSegmentsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(syncSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 5,
|
||||
sessionId: 101,
|
||||
dominantAction: "forehand",
|
||||
sessionMode: "practice",
|
||||
}));
|
||||
expect(result).toEqual({ sessionId: 101, trainingRecordId: 88 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("rating.refreshMine", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates an async NTRP refresh task for the current user", async () => {
|
||||
const user = createTestUser({ id: 22 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const createTaskSpy = vi.spyOn(db, "createBackgroundTask").mockResolvedValueOnce();
|
||||
|
||||
const result = await caller.rating.refreshMine();
|
||||
|
||||
expect(createTaskSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 22,
|
||||
type: "ntrp_refresh_user",
|
||||
payload: { targetUserId: 22 },
|
||||
}));
|
||||
expect(result.taskId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin.users", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects non-admin users", async () => {
|
||||
const user = createTestUser({ role: "user" });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.admin.users({ limit: 20 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns user list for admin users", async () => {
|
||||
const admin = createTestUser({ id: 1, role: "admin", name: "H1" });
|
||||
const { ctx } = createMockContext(admin);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const usersSpy = vi.spyOn(db, "listUsersForAdmin").mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
name: "H1",
|
||||
role: "admin",
|
||||
ntrpRating: 3.4,
|
||||
totalSessions: 10,
|
||||
totalMinutes: 320,
|
||||
totalShots: 240,
|
||||
currentStreak: 6,
|
||||
longestStreak: 12,
|
||||
createdAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await caller.admin.users({ limit: 20 });
|
||||
|
||||
expect(usersSpy).toHaveBeenCalledWith(20);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as any).name).toBe("H1");
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户