fix: address final review — 404 error field, aggregate estFps, recent entries, panel props

This commit is contained in:
2026-07-13 05:30:17 +08:00
parent d63ec4a42e
commit bfd36ad632
4 changed files with 105 additions and 24 deletions
@@ -40,10 +40,10 @@ import { buildPluginGameResponse } from "@/lib/api/plugin-public"
describe("buildPluginGameResponse — shape contract", () => { describe("buildPluginGameResponse — shape contract", () => {
beforeEach(() => vi.clearAllMocks()) 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 }) const r = await buildPluginGameResponse({ game: null })
expect(r.game).toBeNull() expect(r.game).toBeNull()
expect(typeof r.error).toBe("string") expect(r.error).toBeUndefined()
expect(r.estFps).toBeNull() expect(r.estFps).toBeNull()
expect(r.topEntries).toEqual([]) expect(r.topEntries).toEqual([])
expect(r.recentEntries).toEqual([]) expect(r.recentEntries).toEqual([])
@@ -78,6 +78,33 @@ describe("buildPluginGameResponse — shape contract", () => {
expect(r.topEntries[0].id).toBe("e1") // pinned first 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 () => { it("handles all-null fps values gracefully", async () => {
const entries = [ const entries = [
{ id: "e1", hardwareSlug: "steamdeck-oled", fpsAvg: 60, fpsLow: null, fpsOnePercentLow: null, fpsHigh: null, { id: "e1", hardwareSlug: "steamdeck-oled", fpsAvg: 60, fpsLow: null, fpsOnePercentLow: null, fpsHigh: null,
+21 -7
View File
@@ -71,22 +71,23 @@ export async function buildPluginGameResponse(args: {
game: PluginGameRow | null game: PluginGameRow | null
entries?: PluginEntryRow[] entries?: PluginEntryRow[]
recent?: PluginEntryRow[] recent?: PluginEntryRow[]
estFps?: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null
}): Promise<PluginGameResponse> { }): Promise<PluginGameResponse> {
if (!args.game) { 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 entries = args.entries ?? []
const recent = args.recent ?? [] const recent = args.recent ?? []
if (entries.length === 0) { // Use provided estFps if available, otherwise compute from entries
return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] } const estFps = args.estFps ?? (entries.length > 0 ? {
} avg: Math.round((entries.reduce((a, b) => a + b.fpsAvg, 0) / entries.length) * 10) / 10,
const fpsAvgVals = entries.map((e) => e.fpsAvg)
const estFps = {
avg: Math.round((fpsAvgVals.reduce((a, b) => a + b, 0) / fpsAvgVals.length) * 10) / 10,
low: entries.reduce<number | null>((m, e) => (m == null ? e.fpsLow : Math.min(m, e.fpsLow ?? m)), null), 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), 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), high: entries.reduce<number | null>((m, e) => (m == null ? e.fpsHigh : Math.max(m, e.fpsHigh ?? m)), null),
count: entries.length, count: entries.length,
} : null)
if (!estFps) {
return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] }
} }
const topEntries = entries.map(trimEntry) const topEntries = entries.map(trimEntry)
const recentEntries = recent.map(trimEntry) const recentEntries = recent.map(trimEntry)
@@ -206,12 +207,25 @@ export const pluginPublicRoutes = new Elysia({
.orderBy(desc(performanceEntries.createdAt)) .orderBy(desc(performanceEntries.createdAt))
.limit(query.limit ?? 3) .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["Cache-Control"] = "public, max-age=60"
set.headers["Vary"] = "search-params" set.headers["Vary"] = "search-params"
return await buildPluginGameResponse({ return await buildPluginGameResponse({
game, game,
entries: topRows as unknown as PluginEntryRow[], entries: topRows as unknown as PluginEntryRow[],
recent: recentRows 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,7 +140,9 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
</div> </div>
</PanelSectionRow> </PanelSectionRow>
{/* Est FPS */} {/* Est FPS — hidden when "All devices" selected to avoid mixing hardware */}
{device ? (
<>
<PanelSectionRow> <PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}> <div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
<FaChartLine /> Est FPS <FaChartLine /> Est FPS
@@ -160,6 +162,14 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
</div> </div>
</PanelSectionRow> </PanelSectionRow>
)} )}
</>
) : (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "4px 0" }}>
Select a device to see estimated FPS.
</div>
</PanelSectionRow>
)}
{/* Device switcher */} {/* Device switcher */}
<PanelSectionRow> <PanelSectionRow>
@@ -182,6 +192,18 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
{data.topEntries.map((e) => <EntryCard key={e.id} e={e} />)} {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> </PanelSection>
) )
} }
@@ -7,6 +7,7 @@ import {
import { routerHook } from "@decky/api" import { routerHook } from "@decky/api"
import type { ReactElement } from "react" import type { ReactElement } from "react"
import LibraryAppPanel from "../components/LibraryAppPanel" import LibraryAppPanel from "../components/LibraryAppPanel"
import { getSettings } from "../lib/api"
// Mirror of HLTB-for-Deck's patchAppPage, guarded so a Steam UI change // Mirror of HLTB-for-Deck's patchAppPage, guarded so a Steam UI change
// degrades to "section not shown" instead of crashing Steam. // degrades to "section not shown" instead of crashing Steam.
@@ -20,7 +21,24 @@ export function setLibraryAppPanelProps(p: { hardwareSlug: string | null; baseUr
panelProps = p 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() { 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) => { return routerHook.addPatch("/library/app/:appid", (routerTree: any) => {
try { try {
const routeProps = findInReactTree(routerTree, (x: any) => x?.renderFunc) const routeProps = findInReactTree(routerTree, (x: any) => x?.renderFunc)