diff --git a/i18n/en/code.json b/i18n/en/code.json index 31e8f83..d9d184d 100644 --- a/i18n/en/code.json +++ b/i18n/en/code.json @@ -1,4 +1,10 @@ { + "catalog.recipeNameAsc": { + "message": "Recipe name A–Z" + }, + "catalog.recipeNameDesc": { + "message": "Recipe name Z–A" + }, "catalog.loadingDetails": { "message": "Loading details…", "description": "正在加载详情…" @@ -1676,8 +1682,8 @@ "description": "按模型名称 Z–A" }, "site.6592019eaa05": { - "message": "Recipe release date", - "description": "按配方发布日期" + "message": "Newest first", + "description": "按最新发布排序" }, "site.7904ff565aaa": { "message": "Card view", diff --git a/src/features/catalog/CatalogPage.tsx b/src/features/catalog/CatalogPage.tsx index 1c7c261..62bcf7d 100644 --- a/src/features/catalog/CatalogPage.tsx +++ b/src/features/catalog/CatalogPage.tsx @@ -36,6 +36,7 @@ import { reportHref, } from './routes'; import CatalogSelect from './CatalogSelect'; +import CatalogSort from './CatalogSort'; import ModelCard, { ModelRecipeList, ModelTuning } from './ModelCard'; import styles from './styles.module.css'; @@ -316,49 +317,10 @@ export default function CatalogPage() { })} - update({ sort: value })} - options={[ - { - value: 'improvement-desc', - label: - q.view === 'models' - ? translate({ - id: 'site.f7e6d40ff9f5', - message: '按调优配方最佳提升排序', - }) - : translate({ - id: 'site.26ba23cf9e57', - message: '按吞吐量提升排序', - }), - }, - { - value: 'name-asc', - label: translate({ - id: 'site.e273b1d2eca6', - message: '按模型名称 A–Z', - }), - }, - { - value: 'name-desc', - label: translate({ - id: 'site.1322e7738e4a', - message: '按模型名称 Z–A', - }), - }, - { - value: 'updated-desc', - label: translate({ - id: 'site.6592019eaa05', - message: '按配方发布日期', - }), - }, - ]} + context={q.view === 'models' ? 'models' : 'recipes'} />
diff --git a/src/features/catalog/CatalogSort.tsx b/src/features/catalog/CatalogSort.tsx new file mode 100644 index 0000000..446bae6 --- /dev/null +++ b/src/features/catalog/CatalogSort.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { translate } from '@docusaurus/Translate'; +import CatalogSelect from './CatalogSelect'; + +export default function CatalogSort({ + value, + onValueChange, + context = 'recipes', +}: { + value: string; + onValueChange: (value: string) => void; + context?: 'models' | 'recipes' | 'model-recipes'; +}) { + return ( + + ); +} diff --git a/src/features/catalog/DetailPage.tsx b/src/features/catalog/DetailPage.tsx index 9960d50..ad16de1 100644 --- a/src/features/catalog/DetailPage.tsx +++ b/src/features/catalog/DetailPage.tsx @@ -35,6 +35,8 @@ import ReportPanel from './ReportPanel'; import { availableReportTunings } from './report'; import CatalogLink from './CatalogLink'; import CatalogSelect from './CatalogSelect'; +import CatalogSort from './CatalogSort'; +import { compare, initialQuery, parseSort } from './query'; import RecipeOverview from './RecipeOverview'; import styles from './styles.module.css'; @@ -81,6 +83,7 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) { params.get('dataVersion') ?? navigation.catalogDataVersion; const state = useCatalog(dataVersion), c = state.catalog; + const sort = parseSort(params.get('sort')); const recipeParams = new URLSearchParams(location.search); recipeParams.delete('returnTo'); if (recipeParams.get('dataVersion') === state.currentCommit) @@ -90,7 +93,11 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) { !browser || !modelId ? null : kind === 'model' - ? modelRoute(modelId, params.get('dataVersion') ?? '') + ? modelRoute( + modelId, + params.get('dataVersion') ?? '', + sort === initialQuery.sort ? '' : sort, + ) : recipeId ? recipeRoute(modelId, recipeId, recipeParams.toString()) : null; @@ -129,6 +136,10 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) { [c, modelId], ); const recipe = recipes.find((r) => r.variant.id === recipeId); + const sortedRecipes = useMemo( + () => [...recipes].sort((a, b) => compare(a, b, sort)), + [recipes, sort], + ); const [revisions, setRevisions] = useState(null), [historyError, setHistoryError] = useState(''), [text, setText] = useState(''), @@ -230,6 +241,19 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) { modelNavigationState(dataVersion, returnTo), ); } + function chooseSort(value: string) { + if (value === sort) return; + history.push( + siteHref( + modelRoute( + modelId, + params.get('dataVersion') ?? '', + value === initialQuery.sort ? '' : value, + ), + ) + location.hash, + modelNavigationState(dataVersion, returnTo), + ); + } function chooseTab(tab: RecipeTab) { if (tab !== activeTab) history.push( @@ -487,9 +511,16 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) { {recipes.length} + + +
- {recipes.map((r) => ( + {sortedRecipes.map((r) => ( { let attempts = 0; + let available = false; + // Keep the report unavailable until Retry, including any redirect remounts. await page.route('**/mimo-v2.5-h100-report.html', (route) => { attempts++; - return attempts === 1 - ? route.fulfill({ status: 503, body: 'Unavailable' }) - : route.fulfill({ body: report, contentType: 'text/html' }); + return available + ? route.fulfill({ body: report, contentType: 'text/html' }) + : route.fulfill({ status: 503, body: 'Unavailable' }); }); await page.goto( `/models/recipe?modelId=mimo-v2.5&recipeId=${variant.id}&tab=performance`, @@ -1218,12 +1220,14 @@ test('original report retries a failed request without requiring structured JSON await expect( page.getByRole('button', { name: '全屏观看', exact: true }), ).toBeDisabled(); + const attemptsBeforeRetry = attempts; + available = true; await page.getByRole('button', { name: '重试', exact: true }).click(); await expect(reportBody(page)).toContainText('119.9%'); await expect( page.getByRole('button', { name: '全屏观看', exact: true }), ).toBeEnabled(); - expect(attempts).toBe(2); + expect(attempts).toBe(attemptsBeforeRetry + 1); }); test('history opens the exact configuration and keeps reports bound to their measured version', async ({ diff --git a/tests/e2e/model-recipe-sort.spec.ts b/tests/e2e/model-recipe-sort.spec.ts new file mode 100644 index 0000000..1322f30 --- /dev/null +++ b/tests/e2e/model-recipe-sort.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test'; +import baseline from '../../content/catalog/baseline.json'; +import { parseCatalog } from '../../src/features/catalog/data'; + +for (const width of [390, 1440]) { + test(`model recipes sort by gain, recipe name and date at ${width}px`, async ({ + page, + }) => { + const source = parseCatalog(structuredClone(baseline)); + const model = source.models.find((item) => item.name === 'glm5.3')!; + const template = model.variants[0]; + model.variants = [ + { ...template, id: 'alpha', releaseDate: '2026-11-10' }, + { ...template, id: 'bravo', releaseDate: '2026-10-01' }, + { ...template, id: 'charlie', releaseDate: undefined }, + { ...template, id: 'delta', releaseDate: '2026-11-01' }, + ].map((variant) => ({ + ...variant, + path: `models/${model.name}/${variant.id}.yaml`, + history: `models/${model.name}/${variant.id}.history.json`, + })); + model.tuning = [ + { + optimized: `alpha@${template.version}`, + uplift: 10, + workloads: [], + baseline: '', + }, + { + optimized: `bravo@${template.version}`, + uplift: 50, + workloads: [], + baseline: '', + }, + { + optimized: `delta@${template.version}`, + uplift: 50, + workloads: [], + baseline: '', + }, + ]; + const catalog = parseCatalog(source); + await page.route( + 'https://modelsphere.github.io/model-catalog/**', + (route) => + route.request().url().endsWith('index-v2.json') + ? route.fulfill({ json: catalog }) + : route.abort(), + ); + await page.setViewportSize({ width, height: 1000 }); + const returnTo = '/models?q=glm5.3&layout=table'; + await page.goto( + `/models/detail?modelId=${model.name}&dataVersion=${source.commit}&returnTo=${encodeURIComponent(returnTo)}`, + ); + const section = page.getByRole('region', { name: '可用部署配方 · 4' }); + const sort = section.getByRole('combobox', { name: '排序方式' }); + async function expectOrder(ids: string[]) { + await expect + .poll(() => + section + .locator('[data-recipe-id]') + .evaluateAll((cards) => + cards.map((card) => card.getAttribute('data-recipe-id')), + ), + ) + .toEqual(ids.map((id) => `${model.name}/${id}`)); + } + async function choose(label: string) { + await sort.click(); + await page.getByRole('option', { name: label, exact: true }).click(); + } + + await expect(sort).toHaveText('按吞吐量提升排序'); + await expectOrder(['bravo', 'delta', 'alpha', 'charlie']); + await choose('按配方名称 A–Z'); + await expectOrder(['alpha', 'bravo', 'charlie', 'delta']); + await choose('按配方名称 Z–A'); + await expectOrder(['delta', 'charlie', 'bravo', 'alpha']); + await choose('按最新发布排序'); + await expectOrder(['alpha', 'delta', 'bravo', 'charlie']); + await expect(page).toHaveURL( + (url) => + url.pathname === '/model' && + url.searchParams.get('sort') === 'updated-desc' && + url.searchParams.get('dataVersion') === source.commit, + ); + await page.reload(); + await expect(sort).toHaveText('按最新发布排序'); + await expectOrder(['alpha', 'delta', 'bravo', 'charlie']); + await page.goBack(); + await expect(sort).toHaveText('按配方名称 Z–A'); + await expectOrder(['delta', 'charlie', 'bravo', 'alpha']); + await page.goForward(); + await expect(sort).toHaveText('按最新发布排序'); + await choose('按吞吐量提升排序'); + await expectOrder(['bravo', 'delta', 'alpha', 'charlie']); + await expect(page).toHaveURL((url) => !url.searchParams.has('sort')); + await expect( + page.getByRole('navigation', { name: '面包屑' }).getByRole('link', { + name: '模型与调优', + exact: true, + }), + ).toHaveAttribute('href', returnTo); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + }); +} diff --git a/tests/unit/model-recipe-sort.test.ts b/tests/unit/model-recipe-sort.test.ts new file mode 100644 index 0000000..2265113 --- /dev/null +++ b/tests/unit/model-recipe-sort.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { baseline, recipesOf } from '../../src/features/catalog/data'; +import { compare, parseSort } from '../../src/features/catalog/query'; + +describe('model recipe sorting', () => { + const template = recipesOf(baseline)[0]; + const recipes = [ + { id: 'charlie', gain: null, date: undefined }, + { id: 'alpha', gain: 0, date: '2026-11-10' }, + { id: 'delta', gain: 50, date: '2026-11-01' }, + { id: 'bravo', gain: 50, date: '2026-10-01' }, + ].map(({ id, gain, date }) => ({ + ...template, + key: `${template.model.name}/${id}`, + variant: { ...template.variant, id, releaseDate: date }, + quotedGain: gain, + })); + const order = (sort: string) => + [...recipes].sort((a, b) => compare(a, b, sort)).map((r) => r.variant.id); + + it('reverses recipe names within the same model', () => { + expect(order('name-asc')).toEqual(['alpha', 'bravo', 'charlie', 'delta']); + expect(order('name-desc')).toEqual(['delta', 'charlie', 'bravo', 'alpha']); + }); + + it('puts missing gains last, keeps zero gains and resolves ties consistently', () => { + expect(order('improvement-desc')).toEqual([ + 'bravo', + 'delta', + 'alpha', + 'charlie', + ]); + }); + + it('orders newest dates first and puts missing dates last', () => { + expect(order('updated-desc')).toEqual([ + 'alpha', + 'delta', + 'bravo', + 'charlie', + ]); + }); + + it('uses the default for missing or unsupported URL values', () => { + expect(parseSort(null)).toBe('improvement-desc'); + expect(parseSort('unsupported')).toBe('improvement-desc'); + for (const value of ['name-asc', 'name-desc', 'updated-desc']) + expect(parseSort(value)).toBe(value); + }); +});