Spaces:
Running
Running
Download tests/agent-chat.test.ts from Shoraky/Sporalize-api: direct link, hf CLI and curl.
- Browser
- Download file 20.1 kB
-
https://huggingface.co/spaces/Shoraky/Sporalize-api/resolve/main/tests/agent-chat.test.ts
- Command line
-
hf download hf://spaces/Shoraky/Sporalize-api/tests/agent-chat.test.ts
-
curl -L -o agent-chat.test.ts https://huggingface.co/spaces/Shoraky/Sporalize-api/resolve/main/tests/agent-chat.test.ts
20.1 kB
| import assert from "node:assert/strict"; | |
| import { spawn } from "node:child_process"; | |
| import { once } from "node:events"; | |
| import { createServer, type IncomingHttpHeaders } from "node:http"; | |
| import type { AddressInfo } from "node:net"; | |
| import { test } from "node:test"; | |
| test("action chat sends measurements and annotated references on every message and preserves access checks", { timeout: 30_000 }, async () => { | |
| const owner = "11111111-1111-4111-8111-111111111111"; | |
| const other = "22222222-2222-4222-8222-222222222222"; | |
| const sessionId = "33333333-3333-4333-8333-333333333333"; | |
| const actionId = "44444444-4444-4444-8444-444444444444"; | |
| const otherActionId = "55555555-5555-4555-8555-555555555555"; | |
| const referenceSessionId = "77777777-7777-4777-8777-777777777777"; | |
| const foreignSessionId = "88888888-8888-4888-8888-888888888888"; | |
| const analytics = { foot_region: "Toe", active_foot_speed: 1232.04, standing_foot_contact_seconds: 0, | |
| ball_speed: 1303.31, touch_frame: 766, minimum_knee_angle: null, future_metric: false }; | |
| const measured = { id: actionId, label: "Pass", rawAnalytics: analytics, | |
| inActionMetrics: Object.entries(analytics).map(([name, value]) => ({ name, value, unit: name === "active_foot_speed" ? "cm/s" : "" })) }; | |
| const otherMeasured = { id: otherActionId, label: "Shoot", rawAnalytics: { ball_speed: 1303.31, active_foot_speed: 1232.04 }, | |
| inActionMetrics: [{ name: "Ball Speed", value: 1303.31, unit: "cm/s" }, { name: "Active Foot Speed", value: 1232.04, unit: "cm/s" }] }; | |
| let role = "user"; | |
| let sessionOwner = owner; | |
| let resultsAvailable = true; | |
| let analysisStatus = 200; | |
| let analysisReads = 0; | |
| let analysisHeaders: IncomingHttpHeaders = {}; | |
| let savedFeedbackAvailable = false; | |
| let referencesAvailable = true; | |
| let annotationsFail = false; | |
| let referenceResultsAvailable = true; | |
| let referenceReads = 0; | |
| let mutations = 0; | |
| const referenceAnalytics = { active_foot: "left", ball_speed: 1303.31, active_foot_speed: 1232.04, standing_foot_contact_seconds: 0, touch_frame: 999999 }; | |
| const offsets: Record<string, number> = { "00-blank": 0, "02-reference": 0, "00-foreign": 5, "03-reference": 300, | |
| "04-reference": 200, "05-reference": 100, "06-reference": 50, "07-reference": 20, "08-reference": 10, "zz-late": 1, "zz-wrong-report": 0 }; | |
| const referenceUsers = new Set<string>(); | |
| const reference = (id: string, type = "Pass", userId = owner, score: number | null = 7, notes = ["Reference coach note."]) => ({ | |
| action_id: id, score, annotations_notes: notes.map((note) => ({ note })), | |
| actions: { action_id: id, session_id: userId === owner ? referenceSessionId : foreignSessionId, type, | |
| sessions: { user_id: userId, players: { gender: "female", bdate: "2000-01-01", height: 170, weight: 65, position: "Forward" } } }, | |
| }); | |
| const examples = [reference(actionId), reference("00-foreign", "Pass", other, null), reference("shoot-reference", "Shoot"), | |
| reference("00-blank", "Pass", owner, null, [" "]), reference("01-empty"), reference("01-missing"), | |
| reference("02-reference", "Pass", owner, 0, []), reference("03-reference", "Pass", owner, null), | |
| ...Array.from({ length: 5 }, (_, index) => reference(`${String(index + 4).padStart(2, "0")}-reference`)), | |
| ...Array.from({ length: 110 }, (_, index) => reference(`far-${String(index).padStart(3, "0")}`)), | |
| reference("zz-late", "Pass", other, 0), reference("zz-no-common"), reference("zz-wrong-report")]; | |
| const referenceReport = (item: ReturnType<typeof reference>) => { | |
| const rawAnalytics: Record<string, unknown> = item.action_id === "01-empty" ? {} : item.action_id === "zz-no-common" ? { unrelated_metric: 42 } | |
| : { ...referenceAnalytics, active_foot_speed: referenceAnalytics.active_foot_speed + (offsets[item.action_id] ?? 500) }; | |
| return { id: item.action_id, label: item.action_id === "zz-wrong-report" ? "Shoot" : item.actions.type, rawAnalytics, | |
| inActionMetrics: Object.entries(rawAnalytics).map(([name, value]) => ({ name, value, unit: "" })) }; | |
| }; | |
| type Reference = { session_id: string; action_id: string; action_type: string; player: { gender: string }; score: number | null; notes: string[]; | |
| results: { metrics: unknown[]; analytics: unknown } }; | |
| type ChatPayload = { actions: unknown[]; message: string; history: unknown[]; | |
| context: { action?: { action_id: string; results: { metrics: unknown[]; analytics: unknown }; references: Reference[]; feedback: string[]; annotations: unknown }; actions?: unknown[] } }; | |
| const chats: ChatPayload[] = []; | |
| const generations: { actions: { action_id: string; references: Reference[] }[] }[] = []; | |
| let generationOverride: unknown; | |
| const upstream = createServer(async (req, res) => { | |
| const url = new URL(req.url!, "http://stub"); | |
| res.setHeader("Content-Type", "application/json"); | |
| if (url.pathname === "/auth/v1/user") return res.end(JSON.stringify({ id: owner, email: "test@example.com" })); | |
| if (url.pathname === `/api/sessions/${sessionId}`) { | |
| analysisReads++; | |
| analysisHeaders = req.headers; | |
| res.statusCode = analysisStatus; | |
| return res.end(JSON.stringify({ id: sessionId, actions: resultsAvailable ? [otherMeasured, measured] : [] })); | |
| } | |
| if ([`/api/sessions/${referenceSessionId}`, `/api/sessions/${foreignSessionId}`].includes(url.pathname)) { | |
| referenceReads++; | |
| const id = url.pathname.split("/").at(-1); | |
| const expectedOwner = id === referenceSessionId ? owner : other; | |
| referenceUsers.add(String(req.headers["x-sporalize-user-id"])); | |
| if (req.headers["x-sporalize-user-id"] !== expectedOwner) { | |
| res.statusCode = 403; | |
| return res.end("{}"); | |
| } | |
| res.statusCode = referenceResultsAvailable ? 200 : 503; | |
| return res.end(JSON.stringify({ id, actions: examples.filter((item) => item.action_id !== "01-missing" && item.actions.session_id === id).map(referenceReport) })); | |
| } | |
| if (url.pathname === "/api/chat") { | |
| let body = ""; | |
| for await (const chunk of req) body += chunk; | |
| chats.push(JSON.parse(body)); | |
| return res.end(JSON.stringify({ reply: "Measured coaching reply." })); | |
| } | |
| if (url.pathname === "/api/session/generate") { | |
| let body = ""; | |
| for await (const chunk of req) body += chunk; | |
| const input = JSON.parse(body); | |
| generations.push(input); | |
| return res.end(JSON.stringify(generationOverride ?? { actions: input.actions.map((action: { action_id: string }) => ({ action_id: action.action_id, feedback: ["Generated feedback."] })), session_feedback: ["Session summary."] })); | |
| } | |
| const table = url.pathname.split("/").at(-1); | |
| if (req.method !== "GET") { mutations++; return res.end("[]"); } | |
| if (table === "annotations" && annotationsFail) { | |
| res.statusCode = 400; | |
| return res.end(JSON.stringify({ message: "Annotations unavailable." })); | |
| } | |
| let rows: Record<string, unknown>[] = table === "users" ? [{ user_id: owner, account_role: role }] | |
| : table === "users_subscriptions" ? [{ user_id: owner, subscription_id: "plan", startdate: "2000-01-01", enddate: "2099-01-01", plan: { subscription_id: "plan" } }] | |
| : table === "sessions" ? [{ session_id: sessionId, user_id: sessionOwner, name: "Practice", players: { fname: "Player", gender: "male", bdate: "2000-01-01" } }] | |
| : table === "actions" ? [actionId, otherActionId].map((id) => ({ action_id: id, session_id: sessionId, type: id === actionId ? "Pass" : "Shoot", start_frame: 750, end_frame: 780, | |
| annotations: [{ score: 7, annotations_notes: [{ note: "Coach note." }] }] })) | |
| : table === "annotations" ? referencesAvailable ? examples : [] | |
| : table === "actions_feedbacks" ? savedFeedbackAvailable ? [{ action_id: actionId, feedback: "Saved action evaluation." }] : [] | |
| : table === "sessions_feedbacks" ? [{ session_id: sessionId, feedback: "Saved session evaluation." }] : []; | |
| const value = (row: Record<string, unknown>, key: string): unknown => key.split(".").reduce<unknown>((item, part) => (item as Record<string, unknown> | undefined)?.[part], row); | |
| for (const [key, filter] of url.searchParams) { | |
| if (filter.startsWith("eq.")) rows = rows.filter((row) => String(value(row, key)) === filter.slice(3)); | |
| if (filter.startsWith("neq.")) rows = rows.filter((row) => String(value(row, key)) !== filter.slice(4)); | |
| } | |
| if (url.searchParams.get("order") === "action_id.asc") rows = [...rows].sort((a, b) => String(a.action_id).localeCompare(String(b.action_id))); | |
| const from = Number(url.searchParams.get("offset") ?? 0); | |
| const limit = Number(url.searchParams.get("limit") ?? rows.length); | |
| res.end(JSON.stringify(rows.slice(from, from + limit))); | |
| }); | |
| upstream.listen(0, "127.0.0.1"); | |
| await once(upstream, "listening"); | |
| const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; | |
| const portProbe = createServer(); | |
| portProbe.listen(0, "127.0.0.1"); | |
| await once(portProbe, "listening"); | |
| const port = (portProbe.address() as AddressInfo).port; | |
| await new Promise<void>((resolve) => portProbe.close(() => resolve())); | |
| const origin = `http://127.0.0.1:${port}`; | |
| const child = spawn(process.execPath, ["--import", "tsx", "src/server.ts"], { | |
| env: { ...process.env, NODE_ENV: "test", PORT: String(port), FRONTEND_URL: origin, BACKEND_URL: origin, | |
| SUPABASE_URL: upstreamOrigin, SUPABASE_ANON_KEY: "test-anon", SUPABASE_SERVICE_ROLE_KEY: "test-service", | |
| ANALYSIS_API_URL: upstreamOrigin, ANALYSIS_PROXY_SECRET: "x".repeat(32), | |
| AGENT_API_URL: upstreamOrigin, AGENT_API_KEY: "a".repeat(32), HF_TOKEN: "" }, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| try { | |
| let stderr = ""; | |
| child.stderr.on("data", (data) => { stderr += String(data); }); | |
| await new Promise<void>((resolve, reject) => { | |
| child.stdout.on("data", (data) => { if (String(data).includes("API listening")) resolve(); }); | |
| child.once("exit", (code) => reject(new Error(`API exited: ${code}\n${stderr}`))); | |
| child.once("error", reject); | |
| }); | |
| const request = (input: Record<string, unknown>, authenticated = true) => fetch(`${origin}/api/agent/chat`, { | |
| method: "POST", signal: AbortSignal.timeout(10000), | |
| headers: { "Content-Type": "application/json", Origin: origin, ...(authenticated ? { Cookie: "sp_access=access" } : {}) }, | |
| body: JSON.stringify({ message: "Explain these measurements.", ...input }), | |
| }); | |
| const selected = { sessionId, actionId }; | |
| assert.equal((await request(selected, false)).status, 401); | |
| assert.equal(analysisReads, 0); | |
| assert.equal((await request(selected)).status, 200); | |
| assert.equal(analysisReads, 1); | |
| assert.deepEqual(chats[0].context.action?.results, { metrics: measured.inActionMetrics, analytics }); | |
| assert.deepEqual(chats[0].context.action?.feedback, []); | |
| const expectedReferences = ["zz-late", "00-foreign", "08-reference", "07-reference", "06-reference"]; | |
| assert.deepEqual(chats[0].context.action?.references.map((item) => item.action_id), expectedReferences); | |
| assert.ok(chats[0].context.action?.references.every((item) => item.action_type === "Pass" && item.player.gender === "female")); | |
| const nearest = referenceReport(examples.find((item) => item.action_id === "zz-late")!); | |
| assert.deepEqual(chats[0].context.action?.references[0].results, { metrics: nearest.inActionMetrics, analytics: nearest.rawAnalytics }); | |
| assert.equal(chats[0].context.action?.references[0].session_id, foreignSessionId, "comparisons include other accounts with their real session IDs"); | |
| assert.equal(chats[0].context.action?.references[0].score, 0); | |
| assert.equal(chats[0].context.action?.references[1].score, null); | |
| assert.deepEqual(chats[0].context.action?.references[0].notes, ["Reference coach note."]); | |
| assert.equal(referenceReads, 2, "each session is fetched once across all candidate pages"); | |
| assert.deepEqual(referenceUsers, new Set([owner, other])); | |
| assert.deepEqual(chats[0].context.action?.annotations, [{ score: 7, annotations_notes: [{ note: "Coach note." }] }]); | |
| assert.equal(analysisHeaders["x-sporalize-user-id"], owner); | |
| assert.equal(analysisHeaders["x-sporalize-proxy-secret"], "x".repeat(32)); | |
| assert.equal(analysisHeaders["x-sporalize-admin"], undefined); | |
| analytics.ball_speed = 1500.123456; | |
| measured.inActionMetrics.find((metric) => metric.name === "ball_speed")!.value = analytics.ball_speed; | |
| savedFeedbackAvailable = true; | |
| referenceAnalytics.ball_speed = 1400.75; | |
| const history = [{ role: "user", content: "Explain these measurements." }, { role: "assistant", content: "Previous reply." }]; | |
| assert.equal((await request({ ...selected, message: "What changed?", history })).status, 200); | |
| assert.equal(analysisReads, 2); | |
| assert.deepEqual(chats[1].context.action?.results, { metrics: measured.inActionMetrics, analytics }); | |
| assert.deepEqual(chats[1].context.action?.feedback, ["Saved action evaluation."]); | |
| const updatedNearest = referenceReport(examples.find((item) => item.action_id === "zz-late")!); | |
| assert.deepEqual(chats[1].context.action?.references[0].results, { metrics: updatedNearest.inActionMetrics, analytics: updatedNearest.rawAnalytics }); | |
| assert.equal(referenceReads, 4, "each message gets current reference measurements"); | |
| assert.deepEqual(chats[1].history, history); | |
| assert.equal((await request({ sessionId, actionId: otherActionId })).status, 200); | |
| assert.deepEqual(chats[2].context.action?.results, { metrics: otherMeasured.inActionMetrics, analytics: otherMeasured.rawAnalytics }); | |
| assert.deepEqual(chats[2].context.action?.references.map((item) => item.action_id), ["shoot-reference"]); | |
| const chatCount = chats.length; | |
| const readCount = analysisReads; | |
| sessionOwner = other; | |
| assert.equal((await request(selected)).status, 404); | |
| assert.equal(analysisReads, readCount, "unauthorized requests must not load measurements"); | |
| assert.equal(chats.length, chatCount); | |
| role = "admin"; | |
| assert.equal((await request(selected)).status, 200); | |
| assert.equal(analysisHeaders["x-sporalize-admin"], "true"); | |
| assert.equal(analysisHeaders["x-sporalize-user-id"], owner); | |
| assert.deepEqual(chats.at(-1)?.context.action?.results, { metrics: measured.inActionMetrics, analytics }); | |
| role = "user"; | |
| sessionOwner = owner; | |
| referencesAvailable = false; | |
| assert.equal((await request(selected)).status, 200); | |
| assert.deepEqual(chats.at(-1)?.context.action?.references, []); | |
| referencesAvailable = true; | |
| referenceResultsAvailable = false; | |
| const readsBeforeMissing = referenceReads; | |
| assert.equal((await request(selected)).status, 200); | |
| assert.deepEqual(chats.at(-1)?.context.action?.references, []); | |
| assert.equal(referenceReads, readsBeforeMissing + 2, "unavailable reference results are not retried for each candidate page"); | |
| referenceResultsAvailable = true; | |
| const chatsBeforeFailure = chats.length; | |
| annotationsFail = true; | |
| assert.equal((await request(selected)).status, 500); | |
| assert.equal(chats.length, chatsBeforeFailure, "a DB failure must not be presented as an empty search"); | |
| annotationsFail = false; | |
| const successfulChats = chats.length; | |
| resultsAvailable = false; | |
| assert.equal((await request(selected)).status, 404); | |
| analysisStatus = 503; | |
| assert.equal((await request(selected)).status, 500); | |
| assert.equal(chats.length, successfulChats, "missing results must not produce an ungrounded reply"); | |
| const failedReads = analysisReads; | |
| assert.equal((await request({ actionId })).status, 400); | |
| assert.equal((await request({ ...selected, actionId: "66666666-6666-4666-8666-666666666666" })).status, 404); | |
| assert.equal(analysisReads, failedReads); | |
| assert.equal((await request({ sessionId })).status, 200); | |
| assert.equal((await request({})).status, 200); | |
| assert.equal(analysisReads, failedReads, "general and session chats do not need action measurements"); | |
| assert.equal(chats.at(-1)?.context.action, undefined); | |
| assert.equal(mutations, 0, "chat must not save or replace feedback"); | |
| analysisStatus = 200; | |
| resultsAvailable = true; | |
| Object.assign(process.env, { FRONTEND_URL: origin, BACKEND_URL: origin, SUPABASE_URL: upstreamOrigin, | |
| SUPABASE_ANON_KEY: "test-anon", SUPABASE_SERVICE_ROLE_KEY: "test-service", ANALYSIS_API_URL: upstreamOrigin, | |
| ANALYSIS_PROXY_SECRET: "x".repeat(32), AGENT_API_URL: upstreamOrigin, AGENT_API_KEY: "a".repeat(32), HF_TOKEN: "" }); | |
| const { createClient } = await import("@supabase/supabase-js"); | |
| const { actionDistance, generateSessionFeedback } = await import("../src/agent-session.js"); | |
| const sample = (rawAnalytics: Record<string, unknown>) => ({ id: "sample", label: "Pass", rawAnalytics }); | |
| assert.equal(actionDistance(sample({ speed: 0, touch_frame: 1 }), sample({ speed: 0, touch_frame: 9999 })), 0); | |
| assert.equal(actionDistance(sample({ missing: null, flag: false, region: "Toe" }), sample({ speed: 0 })), null); | |
| assert.equal(actionDistance(sample({ speed: 10 }), sample({ speed: "10" })), null); | |
| assert.equal(actionDistance(sample({ speed: 10 }), sample({ unrelated: 10 })), null); | |
| assert.ok(actionDistance(sample({ speed: 1000, angle: 100 }), sample({ speed: 1010, angle: 101 }))! | |
| < actionDistance(sample({ speed: 1000, angle: 100 }), sample({ speed: 1000 }))!, "missing values must not outrank complete close measurements"); | |
| assert.equal(actionDistance(sample({ ball_speed: 10, touch_frame: 1 }), sample({ ball_speed: 9999, touch_frame: 9999 })), null); | |
| assert.equal(actionDistance(sample({ active_foot_speed: 10, ball_speed: 10, touch_frame: 1 }), { id: "metric", label: "Pass", | |
| inActionMetrics: [{ name: "Active Foot Speed", value: 10 }, { name: "Ball Speed", value: 9999 }, { name: "Touch Frame", value: 9999 }] }), 0); | |
| const db = createClient(upstreamOrigin, "test-service", { auth: { persistSession: false } }); | |
| const readsBeforeGeneration = referenceReads; | |
| await generateSessionFeedback(db, sessionId, owner); | |
| assert.equal(referenceReads - readsBeforeGeneration, 2, "all actions in a generation share reference session downloads"); | |
| assert.deepEqual(generations[0].actions.find((item) => item.action_id === actionId)?.references.map((item) => item.action_id), expectedReferences); | |
| assert.deepEqual(generations[0].actions.find((item) => item.action_id === otherActionId)?.references.map((item) => item.action_id), ["shoot-reference"]); | |
| assert.equal(mutations, 6, "automatic generation still persists feedback for both actions and the session"); | |
| await generateSessionFeedback(db, sessionId, owner); | |
| assert.equal(referenceReads - readsBeforeGeneration, 4, "a new generation fetches fresh reference measurements"); | |
| const mutationsBeforeInvalid = mutations; | |
| for (const invalid of [ | |
| { actions: [{ action_id: "foreign-action", feedback: ["Bad."] }, { action_id: otherActionId, feedback: ["Good."] }], session_feedback: ["Summary."] }, | |
| { actions: [{ action_id: actionId, feedback: ["Bad."] }, { action_id: actionId, feedback: ["Duplicate."] }], session_feedback: ["Summary."] }, | |
| { actions: [{ action_id: actionId, feedback: ["Bad."] }], session_feedback: ["Summary."] }, | |
| { actions: [{ action_id: actionId, feedback: ["Bad."] }, { action_id: otherActionId, feedback: [" "] }], session_feedback: ["Summary."] }, | |
| ]) { | |
| generationOverride = invalid; | |
| await assert.rejects(generateSessionFeedback(db, sessionId, owner), /Agent generation failed/); | |
| assert.equal(mutations, mutationsBeforeInvalid, "invalid agent output cannot change stored feedback"); | |
| } | |
| generationOverride = undefined; | |
| } finally { | |
| if (child.exitCode === null && child.signalCode === null) { | |
| const exited = once(child, "exit"); | |
| child.kill(); | |
| await exited; | |
| } | |
| upstream.closeAllConnections(); | |
| await new Promise<void>((resolve) => upstream.close(() => resolve())); | |
| } | |
| }); | |