fix: address final review — 404 error field, aggregate estFps, recent entries, panel props
This commit is contained in:
@@ -40,10 +40,10 @@ import { buildPluginGameResponse } from "@/lib/api/plugin-public"
|
||||
describe("buildPluginGameResponse — shape contract", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("returns { game: null, error } shape when game is missing", async () => {
|
||||
it("returns { game: null } shape (no error) when game is missing", async () => {
|
||||
const r = await buildPluginGameResponse({ game: null })
|
||||
expect(r.game).toBeNull()
|
||||
expect(typeof r.error).toBe("string")
|
||||
expect(r.error).toBeUndefined()
|
||||
expect(r.estFps).toBeNull()
|
||||
expect(r.topEntries).toEqual([])
|
||||
expect(r.recentEntries).toEqual([])
|
||||
@@ -78,6 +78,33 @@ describe("buildPluginGameResponse — shape contract", () => {
|
||||
expect(r.topEntries[0].id).toBe("e1") // pinned first
|
||||
})
|
||||
|
||||
it("uses provided estFps aggregate over entries-derived computation", async () => {
|
||||
const entries = [
|
||||
{ id: "e1", hardwareSlug: "steamdeck-oled", fpsAvg: 60, fpsLow: 40, fpsOnePercentLow: 45, fpsHigh: 90,
|
||||
upscalerType: "none", frameGenMethod: "none", protonVersion: "9", osVersion: "SteamOS 3", tdpWatts: 12,
|
||||
settingsJson: null, upvotes: 5, isPinned: true, createdAt: new Date("2026-01-01"),
|
||||
userName: "u", userImage: null },
|
||||
{ id: "e2", hardwareSlug: "steamdeck-oled", fpsAvg: 80, fpsLow: 55, fpsOnePercentLow: 60, fpsHigh: 120,
|
||||
upscalerType: "fsr", frameGenMethod: "none", protonVersion: "9", osVersion: "SteamOS 3", tdpWatts: 15,
|
||||
settingsJson: null, upvotes: 2, isPinned: false, createdAt: new Date("2026-02-01"),
|
||||
userName: "u2", userImage: null },
|
||||
]
|
||||
// Aggregate says 25 entries averaging 72.3, distinct from the 2-row top-3 avg of 70
|
||||
const r = await buildPluginGameResponse({
|
||||
game: { id: "g1", steamAppId: 123, title: "X", slug: "x" },
|
||||
entries, recent: entries,
|
||||
estFps: { avg: 72.3, low: 35, onePct: 38, high: 140, count: 25 },
|
||||
})
|
||||
expect(r.estFps).not.toBeNull()
|
||||
expect(r.estFps!.avg).toBe(72.3)
|
||||
expect(r.estFps!.count).toBe(25)
|
||||
expect(r.estFps!.high).toBe(140)
|
||||
expect(r.estFps!.low).toBe(35)
|
||||
// Entries are still trimmed/rendered from the provided rows
|
||||
expect(r.topEntries.length).toBe(2)
|
||||
expect(r.recentEntries.length).toBe(2)
|
||||
})
|
||||
|
||||
it("handles all-null fps values gracefully", async () => {
|
||||
const entries = [
|
||||
{ id: "e1", hardwareSlug: "steamdeck-oled", fpsAvg: 60, fpsLow: null, fpsOnePercentLow: null, fpsHigh: null,
|
||||
|
||||
@@ -71,22 +71,23 @@ export async function buildPluginGameResponse(args: {
|
||||
game: PluginGameRow | null
|
||||
entries?: PluginEntryRow[]
|
||||
recent?: PluginEntryRow[]
|
||||
estFps?: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null
|
||||
}): Promise<PluginGameResponse> {
|
||||
if (!args.game) {
|
||||
return { game: null, estFps: null, topEntries: [], recentEntries: [], error: "Game not in DeckyVault" }
|
||||
return { game: null, estFps: null, topEntries: [], recentEntries: [] }
|
||||
}
|
||||
const entries = args.entries ?? []
|
||||
const recent = args.recent ?? []
|
||||
if (entries.length === 0) {
|
||||
return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] }
|
||||
}
|
||||
const fpsAvgVals = entries.map((e) => e.fpsAvg)
|
||||
const estFps = {
|
||||
avg: Math.round((fpsAvgVals.reduce((a, b) => a + b, 0) / fpsAvgVals.length) * 10) / 10,
|
||||
// Use provided estFps if available, otherwise compute from entries
|
||||
const estFps = args.estFps ?? (entries.length > 0 ? {
|
||||
avg: Math.round((entries.reduce((a, b) => a + b.fpsAvg, 0) / entries.length) * 10) / 10,
|
||||
low: entries.reduce<number | null>((m, e) => (m == null ? e.fpsLow : Math.min(m, e.fpsLow ?? m)), null),
|
||||
onePct: entries.reduce<number | null>((m, e) => (m == null ? e.fpsOnePercentLow : Math.min(m, e.fpsOnePercentLow ?? m)), null),
|
||||
high: entries.reduce<number | null>((m, e) => (m == null ? e.fpsHigh : Math.max(m, e.fpsHigh ?? m)), null),
|
||||
count: entries.length,
|
||||
} : null)
|
||||
if (!estFps) {
|
||||
return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] }
|
||||
}
|
||||
const topEntries = entries.map(trimEntry)
|
||||
const recentEntries = recent.map(trimEntry)
|
||||
@@ -206,12 +207,25 @@ export const pluginPublicRoutes = new Elysia({
|
||||
.orderBy(desc(performanceEntries.createdAt))
|
||||
.limit(query.limit ?? 3)
|
||||
|
||||
// Aggregate estFps from ALL matching entries (not just top-3)
|
||||
const [agg] = await db
|
||||
.select({
|
||||
avg: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
|
||||
low: sql<number | null>`min(${performanceEntries.fpsLow})`,
|
||||
onePct: sql<number | null>`min(${performanceEntries.fpsOnePercentLow})`,
|
||||
high: sql<number | null>`max(${performanceEntries.fpsHigh})`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(baseWhere)
|
||||
|
||||
set.headers["Cache-Control"] = "public, max-age=60"
|
||||
set.headers["Vary"] = "search-params"
|
||||
return await buildPluginGameResponse({
|
||||
game,
|
||||
entries: topRows as unknown as PluginEntryRow[],
|
||||
recent: recentRows as unknown as PluginEntryRow[],
|
||||
estFps: agg.avg ? { avg: agg.avg, low: agg.low, onePct: agg.onePct, high: agg.high, count: agg.count } : null,
|
||||
})
|
||||
},
|
||||
{
|
||||
|
||||
@@ -140,23 +140,33 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
|
||||
{/* Est FPS */}
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<FaChartLine /> Est FPS
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
{data.estFps ? (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "0 0 6px 0" }}>
|
||||
<strong>{data.estFps.avg}</strong> avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high
|
||||
<span style={{ opacity: 0.5, fontSize: "11px" }}> · {data.estFps.count} entries</span>
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
{/* Est FPS — hidden when "All devices" selected to avoid mixing hardware */}
|
||||
{device ? (
|
||||
<>
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<FaChartLine /> Est FPS
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
{data.estFps ? (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "0 0 6px 0" }}>
|
||||
<strong>{data.estFps.avg}</strong> avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high
|
||||
<span style={{ opacity: 0.5, fontSize: "11px" }}> · {data.estFps.count} entries</span>
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
) : (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "0 0 6px 0" }}>
|
||||
No entries for this device yet — be the first: open the DeckyVault plugin and record.
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "0 0 6px 0" }}>
|
||||
No entries for this device yet — be the first: open the DeckyVault plugin and record.
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "4px 0" }}>
|
||||
Select a device to see estimated FPS.
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
@@ -182,6 +192,18 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
|
||||
{data.topEntries.map((e) => <EntryCard key={e.id} e={e} />)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Recent entries */}
|
||||
{data.recentEntries.length > 0 && (
|
||||
<>
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "8px 0 2px 0", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Recent entries
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
{data.recentEntries.map((e) => <EntryCard key={e.id} e={e} />)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { routerHook } from "@decky/api"
|
||||
import type { ReactElement } from "react"
|
||||
import LibraryAppPanel from "../components/LibraryAppPanel"
|
||||
import { getSettings } from "../lib/api"
|
||||
|
||||
// Mirror of HLTB-for-Deck's patchAppPage, guarded so a Steam UI change
|
||||
// degrades to "section not shown" instead of crashing Steam.
|
||||
@@ -20,7 +21,24 @@ export function setLibraryAppPanelProps(p: { hardwareSlug: string | null; baseUr
|
||||
panelProps = p
|
||||
}
|
||||
|
||||
// Read settings directly from the Python backend so the panel has them even
|
||||
// if the QAM Content tab has never mounted (which is what populates panelProps
|
||||
// via setLibraryAppPanelProps). Keeps hardwareSlug/baseUrl in sync eagerly.
|
||||
async function loadPanelProps() {
|
||||
try {
|
||||
const s = await getSettings()
|
||||
panelProps = {
|
||||
hardwareSlug: (s.hardwareSlug as string) || null,
|
||||
baseUrl: (s.baseUrl as string) || "https://deckyvault.xyz",
|
||||
}
|
||||
} catch {
|
||||
// Use defaults — panelProps retains its last known value
|
||||
}
|
||||
}
|
||||
|
||||
export function registerLibraryAppPatch() {
|
||||
// Load settings eagerly so the panel has them even if Content never mounted
|
||||
loadPanelProps()
|
||||
return routerHook.addPatch("/library/app/:appid", (routerTree: any) => {
|
||||
try {
|
||||
const routeProps = findInReactTree(routerTree, (x: any) => x?.renderFunc)
|
||||
|
||||
Reference in New Issue
Block a user