Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions apps/web/e2e/deals-paging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 3 additions & 10 deletions apps/web/e2e/light-theme-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
41 changes: 16 additions & 25 deletions apps/web/e2e/pill-group-state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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á'],
Expand Down Expand Up @@ -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(
Expand All @@ -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);
});
206 changes: 128 additions & 78 deletions apps/web/src/components/compare/VersusInteractiveScoreboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersonaPresetKey, 'custom'>[] {
if (shared.length === 0) return [];
return (
Object.keys(PERSONA_WEIGHTS) as Exclude<PersonaPresetKey, 'custom'>[]
).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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})),
Expand Down Expand Up @@ -482,7 +524,7 @@ export default function VersusInteractiveScoreboard({
const verdict = versusVerdictFor(
weightedResult,
scoreA.slug,
persona,
activePersona,
personaLabels,
locale
);
Expand Down Expand Up @@ -510,62 +552,68 @@ export default function VersusInteractiveScoreboard({
{/* Top Action & Persona Filter Strip */}
<div className="d-flex justify-content-between align-items-center border-bottom border-secondary border-opacity-25 mb-3 flex-wrap gap-2 pb-3">
{/* Presets expose the pressed one, not only its colour (DOS/Rate#1069). */}
<div
className="d-flex align-items-center flex-wrap gap-1"
role="group"
aria-labelledby={useCaseLabelId}
>
<span
id={useCaseLabelId}
className="small text-white-50 fw-semibold me-1"
{canWeigh ? (
<div
className="d-flex align-items-center flex-wrap gap-1"
role="group"
aria-labelledby={useCaseLabelId}
>
{t('useCaseLabel')}
</span>
{(
[
{ 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) => (
<span
id={useCaseLabelId}
className="small text-white-50 fw-semibold me-1"
>
{t('useCaseLabel')}
</span>
{(
[
{ 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) => (
<button
key={item.key}
type="button"
aria-pressed={activePersona === item.key}
onClick={() => selectPersona(item.key)}
className={`btn btn-sm rounded-pill px-3 py-1 ${
activePersona === item.key
? 'btn-danger fw-bold text-white'
: 'btn-outline-light text-white-50'
}`}
style={{ fontSize: '0.8125rem' }}
>
{item.label}
</button>
))}
<button
key={item.key}
type="button"
aria-pressed={persona === item.key}
onClick={() => selectPersona(item.key)}
aria-expanded={showSliders}
aria-controls={slidersPanelId}
onClick={() => setShowSliders((prev) => !prev)}
className={`btn btn-sm rounded-pill px-3 py-1 ${
persona === item.key
? 'btn-danger fw-bold text-white'
: 'btn-outline-light text-white-50'
showSliders || activePersona === 'custom'
? 'btn-warning text-dark fw-bold'
: 'btn-outline-secondary text-light'
}`}
style={{ fontSize: '0.8125rem' }}
>
{item.label}
</button>
))}
<button
type="button"
aria-expanded={showSliders}
aria-controls={slidersPanelId}
onClick={() => setShowSliders((prev) => !prev)}
className={`btn btn-sm rounded-pill px-3 py-1 ${
showSliders || persona === 'custom'
? 'btn-warning text-dark fw-bold'
: 'btn-outline-secondary text-light'
}`}
style={{ fontSize: '0.8125rem' }}
>
<BsSliders aria-hidden="true" className="me-1" />
{t('customWeightsBtn')}
{/* Custom weights select no preset and are shown only by this
<BsSliders aria-hidden="true" className="me-1" />
{t('customWeightsBtn')}
{/* Custom weights select no preset and are shown only by this
button's colour, so its name says so too (DOS/Rate#1069). */}
{persona === 'custom' && (
<span className="visually-hidden">{`: ${t('customWeightsActive')}`}</span>
)}
</button>
</div>
{activePersona === 'custom' && (
<span className="visually-hidden">{`: ${t('customWeightsActive')}`}</span>
)}
</button>
</div>
) : (
<span />
)}

<div className="d-flex align-items-center flex-wrap gap-2">
<button
Expand Down Expand Up @@ -605,35 +653,37 @@ export default function VersusInteractiveScoreboard({
</div>

{/* Interactive 5-Criteria Weight Sliders */}
{showSliders && (
{showSliders && canWeigh && (
<div
id={slidersPanelId}
className="rounded-3 border-secondary border-opacity-25 mb-3 border p-3"
style={{ background: 'rgba(255,255,255,0.04)' }}
>
<div className="row g-3">
{criteriaRows.map((c) => (
<div key={c.key} className="col-md col-6">
<div className="d-flex justify-content-between small mb-1 text-white">
<span>{c.label}</span>
<strong className="text-warning rate-tabular">
{formatPercent(weights[c.key], locale)}
</strong>
{criteriaRows
.filter((c) => sharedCriteria.includes(c.key))
.map((c) => (
<div key={c.key} className="col-md col-6">
<div className="d-flex justify-content-between small mb-1 text-white">
<span>{c.label}</span>
<strong className="text-warning rate-tabular">
{formatPercent(activeWeights[c.key], locale)}
</strong>
</div>
<input
type="range"
min={0}
max={100}
step={5}
value={activeWeights[c.key]}
onChange={(e) =>
updateWeight(c.key, Number(e.target.value))
}
className="form-range"
aria-label={c.label}
/>
</div>
<input
type="range"
min={0}
max={100}
step={5}
value={weights[c.key]}
onChange={(e) =>
updateWeight(c.key, Number(e.target.value))
}
className="form-range"
aria-label={c.label}
/>
</div>
))}
))}
</div>
</div>
)}
Expand Down Expand Up @@ -761,7 +811,7 @@ export default function VersusInteractiveScoreboard({
<div className="small text-light fw-semibold col-4 text-center">
{row.label}{' '}
<span className="text-white-50 rate-tabular">
({formatPercent(weights[row.key], locale)})
({formatPercent(activeWeights[row.key], locale)})
</span>
</div>
<div className="col-4 text-end">
Expand Down
Loading
Loading