From 376fd74b9614bae5f6cd07544223172af91620a2 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:01:26 +0700 Subject: [PATCH] fix(web): versus and deals offer a persona only with a scored criterion DOS/Rate#1556: with no criterion scores yet, every persona on versus and deals ranked exactly like the default while still being offered, and the verdict named the persona's weights. - Versus shows a use-case preset only when both models have a real score for the criterion it weighs most; with no criterion scored on both sides, the presets and the custom weights are not shown, and the sliders list only the criteria both sides have. - Deals shows a persona only when some product in the category has a real score for its criterion; with only the default left the pills are hidden and the ranking uses the default. - Scoring already skipped null criteria (#1143); no new text. Closes #1556 Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/deals-paging.spec.ts | 9 +- apps/web/e2e/light-theme-controls.spec.ts | 13 +- apps/web/e2e/pill-group-state.spec.ts | 41 ++-- .../compare/VersusInteractiveScoreboard.tsx | 206 +++++++++++------- .../components/deals/DealFinderWorkspace.tsx | 113 +++++++--- .../__tests__/core-rate-architecture.test.ts | 39 ++++ 6 files changed, 275 insertions(+), 146 deletions(-) diff --git a/apps/web/e2e/deals-paging.spec.ts b/apps/web/e2e/deals-paging.spec.ts index a22b0e208..c1e59c205 100644 --- a/apps/web/e2e/deals-paging.spec.ts +++ b/apps/web/e2e/deals-paging.spec.ts @@ -19,11 +19,10 @@ test('deals load 12 at a time and filters restart paging', async ({ page }) => { // Keyboard focus moves to the first newly shown card. await expect(cards.nth(12).locator('a[href]').first()).toBeFocused(); - // Any filter change starts again from the first page: a persona... - await page - .locator('#rate-deal-personas button[aria-pressed="false"]') - .first() - .click(); + // Any filter change starts again from the first page: the budget... + const budget = page.locator('input[type="range"]').first(); + await budget.focus(); + await page.keyboard.press('ArrowLeft'); await expect(cards).toHaveCount(12); // ...or a search. diff --git a/apps/web/e2e/light-theme-controls.spec.ts b/apps/web/e2e/light-theme-controls.spec.ts index 412c07843..d5c6be21e 100644 --- a/apps/web/e2e/light-theme-controls.spec.ts +++ b/apps/web/e2e/light-theme-controls.spec.ts @@ -135,16 +135,9 @@ for (const colorScheme of ['light', 'dark'] as const) { ); await settled(page); - const scoreboard = page.locator('.rate-scoreboard-dark').first(); - // The custom-weights toggle in its inactive state, the one axe missed. - const weights = scoreboard.getByRole('button', { - name: 'Tùy chỉnh trọng số', - }); - await expect(weights.first()).toBeVisible(); - await expect(weights.first()).toHaveAttribute('aria-expanded', 'false'); - const weightMeasures = await contrastOf(weights); - expect(weightMeasures.length).toBeGreaterThan(0); - expectReadable(weightMeasures, 'custom-weights button'); + // The custom-weights toggle is not offered while no seeded product + // has a criterion score (DOS/Rate#1556); the other controls are. + await expect(page.locator('.rate-scoreboard-dark').first()).toBeVisible(); const controls = await contrastOf( page.locator( diff --git a/apps/web/e2e/pill-group-state.spec.ts b/apps/web/e2e/pill-group-state.spec.ts index fad5080ef..1cfc921b8 100644 --- a/apps/web/e2e/pill-group-state.spec.ts +++ b/apps/web/e2e/pill-group-state.spec.ts @@ -7,11 +7,7 @@ const PAGES: { path: string; groups: (string | RegExp)[] }[] = [ path: '/smartphones/iphone-16-pro-max-256gb', groups: ['Gian hàng:', 'Khoảng thời gian', 'Chu kỳ sử dụng'], }, - { - path: '/compare/iphone-16-pro-max-256gb-vs-samsung-galaxy-s25-ultra-256gb', - groups: [/Chấm theo nhu cầu/], - }, - { path: '/deals', groups: [/Ngành hàng/, /Ưu tiên chấm điểm/] }, + { path: '/deals', groups: [/Ngành hàng/] }, { path: '/merchants', groups: ['Lọc theo sàn', 'Lọc theo rủi ro đánh giá'], @@ -51,7 +47,10 @@ test('choosing another pill moves the pressed state', async ({ page }) => { await expect(group.getByRole('button', { pressed: true })).toHaveCount(1); }); -test('compare custom weights are named on the sliders toggle', async ({ +// No seeded product has a criterion score, as on rate.vn today: a persona +// or custom weights would rank exactly like the default, so versus and deals +// do not offer them (DOS/Rate#1556). +test('versus and deals offer no persona without criterion scores', async ({ page, }) => { expect( @@ -61,23 +60,15 @@ test('compare custom weights are named on the sliders toggle', async ({ ) )?.status() ).toBe(200); - const toggle = page.getByRole('button', { name: /Tùy chỉnh trọng số/ }); - await expect(async () => { - if ((await toggle.getAttribute('aria-expanded')) !== 'true') { - await toggle.click(); - } - await expect(toggle).toHaveAttribute('aria-expanded', 'true', { - timeout: 500, - }); - }).toPass(); - // useId ids hold characters that need escaping in a #id selector. - const panelId = (await toggle.getAttribute('aria-controls')) ?? ''; - const panel = page.locator(`[id="${panelId}"]`); - await expect(panel).toBeVisible(); - await expect(toggle).not.toHaveAccessibleName(/Trọng số tùy chỉnh/); - await panel.locator('input[type="range"]').first().focus(); - await page.keyboard.press('ArrowRight'); - await expect(toggle).toHaveAccessibleName(/Trọng số tùy chỉnh/); - const presets = page.getByRole('group', { name: /Chấm theo nhu cầu/ }); - await expect(presets.getByRole('button', { pressed: true })).toHaveCount(0); + await expect(page.locator('.rate-scoreboard-dark')).toBeVisible(); + await expect( + page.getByRole('group', { name: /Chấm theo nhu cầu/ }) + ).toHaveCount(0); + await expect( + page.getByRole('button', { name: /Tùy chỉnh trọng số/ }) + ).toHaveCount(0); + + expect((await page.goto('/deals'))?.status()).toBe(200); + await expect(page.locator('#rate-ranked-deals > div').first()).toBeVisible(); + await expect(page.locator('#rate-deal-personas')).toHaveCount(0); }); diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index ca014f74b..e4d3dbaec 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -120,6 +120,27 @@ export function sanitizeVersusMarkdownCell(value: unknown): string { return cleaned || '-'; } +/** + * The use-case presets worth offering for a pair (DOS/Rate#1556): one whose + * heaviest criterion both models have a real score for. With no criterion + * scored on both sides every preset would read as the default one. + */ +export function versusAvailablePersonas( + shared: readonly string[] +): Exclude[] { + if (shared.length === 0) return []; + return ( + Object.keys(PERSONA_WEIGHTS) as Exclude[] + ).filter((key) => { + if (key === 'default') return true; + const weights = PERSONA_WEIGHTS[key]; + const lead = (Object.keys(weights) as (keyof CriteriaWeights)[]).reduce( + (best, c) => (weights[c] > weights[best] ? c : best) + ); + return shared.includes(lead); + }); +} + export function computeVersusWeightedResult( scoreA: ScoredVersusItem, scoreB: ScoredVersusItem, @@ -241,9 +262,30 @@ export default function VersusInteractiveScoreboard({ ? t('mdRowTie') : '-'; + // Presets and weights only where both models have a real criterion score; + // otherwise they would change nothing, so they are not offered + // (DOS/Rate#1556). + const sharedCriteria = useMemo( + () => sharedScoredCriteria(scoreA.criteria, scoreB.criteria), + [scoreA.criteria, scoreB.criteria] + ); + const availablePersonas = versusAvailablePersonas(sharedCriteria); + const canWeigh = availablePersonas.length > 0; + const activePersona: PersonaPresetKey = + persona === 'custom' + ? canWeigh + ? 'custom' + : 'default' + : availablePersonas.includes(persona) + ? persona + : 'default'; + const activeWeights = + activePersona === persona ? weights : PERSONA_WEIGHTS.default; + const weightedResult = useMemo( - () => computeVersusWeightedResult(scoreA, scoreB, weights, persona), - [scoreA, scoreB, weights, persona] + () => + computeVersusWeightedResult(scoreA, scoreB, activeWeights, activePersona), + [scoreA, scoreB, activeWeights, activePersona] ); // The criteria of the pair's category (no screen for earbuds); a side @@ -345,7 +387,7 @@ export default function VersusInteractiveScoreboard({ ], ...criteriaRows.map((r) => [ r.label, - weights[r.key], + activeWeights[r.key], r.aVal != null ? r.aVal.toFixed(2) : '', r.bVal != null ? r.bVal.toFixed(2) : '', !r.compared @@ -421,8 +463,8 @@ export default function VersusInteractiveScoreboard({ const payload = { source: 'Rate.vn 1v1 Versus Scoreboard', versusSlug: `${safeSlugA}-vs-${safeSlugB}`, - persona, - weights, + persona: activePersona, + weights: activeWeights, exportedAt: new Date().toISOString(), tie: weightedResult.tie, winner: weightedResult.tie @@ -453,7 +495,7 @@ export default function VersusInteractiveScoreboard({ criteriaBreakdown: criteriaRows.map((r) => ({ key: r.key, label: r.label, - weightPercent: weights[r.key], + weightPercent: activeWeights[r.key], scoreA: r.aVal, scoreB: r.bVal, })), @@ -482,7 +524,7 @@ export default function VersusInteractiveScoreboard({ const verdict = versusVerdictFor( weightedResult, scoreA.slug, - persona, + activePersona, personaLabels, locale ); @@ -510,62 +552,68 @@ export default function VersusInteractiveScoreboard({ {/* Top Action & Persona Filter Strip */}
{/* Presets expose the pressed one, not only its colour (DOS/Rate#1069). */} -
- - {t('useCaseLabel')} - - {( - [ - { key: 'default', label: t('personaDefault') }, - { key: 'photography', label: t('personaPhotography') }, - { key: 'gaming', label: t('personaGaming') }, - { key: 'endurance', label: t('personaEndurance') }, - { key: 'value_hunter', label: t('personaValueHunter') }, - ] as const - ).map((item) => ( + + {t('useCaseLabel')} + + {( + [ + { key: 'default', label: t('personaDefault') }, + { key: 'photography', label: t('personaPhotography') }, + { key: 'gaming', label: t('personaGaming') }, + { key: 'endurance', label: t('personaEndurance') }, + { key: 'value_hunter', label: t('personaValueHunter') }, + ] as const + ) + .filter((item) => availablePersonas.includes(item.key)) + .map((item) => ( + + ))} - ))} - -
+ {activePersona === 'custom' && ( + {`: ${t('customWeightsActive')}`} + )} + +
+ ) : ( + + )}
- ))} -
+ {t('personaLabel')} +
+ {personaOptions + .filter((p) => availablePersonas.includes(p.key)) + .map((p) => ( + + ))} + + )} {/* Budget Slider & Search */}
diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 8703d4ca2..fa9537dd0 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -14880,6 +14880,45 @@ async function runCoreArchitectureSuite() { ); } + // Versus and deals offer a persona only where its criterion has a real + // score; with none, every persona would rank like the default + // (DOS/Rate#1556). + { + const { availableDealPersonas } = + await import('../../../components/deals/DealFinderWorkspace'); + const { versusAvailablePersonas } = + await import('../../../components/compare/VersusInteractiveScoreboard'); + const phone = (slug: string, criteria?: Record) => + ({ + id: slug, + slug, + name: slug, + brand: 'E2E', + min_price: 10_000_000, + user_rating_weighted: 4.5, + metadata: { category_slug: 'smartphones' }, + expert_criteria_scores: criteria ?? null, + }) as unknown as import('../../../lib/rate-api').MasterProduct; + assert.deepEqual(availableDealPersonas([phone('a'), phone('b')], 'all'), [ + 'default', + ]); + assert.deepEqual( + availableDealPersonas([phone('a'), phone('b', { camera: 9 })], 'all'), + ['default', 'photography'] + ); + assert.deepEqual( + availableDealPersonas([phone('b', { camera: 9 })], 'earbuds'), + ['default'] + ); + assert.deepEqual(versusAvailablePersonas([]), []); + const cameraOnly = versusAvailablePersonas(['camera']); + assert.ok(cameraOnly.includes('default')); + assert.ok(cameraOnly.includes('photography')); + assert.ok(!cameraOnly.includes('gaming')); + assert.ok(!cameraOnly.includes('endurance')); + console.log(' ✅ Personas need a scored criterion (#1556) passed'); + } + console.log('🎉 All Rate.vn Core Architecture tests passed!'); }