diff --git a/src/features/catalog/GainMeter.tsx b/src/features/catalog/GainMeter.tsx
index 2c62a99..5e91555 100644
--- a/src/features/catalog/GainMeter.tsx
+++ b/src/features/catalog/GainMeter.tsx
@@ -1,6 +1,6 @@
import { translate } from '@docusaurus/Translate';
import React from 'react';
-import Link from '@docusaurus/Link';
+import CatalogLink from './CatalogLink';
import styles from './GainMeter.module.css';
export default function GainMeter({
@@ -8,6 +8,8 @@ export default function GainMeter({
label,
valueHref,
valueLabel,
+ version = '',
+ returnTo,
layout = 'stacked',
max = 100,
}: {
@@ -15,6 +17,8 @@ export default function GainMeter({
label?: React.ReactNode;
valueHref?: string;
valueLabel?: string;
+ version?: string;
+ returnTo?: string;
layout?: 'stacked' | 'inline';
max?: number;
}) {
@@ -37,9 +41,15 @@ export default function GainMeter({
>
{label && {label}}
{value != null && valueHref ? (
-
+
{summary.recipes.map((r) => (
- {model.displayName || model.name} - +
@@ -171,12 +183,14 @@ export default function ModelCard(props: Props) {{variant.description}
}-
@@ -162,11 +167,9 @@ export default function RecipeOverview({
const baseline = recipes.find(
(r) => r.variant.id === tuning.baseline.split('@')[0],
);
- const report = reportUrl(
- recipe,
- tuning,
- currentLocale === 'en' ? 'en' : 'zh',
- );
+ const report = reportUrl(recipe, tuning)
+ ? reportHref(recipe, version, returnTo, tuning.report)
+ : null;
const rows = tuning.workloads.length
? tuning.workloads
: tuning.uplift == null
@@ -195,16 +198,18 @@ export default function RecipeOverview({
id="catalog.gainRelativeToBaseline"
values={{
baseline: baseline ? (
-
{tuning.baseline}
-
+
) : (
tuning.baseline ||
translate({
@@ -218,12 +223,19 @@ export default function RecipeOverview({
{report && (
-
+
- - {context === 'catalog' ? r.variant.id : r.model.displayName || r.model.name} - +
diff --git a/src/features/catalog/ReportPage.tsx b/src/features/catalog/ReportPage.tsx
index dd9c4ce..afe35f1 100644
--- a/src/features/catalog/ReportPage.tsx
+++ b/src/features/catalog/ReportPage.tsx
@@ -18,8 +18,8 @@ export default function ReportPage() {
const history = useHistory();
const target = recipeTabHref(browser ? location.search : '', 'performance');
useEffect(() => {
- if (browser) history.replace(siteHref(target));
- }, [browser, history, target, siteHref]);
+ if (browser) history.replace(siteHref(target), location.state);
+ }, [browser, history, target, siteHref, location.state]);
return (
- {r.tuning[0]?.baseline} → {r.tuning[0]?.optimized}
+ {tuning?.baseline} → {tuning?.optimized}
{fullscreenError}
+
{translate({ id: 'site.97a3583c6b16', message: '基线与调优对照' })}
第二份性能报告
', + contentType: 'text/html', + }), + ); + await page.goto('/models/recipe' + query.replace('&tab=performance', '')); + const reportLink = page.locator( + 'a[href*="report=second-comparison-report.html"]', + ); + await expect(reportLink).toBeVisible(); + await expect(reportLink).not.toHaveAttribute('target', '_blank'); + await reportLink.click(); + await expect( + frame(page).getByRole('heading', { name: '第二份性能报告' }), + ).toBeVisible(); + await expect(page.getByLabel('性能报告', { exact: true })).toHaveValue( + secondReport, + ); + const selectedUrl = page.url(); + await page.reload(); + await expect( + frame(page).getByRole('heading', { name: '第二份性能报告' }), + ).toBeVisible(); + expect(page.url()).toBe(selectedUrl); + await page + .getByLabel('性能报告', { exact: true }) + .selectOption(original.report!); + await expect( + frame(page).getByRole('heading', { name: '中文性能报告' }), + ).toBeVisible(); + expect(new URL(page.url()).searchParams.get('report')).toBe(original.report); + expect(new URL(page.url()).searchParams.has('dataVersion')).toBe(false); + expect( + await page.evaluate(() => window.history.state?.state?.catalogDataVersion), + ).toBe(baseline.commit); +}); + +for (const width of [390, 1440]) { + test(`report stays readable at ${width}px when native fullscreen is unavailable`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.addInitScript(() => { + HTMLElement.prototype.requestFullscreen = () => + Promise.reject(new Error('Unavailable')); + }); + await mockSources(page, bilingual); + await page.goto('/models/recipe' + query); + await expect( + frame(page).getByRole('heading', { name: '中文性能报告' }), + ).toBeVisible(); + await page.getByRole('button', { name: '全屏观看', exact: true }).click(); + await expect( + page.getByRole('status').filter({ hasText: '暂时无法全屏观看' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: '查看完整报告', exact: true }), + ).toHaveCount(0); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect( + frame(page).getByRole('heading', { name: '中文性能报告' }), + ).toBeVisible(); + }); +} + test('language metadata retains assets and cannot inject HTML when reserialized', async ({ page, }) => { diff --git a/tests/pages/deployment.spec.ts b/tests/pages/deployment.spec.ts index 4c70e06..67ae02e 100644 --- a/tests/pages/deployment.spec.ts +++ b/tests/pages/deployment.spec.ts @@ -133,7 +133,7 @@ test('catalog filters, recipe tabs, legacy redirects and return links preserve t ).toContainText('119.9%'); await page.getByRole('tab', { name: '部署配置' }).click(); await expect(page).toHaveURL( - (url) => url.pathname === `${prefix}models/recipe` && url.search.length > 0, + (url) => url.pathname === `${prefix}recipe` && url.search.length > 0, ); await expect( page.getByRole('tab', { name: '部署配置', selected: true }), @@ -159,7 +159,7 @@ test('catalog filters, recipe tabs, legacy redirects and return links preserve t await page.goto(`models/report?${params}`); await expect(page).toHaveURL( (url) => - url.pathname === `${prefix}models/recipe` && + url.pathname === `${prefix}recipe` && url.searchParams.get('tab') === 'performance', ); await expect( @@ -178,6 +178,8 @@ test('direct documents, blog routes, 404 responses and version metadata work und for (const route of [ 'docs', 'blog', + 'model?id=mimo-v2.5', + 'en/model?id=mimo-v2.5', 'models/detail?modelId=mimo-v2.5', 'models/evidence', 'community', diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 47259dd..5a3099f 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -523,12 +523,12 @@ describe('unified recipe navigation', () => { reportHref(recipe, baseline.commit, returnTo), 'https://site.test', ); - expect(url.pathname).toBe('/models/recipe'); + expect(url.pathname).toBe('/recipe'); expect(url.searchParams.get('tab')).toBe('performance'); - expect(url.searchParams.get('modelId')).toBe(recipe.model.name); - expect(url.searchParams.get('recipeId')).toBe(recipe.variant.id); - expect(url.searchParams.get('dataVersion')).toBe(baseline.commit); - expect(url.searchParams.get('returnTo')).toBe(returnTo); + expect(url.searchParams.get('model')).toBe(recipe.model.name); + expect(url.searchParams.get('id')).toBe(recipe.variant.id); + expect(url.searchParams.has('dataVersion')).toBe(false); + expect(url.searchParams.has('returnTo')).toBe(false); expect(url.searchParams.has('benchmark')).toBe(false); }); it('only restores catalog queries and removes legacy report selection', () => { @@ -555,9 +555,9 @@ describe('unified recipe navigation', () => { )!, 'https://site.test', ); - expect(url.pathname).toBe('/models/recipe'); + expect(url.pathname).toBe('/recipe'); expect(url.searchParams.get('tab')).toBe('performance'); - expect(url.searchParams.get('recipeId')).toBe(recipe.variant.id); + expect(url.searchParams.get('id')).toBe(recipe.variant.id); expect(url.searchParams.get('returnTo')).toBe( '/models?q=mimo&page=2&dataVersion=' + baseline.commit, ); @@ -566,8 +566,8 @@ describe('unified recipe navigation', () => { legacyCatalogReportHref('?benchmark=unknown')!, 'https://site.test', ); - expect(missing.searchParams.get('modelId')).toBe('unknown'); - expect(missing.searchParams.get('recipeId')).toBe(''); + expect(missing.searchParams.get('model')).toBe('unknown'); + expect(missing.searchParams.get('id')).toBe(''); }); it('redirects legacy recipe report anchors while retaining the selected configuration version', () => { const search = @@ -577,7 +577,7 @@ describe('unified recipe navigation', () => { legacyRecipeReportHref(search, '#benchmark')!, 'https://site.test', ); - expect(url.pathname).toBe('/models/recipe'); + expect(url.pathname).toBe('/recipe'); expect(url.searchParams.get('tab')).toBe('performance'); expect(url.searchParams.get('dataVersion')).toBe(baseline.commit); expect(url.searchParams.get('version')).toBe('1.0.0'); @@ -597,8 +597,12 @@ describe('unified recipe navigation', () => { 'https://site.test', ); expect(recipeTab(url.searchParams.get('tab'))).toBe(tab); - for (const [key, value] of params) - expect(url.searchParams.get(key)).toBe(value); + expect(url.searchParams.get('model')).toBe(recipe.model.name); + expect(url.searchParams.get('id')).toBe(recipe.variant.id); + expect(url.searchParams.has('modelId')).toBe(false); + expect(url.searchParams.has('recipeId')).toBe(false); + for (const key of ['version', 'dataVersion', 'returnTo']) + expect(url.searchParams.get(key)).toBe(params.get(key)); } expect(recipeTab('unknown')).toBe('deployment'); expect(recipeTab('history')).toBe('deployment'); diff --git a/tests/unit/model-url.test.ts b/tests/unit/model-url.test.ts new file mode 100644 index 0000000..9cf0422 --- /dev/null +++ b/tests/unit/model-url.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { baseline, modelHref } from '../../src/features/catalog/data'; +import { + modelNavigationState, + modelRoute, + readModelNavigationState, +} from '../../src/features/catalog/routes'; + +describe('shareable model addresses and private navigation context', () => { + it('keeps model identity in a stable short address without exposing list filters', () => { + const model = baseline.models[0]; + const href = modelHref( + model, + baseline.commit, + '/models?q=private-search&vendor=ascend&page=2', + ); + expect(href).toBe(`/model?id=${model.name}`); + expect(href).not.toContain('private-search'); + expect(href).not.toContain(baseline.commit); + expect(modelRoute(model.name, baseline.commit)).toBe( + `${href}&dataVersion=${baseline.commit}`, + ); + }); + + it('round trips the list and pinned snapshot separately from a shareable URL', () => { + const returnTo = '/models?vendor=ascend&layout=table&page=2'; + const state = modelNavigationState(baseline.commit, returnTo); + expect(readModelNavigationState(JSON.parse(JSON.stringify(state)))).toEqual( + { + catalogDataVersion: baseline.commit, + catalogReturnTo: returnTo, + }, + ); + expect(readModelNavigationState(undefined)).toEqual({ + catalogDataVersion: '', + catalogReturnTo: '/models', + }); + }); + + it('rejects unsafe return locations and malformed history snapshot values', () => { + for (const returnTo of ['https://evil.test/models', '//evil.test/models']) { + expect( + readModelNavigationState({ + catalogReturnTo: returnTo, + catalogDataVersion: 'master', + }), + ).toEqual({ catalogReturnTo: '/models', catalogDataVersion: '' }); + } + expect(readModelNavigationState({ catalogReturnTo: 42 })).toEqual({ + catalogDataVersion: '', + catalogReturnTo: '/models', + }); + }); +}); diff --git a/tests/unit/recipe-url.test.ts b/tests/unit/recipe-url.test.ts new file mode 100644 index 0000000..89828ff --- /dev/null +++ b/tests/unit/recipe-url.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + baseline, + detailHref, + recipesOf, +} from '../../src/features/catalog/data'; +import { + recipeRoute, + recipeTabHref, + reportHref, +} from '../../src/features/catalog/routes'; + +const recipe = recipesOf(baseline).find( + (r) => r.key === 'glm5.3/sglang-tp4-b300-optimized', +)!; +const href = '/recipe?model=glm5.3&id=sglang-tp4-b300-optimized'; + +describe('shareable recipe addresses', () => { + it('omits the catalog snapshot and list filters from normal deployment links', () => { + expect( + detailHref( + recipe, + baseline.commit, + undefined, + '/models?q=private&layout=table', + ), + ).toBe(href); + expect(detailHref(recipe, baseline.commit, '1.0.4')).toBe( + href + '&version=1.0.4', + ); + }); + + it('keeps model identity because recipe identifiers repeat across models', () => { + const repeated = recipesOf(baseline).filter( + (r) => r.variant.id === 'sglang-tp8-h100-optimized', + ); + expect(repeated.length).toBeGreaterThan(1); + expect( + new Set(repeated.map((r) => detailHref(r, baseline.commit))).size, + ).toBe(repeated.length); + }); + + it('retains selected reports and configuration versions across tabs', () => { + const report = reportHref( + recipe, + baseline.commit, + '/models?q=private', + 'glm5.3-1.0.3-b300-report.html', + ); + const url = new URL(report, 'https://site.test'); + expect( + url.pathname + + '?model=' + + url.searchParams.get('model') + + '&id=' + + url.searchParams.get('id'), + ).toBe(href); + expect(url.searchParams.get('report')).toBe( + 'glm5.3-1.0.3-b300-report.html', + ); + expect(url.searchParams.get('tab')).toBe('performance'); + expect(url.searchParams.has('dataVersion')).toBe(false); + expect(url.searchParams.has('returnTo')).toBe(false); + const historical = recipeRoute( + recipe.model.name, + recipe.variant.id, + 'version=1.0.4&tab=performance&report=selected.html', + ); + expect( + recipeTabHref( + new URL(historical, 'https://site.test').search, + 'deployment', + ), + ).toBe(href + '&version=1.0.4&report=selected.html'); + }); +}); diff --git a/tests/unit/report.test.ts b/tests/unit/report.test.ts index aca896a..0c0ea4d 100644 --- a/tests/unit/report.test.ts +++ b/tests/unit/report.test.ts @@ -3,9 +3,12 @@ import { baseline, recipesOf, reportUrl, + type Recipe, } from '../../src/features/catalog/data'; import { + availableReportTunings, loadReportDocument, + selectReportTuning, selectReportLanguage, } from '../../src/features/catalog/report'; @@ -67,6 +70,77 @@ describe('report language selection', () => { }); describe('original report loading', () => { + const secondReport = 'second-comparison-report.html'; + const multiple: Recipe = { + ...recipe, + model: { + ...recipe.model, + reports: [...recipe.model.reports, secondReport], + }, + tuning: [ + ...recipe.tuning, + { + ...recipe.tuning[0], + baseline: 'another-baseline@1', + report: secondReport, + }, + ], + }; + + it('selects each available comparison and skips missing or duplicate report entries', () => { + const withMissing: Recipe = { + ...multiple, + tuning: [ + { ...multiple.tuning[0], report: undefined }, + ...multiple.tuning, + multiple.tuning[0], + { ...multiple.tuning[0], report: 'not-in-model.html' }, + ], + }; + expect(availableReportTunings(withMissing)).toHaveLength(2); + expect(selectReportTuning(withMissing)?.report).toBe( + recipe.tuning[0].report, + ); + expect(selectReportTuning(withMissing, secondReport)?.baseline).toBe( + 'another-baseline@1', + ); + expect( + selectReportTuning(withMissing, 'not-in-model.html'), + ).toBeUndefined(); + }); + + it('loads the requested comparison from the same immutable snapshot', async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response('Second report'), + ); + vi.stubGlobal('fetch', fetch); + await loadReportDocument( + multiple, + baseline.commit, + undefined, + secondReport, + ); + expect(fetch.mock.calls[0][0]).toContain( + `/${baseline.commit}/models/mimo-v2.5/${secondReport}`, + ); + }); + + it('rejects report names outside this recipe before issuing a request', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + await expect( + loadReportDocument( + multiple, + baseline.commit, + undefined, + 'other-report.html', + ), + ).rejects.toThrow('未关联性能报告'); + expect(fetch).not.toHaveBeenCalled(); + }); + it('retains the immutable catalog snapshot', async () => { const html = 'Original report'; const fetch = vi.fn().mockResolvedValue(new Response(html)); From 8e813b9dc2f15d6483e745e4c85cd2a3374cfc7b Mon Sep 17 00:00:00 2001 From: tanhuan+
{error}{' '}
) : !data ? ( -+
{translate({ id: 'site.ab325d7067a1', message: '正在读取报告数据…' })}
) : (