diff --git a/packages/app/cypress/e2e/overview.cy.ts b/packages/app/cypress/e2e/overview.cy.ts index 09ad0245..4d7908b1 100644 --- a/packages/app/cypress/e2e/overview.cy.ts +++ b/packages/app/cypress/e2e/overview.cy.ts @@ -24,6 +24,8 @@ const MATRIX_ROWS = 8; const AGENTX = 'agentx'; const AGENTX_LABEL = 'Long Context Multi-Turn Realistic Agentic Scenario (AgentX)'; const AGENTX_LABEL_ZH = '长上下文多轮真实智能体场景(AgentX)'; +/** Shared by both locales: the scenario is named after its acronym. */ +const AGENTX_SHORT = 'AgentX'; const PAGE_TITLE = 'Inference Cost per Million Tokens'; const PAGE_TITLE_ZH = '推理每百万 token 成本'; @@ -61,6 +63,21 @@ function expectNoHorizontalScroller(testId: string) { }); } +/** + * The row header shows the acronym so the 22%-wide model column stays one line, + * and keeps the full scenario name for assistive tech and the hover title. Both + * strings are in the cell, so assert the two layers rather than their + * concatenation. Scenarios already named by a short label render one node. + */ +function expectAgentxScenario(fullLabel: string) { + cy.get('[data-testid="overview-model-scenario"]') + .should('have.attr', 'title', fullLabel) + .within(() => { + cy.get('.sr-only').should('have.text', fullLabel); + cy.get('[aria-hidden="true"]').should('have.text', AGENTX_SHORT); + }); +} + /** Visible dates and snapshot framing must be gone; evidence stays in labels. */ function expectNoVisibleDatesOrSnapshot() { cy.get('[data-testid="overview-pair-evidence-date"]').should('not.exist'); @@ -354,10 +371,12 @@ describe('Overview page', () => { cy.viewport(1280, 900); cy.visit('/zh/overview?models=all'); - cy.get('[data-testid="overview-model-scope-toggle"]').should( - 'contain.text', - '隐藏已弃用与维护模式模型', - ); + // The chip carries the short label; the full sentence stays on the + // accessible name and hover title. + cy.get('[data-testid="overview-model-scope-toggle"]') + .should('contain.text', '隐藏停用模型') + .find('[data-overview-model-scope="default"]') + .should('have.attr', 'aria-label', '隐藏已弃用与维护模式模型'); desktopModel('gpt-oss-120b') .find('[data-testid="overview-model-category-badge"]') .should('contain.text', '已弃用') @@ -415,8 +434,11 @@ describe('Overview page', () => { cy.viewport(1280, 900); cy.visit('/overview'); - cy.get('[data-testid="overview-page"]') - .children('[data-testid="overview-comparison-switcher"]') + // The tabs sit inside the surface handed to the Fullscreen API so that + // presenting keeps them with the matrix, which costs them their old spot as + // a direct child of the page. + cy.get('[data-testid="overview-presentation-surface"]') + .find('[data-testid="overview-comparison-switcher"]') .should('have.length', 1) .and('have.class', 'justify-center'); cy.get('[data-testid="overview-comparison-switcher"]') @@ -631,6 +653,48 @@ describe('Overview page', () => { } }); + // Clicking the control, not just building its href: the matrix reads from a + // client data cache, and a cache keyed without the row params moves the + // address bar while leaving every row on screen. + it('narrows the matrix when the row filter is clicked in either comparison mode', () => { + cy.viewport(1280, 900); + + for (const [href, attribute, key, sentence] of [ + ['/overview?compare=30d', 'data-overview-row-scope', 'rows=changed', 'no 30-day change'], + [ + '/overview', + 'data-overview-hardware-row-scope', + 'hwrows=priced', + 'no result on any platform', + ], + ] as const) { + cy.visit(href); + cy.get('[data-testid="overview-desktop-model"]').should('have.length', MATRIX_ROWS); + + cy.get(`a[${attribute}]`) + .should('contain.text', 'Hide ') + .invoke('text') + .then((label) => { + const hidden = Number(/\d+/.exec(label)?.[0]); + expect(hidden, `hidden row count in "${label}"`).to.be.greaterThan(0); + + cy.get(`a[${attribute}]`).click(); + cy.location('search').should('contain', key); + cy.get('[data-testid="overview-desktop-model"]').should( + 'have.length', + MATRIX_ROWS - hidden, + ); + // The action label names the click, so it flips once the scope + // lands; the chip keeps the short label and the counted sentence + // rides the accessible name. + cy.get(`a[${attribute}]`) + .should('contain.text', 'Show all rows') + .and('contain.text', String(hidden)) + .and('have.attr', 'aria-label', `Show ${hidden} rows with ${sentence}`); + }); + } + }); + it('defaults to community engine scope and switches with canonical links preserving tier and locale', () => { cy.viewport(1280, 900); cy.visit('/overview'); @@ -660,7 +724,7 @@ describe('Overview page', () => { }); desktopModel('GLM-5.2').within(() => { - cy.get('[data-testid="overview-model-scenario"]').should('have.text', AGENTX_LABEL); + expectAgentxScenario(AGENTX_LABEL); cy.get('[data-testid="overview-pair-missing"]').should('have.length', 5); }); cy.get( @@ -900,9 +964,9 @@ describe('Overview page', () => { cy.get('[data-testid="overview-desktop-matrix"]').should('contain.text', label); } for (const model of ['Kimi-K3', 'GLM-5.2']) { - desktopModel(model) - .find('[data-testid="overview-model-scenario"]') - .should('have.text', AGENTX_LABEL); + desktopModel(model).within(() => { + expectAgentxScenario(AGENTX_LABEL); + }); } for (const model of ['DeepSeek-V4-Pro', 'MiniMax-M3', 'Qwen-3.5-397B-A17B']) { desktopModel(model, SINGLE_TURN) @@ -925,7 +989,7 @@ describe('Overview page', () => { }); desktopModel('DeepSeek-V4-Pro', AGENTX).within(() => { - cy.get('[data-testid="overview-model-scenario"]').should('have.text', AGENTX_LABEL); + expectAgentxScenario(AGENTX_LABEL); cy.contains('DeepSeek V4 Pro 1.6T').should('exist'); // Priced from the AgentX rows alone — the single-turn sweep never leaks in. cy.get( @@ -1440,14 +1504,14 @@ describe('Overview page', () => { .and('have.attr', 'title', '缺少可比较的 B200 基线'); }); desktopModel('GLM-5.2').within(() => { - cy.get('[data-testid="overview-model-scenario"]').should('have.text', AGENTX_LABEL_ZH); + expectAgentxScenario(AGENTX_LABEL_ZH); cy.get('[data-testid="overview-pair-missing"]').should('have.length', 5); platform('b300') .find('[data-testid="overview-pair-missing"]') .should('contain.text', '该场景暂无数据'); }); desktopModel('DeepSeek-V4-Pro', AGENTX).within(() => { - cy.get('[data-testid="overview-model-scenario"]').should('have.text', AGENTX_LABEL_ZH); + expectAgentxScenario(AGENTX_LABEL_ZH); cy.get( '[data-testid="overview-pair-value"][data-hardware="b200"] [data-testid="overview-cost-evidence-link"]', ).should('have.text', '$0.064'); diff --git a/packages/app/src/app/api/v1/overview/route.test.ts b/packages/app/src/app/api/v1/overview/route.test.ts index 2a0829d2..e68107f7 100644 --- a/packages/app/src/app/api/v1/overview/route.test.ts +++ b/packages/app/src/app/api/v1/overview/route.test.ts @@ -37,12 +37,22 @@ describe('GET /api/v1/overview', () => { mockGetOverviewPageData.mockResolvedValueOnce(data); const response = await GET( - request('/api/v1/overview?tier=75&engine=all&compare=30d&ref=b300&models=all'), + request( + '/api/v1/overview?tier=75&engine=all&compare=30d&ref=b300&models=all&rows=changed&hwrows=priced', + ), ); expect(response.status).toBe(200); expect(await response.json()).toEqual(data); - expect(mockGetOverviewPageData).toHaveBeenCalledWith(75, 'all', '30d', 'b300', 'all'); + expect(mockGetOverviewPageData).toHaveBeenCalledWith( + 75, + 'all', + '30d', + 'b300', + 'all', + 'changed', + 'priced', + ); expect(mockCachedJson).toHaveBeenCalledWith(data); }); @@ -50,7 +60,9 @@ describe('GET /api/v1/overview', () => { mockGetOverviewPageData.mockResolvedValueOnce({ models: [] }); await GET( - request('/api/v1/overview?tier=999&engine=vendor&compare=weekly&ref=h100&models=inactive'), + request( + '/api/v1/overview?tier=999&engine=vendor&compare=weekly&ref=h100&models=inactive&rows=some&hwrows=blank', + ), ); expect(mockGetOverviewPageData).toHaveBeenCalledWith( @@ -59,6 +71,8 @@ describe('GET /api/v1/overview', () => { 'hardware', 'b200', 'default', + 'all', + 'all', ); }); diff --git a/packages/app/src/app/api/v1/overview/route.ts b/packages/app/src/app/api/v1/overview/route.ts index ba681916..3842b87c 100644 --- a/packages/app/src/app/api/v1/overview/route.ts +++ b/packages/app/src/app/api/v1/overview/route.ts @@ -4,8 +4,10 @@ import { cachedJson } from '@/lib/api-cache'; import { resolveOverviewComparisonMode, resolveOverviewEngineScope, + resolveOverviewHardwareRowScope, resolveOverviewModelScope, resolveOverviewReferenceHardware, + resolveOverviewRowScope, resolveOverviewTier, } from '@/lib/overview-data'; import { getOverviewPageData } from '@/lib/overview-data.server'; @@ -22,6 +24,8 @@ export async function GET(request: NextRequest) { resolveOverviewComparisonMode(params.get('compare') ?? undefined), resolveOverviewReferenceHardware(params.get('ref') ?? undefined), resolveOverviewModelScope(params.get('models') ?? undefined), + resolveOverviewRowScope(params.get('rows') ?? undefined), + resolveOverviewHardwareRowScope(params.get('hwrows') ?? undefined), ); return cachedJson(data); } catch (error) { diff --git a/packages/app/src/app/overview/page.tsx b/packages/app/src/app/overview/page.tsx index a0c04446..ae76ab5a 100644 --- a/packages/app/src/app/overview/page.tsx +++ b/packages/app/src/app/overview/page.tsx @@ -7,8 +7,10 @@ import { enAlternates } from '@/lib/i18n'; import { resolveOverviewComparisonMode, resolveOverviewEngineScope, + resolveOverviewHardwareRowScope, resolveOverviewModelScope, resolveOverviewReferenceHardware, + resolveOverviewRowScope, resolveOverviewTier, } from '@/lib/overview-data'; import { getOverviewPageData } from '@/lib/overview-data.server'; @@ -47,6 +49,8 @@ export default async function OverviewPage({ searchParams }: Props) { resolveOverviewComparisonMode(sp.compare), resolveOverviewReferenceHardware(sp.ref), resolveOverviewModelScope(sp.models), + resolveOverviewRowScope(sp.rows), + resolveOverviewHardwareRowScope(sp.hwrows), ); return ; } diff --git a/packages/app/src/app/zh/overview/page.tsx b/packages/app/src/app/zh/overview/page.tsx index 524d15db..742dcc86 100644 --- a/packages/app/src/app/zh/overview/page.tsx +++ b/packages/app/src/app/zh/overview/page.tsx @@ -7,8 +7,10 @@ import { ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n'; import { resolveOverviewComparisonMode, resolveOverviewEngineScope, + resolveOverviewHardwareRowScope, resolveOverviewModelScope, resolveOverviewReferenceHardware, + resolveOverviewRowScope, resolveOverviewTier, } from '@/lib/overview-data'; import { getOverviewPageData } from '@/lib/overview-data.server'; @@ -48,6 +50,8 @@ export default async function ZhOverviewPage({ searchParams }: Props) { resolveOverviewComparisonMode(sp.compare), resolveOverviewReferenceHardware(sp.ref), resolveOverviewModelScope(sp.models), + resolveOverviewRowScope(sp.rows), + resolveOverviewHardwareRowScope(sp.hwrows), ); return ; } diff --git a/packages/app/src/components/overview/overview-history-window-select.tsx b/packages/app/src/components/overview/overview-history-window-select.tsx index 2765433c..69d981af 100644 --- a/packages/app/src/components/overview/overview-history-window-select.tsx +++ b/packages/app/src/components/overview/overview-history-window-select.tsx @@ -11,6 +11,7 @@ import { track } from '@/lib/analytics'; import type { OverviewHistoryWindowKey } from '@/lib/overview-data'; import { useOverviewComparisonMode, useOverviewNavigation } from './overview-navigation'; +import { useIsPresenting } from './overview-presentation'; interface WindowOption { href: string; @@ -34,6 +35,10 @@ export function OverviewHistoryWindowSelect({ // back to hardware mode carries no window, so keep the committed one. const pendingMode = useOverviewComparisonMode(); const value = pendingMode === 'hardware' ? committedValue : pendingMode; + // Same reason as the reference select: `document.body` sits outside both the + // fullscreen element and the CSS `zoom`, so a portalled menu would be + // invisible or unscaled while presenting. + const presenting = useIsPresenting(); return ( {options.find((option) => option.value === value)?.label} - + {options.map((option) => ( `View details: ${modelLabel} · ${scenarioLabel}`, @@ -114,6 +124,30 @@ export const OVERVIEW_STRINGS = { modelScopeNavLabel: 'Inactive models', modelScopeShow: 'Show deprecated & maintenance-mode models', modelScopeHide: 'Hide deprecated & maintenance-mode models', + rowScopeNavLabel: (days: number) => `Rows without a ${days}-day change`, + rowScopeShow: (count: number, days: number) => + `Show ${count} ${count === 1 ? 'row' : 'rows'} with no ${days}-day change`, + rowScopeHide: (count: number, days: number) => + `Hide ${count} ${count === 1 ? 'row' : 'rows'} with no ${days}-day change`, + hardwareRowScopeNavLabel: 'Rows with no result', + hardwareRowScopeShow: (count: number) => + `Show ${count} ${count === 1 ? 'row' : 'rows'} with no result on any platform`, + hardwareRowScopeHide: (count: number) => + `Hide ${count} ${count === 1 ? 'row' : 'rows'} with no result on any platform`, + // Short forms of the sentences above, for the presentation toolbar. They + // carry the same verb, so they flip with the scope the same way; the + // sentence with the count stays on the accessible name. + rowScopeChipHide: 'Hide unchanged', + rowScopeChipShow: 'Show all rows', + hardwareRowScopeChipHide: 'Hide blanks', + hardwareRowScopeChipShow: 'Show all rows', + modelScopeChipShow: 'Show inactive', + modelScopeChipHide: 'Hide inactive', + presentEnter: 'Present', + presentExit: 'Exit', + presentEnterAria: 'Show the matrix full screen', + presentExitAria: 'Leave full screen', + presentShortcutHint: 'Arrow keys switch views · Esc exits', categoryBadges: { maintenance: 'Maintenance', deprecated: 'Deprecated', @@ -156,6 +190,10 @@ export const OVERVIEW_STRINGS = { single_turn_8k1k: '8K/1K', agentx: '长上下文多轮真实智能体场景(AgentX)', }, + scenarioLabelsShort: { + single_turn_8k1k: '8K/1K', + agentx: 'AgentX', + }, detailLink: '查看详情', detailAria: (modelLabel: string, scenarioLabel: string) => `查看详情:${modelLabel} · ${scenarioLabel}`, @@ -190,6 +228,23 @@ export const OVERVIEW_STRINGS = { modelScopeNavLabel: '非活跃模型', modelScopeShow: '显示已弃用与维护模式模型', modelScopeHide: '隐藏已弃用与维护模式模型', + rowScopeNavLabel: (days: number) => `${days} 天内无变化的行`, + rowScopeShow: (count: number, days: number) => `显示 ${count} 行 ${days} 天内无变化的数据`, + rowScopeHide: (count: number, days: number) => `隐藏 ${count} 行 ${days} 天内无变化的数据`, + hardwareRowScopeNavLabel: '无结果的行', + hardwareRowScopeShow: (count: number) => `显示 ${count} 行所有平台均无结果的数据`, + hardwareRowScopeHide: (count: number) => `隐藏 ${count} 行所有平台均无结果的数据`, + rowScopeChipHide: '隐藏无变化', + rowScopeChipShow: '显示全部行', + hardwareRowScopeChipHide: '隐藏空行', + hardwareRowScopeChipShow: '显示全部行', + modelScopeChipShow: '显示停用模型', + modelScopeChipHide: '隐藏停用模型', + presentEnter: '演示', + presentExit: '退出', + presentEnterAria: '全屏展示矩阵', + presentExitAria: '退出全屏', + presentShortcutHint: '方向键切换视图 · Esc 退出', categoryBadges: { maintenance: '维护模式', deprecated: '已弃用', @@ -667,6 +722,8 @@ function PlatformCell(props: { function ModelName({ model, strings }: { model: OverviewModelSummary; strings: OverviewStrings }) { const badge = strings.categoryBadges[model.category]; + const label = strings.scenarioLabels[model.scenario]; + const shortLabel = strings.scenarioLabelsShort[model.scenario]; return (

@@ -690,9 +747,17 @@ function ModelName({ model, strings }: { model: OverviewModelSummary; strings: O

- {strings.scenarioLabels[model.scenario]} + {shortLabel === label ? ( + label + ) : ( + <> + {label} + + + )}

); @@ -714,11 +779,16 @@ export function DesktopOverviewMatrix({ strings, comparisonMode, referenceHardware, -}: SurfaceProps) { + presenting = false, +}: SurfaceProps & { presenting?: boolean }) { const platforms = models[0]?.platforms ?? []; const referenceLabel = overviewHardwareLabel(referenceHardware); return ( -
+ // The `xl` gate asks whether the viewport can hold the matrix, which stops + // being the question once presenting: the deck lays out at a fixed width and + // is scaled by `zoom`, so on a projector narrower than 1280px this would + // hide the matrix on a slide whose phone list has already been dropped. +
{comparisonMode === 'hardware' @@ -768,7 +838,7 @@ export function DesktopOverviewMatrix({ data-scenario={model.scenario} className="border-b border-border/50 align-top last:border-b-0" > -
+ {/* The link lives with the model it drills into, so the matrix spends no column on a header that is the same every row. */} @@ -795,7 +865,7 @@ export function DesktopOverviewMatrix({ referenceHardware, referenceCost, )} - className={`px-3 py-4 align-top ${comparisonMode === 'hardware' && platform.hardware === referenceHardware ? 'bg-muted/30' : ''}`} + className={`px-3 py-2.5 align-top ${comparisonMode === 'hardware' && platform.hardware === referenceHardware ? 'bg-muted/30' : ''}`} > ({ value: hardware, label: overviewHardwareLabel(hardware), - href: overviewHref(locale, tier, engineScope, 'hardware', hardware, modelScope), + href: overviewHref( + locale, + tier, + engineScope, + 'hardware', + hardware, + modelScope, + rowScope, + hardwareRowScope, + ), })); const windowOptions = OVERVIEW_HISTORY_WINDOWS.map((window) => ({ value: window, @@ -1119,7 +1214,16 @@ export function OverviewComparisonSwitcher({ ) : (