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", () => {
|
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,
|
||||||
|
|||||||
@@ -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,23 +140,33 @@ 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 */}
|
||||||
<PanelSectionRow>
|
{device ? (
|
||||||
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
|
<>
|
||||||
<FaChartLine /> Est FPS
|
<PanelSectionRow>
|
||||||
</div>
|
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
</PanelSectionRow>
|
<FaChartLine /> Est FPS
|
||||||
{data.estFps ? (
|
</div>
|
||||||
<PanelSectionRow>
|
</PanelSectionRow>
|
||||||
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "0 0 6px 0" }}>
|
{data.estFps ? (
|
||||||
<strong>{data.estFps.avg}</strong> avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high
|
<PanelSectionRow>
|
||||||
<span style={{ opacity: 0.5, fontSize: "11px" }}> · {data.estFps.count} entries</span>
|
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "0 0 6px 0" }}>
|
||||||
</div>
|
<strong>{data.estFps.avg}</strong> avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high
|
||||||
</PanelSectionRow>
|
<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>
|
<PanelSectionRow>
|
||||||
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "0 0 6px 0" }}>
|
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "4px 0" }}>
|
||||||
No entries for this device yet — be the first: open the DeckyVault plugin and record.
|
Select a device to see estimated FPS.
|
||||||
</div>
|
</div>
|
||||||
</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)
|
||||||
|
|||||||
Reference in New Issue
Block a user