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
35 changes: 21 additions & 14 deletions apps/web/src/components/compare/VersusInteractiveScoreboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ export function computeVersusWeightedResult(
const rawSumW = wScreen + wPerf + wCam + wBat + wVal;

const calcWeighted = (item: ScoredVersusItem): number => {
const baseScore5 = sanitizeVersusScore5(item.combinedScore5);
// The product's rating as every page shows it (DOS/Rate#1470); a model
// with no buyer rating yet keeps its composite (expert or criteria).
const baseScore5 = sanitizeVersusScore5(
item.userScore5 > 0 ? item.userScore5 : item.combinedScore5
);
if (persona === 'default') {
return baseScore5;
}
Expand Down Expand Up @@ -163,7 +167,10 @@ export function computeVersusWeightedResult(
return {
finalA,
finalB,
delta: Math.abs(finalA - finalB).toFixed(2),
// The gap between the scores as shown, at one decimal.
delta: Math.abs(
Number(finalA.toFixed(1)) - Number(finalB.toFixed(1))
).toFixed(1),
winner,
// Level at the shown precision: no lead for either (DOS/Rate#1209).
tie: isVersusScoreTie(finalA, finalB),
Expand Down Expand Up @@ -266,8 +273,8 @@ export default function VersusInteractiveScoreboard({
const safePriceAdv = sanitizeVersusMarkdownCell(priceAdvantageLabel);
const leadScore =
weightedResult.finalA >= weightedResult.finalB
? weightedResult.finalA.toFixed(2)
: weightedResult.finalB.toFixed(2);
? weightedResult.finalA.toFixed(1)
: weightedResult.finalB.toFixed(1);
const lines = [
`### ${t('md1v1Heading', { nameA: safeNameA, nameB: safeNameB })}`,
weightedResult.tie
Expand All @@ -277,7 +284,7 @@ export default function VersusInteractiveScoreboard({
'',
`| ${t('mdColCriterion')} | ${safeNameA} | ${safeNameB} | ${t('mdColAdvantage')} |`,
`| --- | --- | --- | --- |`,
`| **${t('mdRowWeightedScore')}** | **${weightedResult.finalA.toFixed(2)}/5.0** | **${weightedResult.finalB.toFixed(2)}/5.0** | **${weightedResult.tie ? sanitizeVersusMarkdownCell(t('mdRowTie')) : safeWinnerName}** |`,
`| **${t('mdRowWeightedScore')}** | **${weightedResult.finalA.toFixed(1)}/5.0** | **${weightedResult.finalB.toFixed(1)}/5.0** | **${weightedResult.tie ? sanitizeVersusMarkdownCell(t('mdRowTie')) : safeWinnerName}** |`,
...criteriaRows.map((r) => {
const winName =
r.aVal > r.bVal
Expand Down Expand Up @@ -315,8 +322,8 @@ export default function VersusInteractiveScoreboard({
[
t('csvRowWeightedScore5'),
100,
weightedResult.finalA.toFixed(2),
weightedResult.finalB.toFixed(2),
weightedResult.finalA.toFixed(1),
weightedResult.finalB.toFixed(1),
weightedResult.tie ? t('mdRowTie') : weightedResult.winner.name,
],
...criteriaRows.map((r) => [
Expand Down Expand Up @@ -410,7 +417,7 @@ export default function VersusInteractiveScoreboard({
slug: safeSlugA,
name: scoreA.name,
brand: scoreA.brand,
weightedScore5: weightedResult.finalA,
weightedScore5: Number(weightedResult.finalA.toFixed(1)),
expertScore10: scoreA.expertScore10,
minPriceVnd: scoreA.minPrice,
dealRadar: radarExportFields(sigA),
Expand All @@ -419,7 +426,7 @@ export default function VersusInteractiveScoreboard({
slug: safeSlugB,
name: scoreB.name,
brand: scoreB.brand,
weightedScore5: weightedResult.finalB,
weightedScore5: Number(weightedResult.finalB.toFixed(1)),
expertScore10: scoreB.expertScore10,
minPriceVnd: scoreB.minPrice,
dealRadar: radarExportFields(sigB),
Expand Down Expand Up @@ -635,16 +642,16 @@ export default function VersusInteractiveScoreboard({
delta: formatDecimal(
Number(weightedResult.delta),
locale,
2
1
),
})}
</span>
</>
)}
</div>
<div className="rate-tabular small text-warning fw-bold">
{scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 2)} •{' '}
{scoreB.brand}: {formatDecimal(weightedResult.finalB, locale, 2)}
{scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 1)} •{' '}
{scoreB.brand}: {formatDecimal(weightedResult.finalB, locale, 1)}
</div>
</div>

Expand All @@ -658,7 +665,7 @@ export default function VersusInteractiveScoreboard({
{scoreA.name}
</Link>
<div className="d-flex mt-1 flex-wrap gap-2">
<StarRating value={weightedResult.finalA} digits={2} />
<StarRating value={weightedResult.finalA} />
<span className="rate-score-badge-expert">
{scoreA.expertScore10 != null
? `Lab ${formatDecimal(scoreA.expertScore10, locale, 1)}/10`
Expand All @@ -679,7 +686,7 @@ export default function VersusInteractiveScoreboard({
{scoreB.name}
</Link>
<div className="d-flex justify-content-end mt-1 flex-wrap gap-2">
<StarRating value={weightedResult.finalB} digits={2} />
<StarRating value={weightedResult.finalB} />
<span className="rate-score-badge-expert">
{scoreB.expertScore10 != null
? `Lab ${formatDecimal(scoreB.expertScore10, locale, 1)}/10`
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/lib/__tests__/locale-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,17 +300,18 @@ assert.deepEqual(
);
assert.deepEqual(versusVerdictCopy(versusInput, 'vi').values, {
winner: 'A',
score: '4.92',
// One decimal, like every rating on the site (DOS/Rate#1470).
score: '4.9',
expertSuffix: ' (9.1/10 Lab)',
});
assert.deepEqual(versusVerdictCopy(versusInput, 'cs').values, {
winner: 'A',
score: '4,92',
score: '4,9',
expertSuffix: ' (9,1/10 Lab)',
});
assert.deepEqual(
versusVerdictCopy({ ...versusInput, tie: true }, 'cs').values,
{ score: '4,92' }
{ score: '4,9' }
);

// cs.json: a placeholder before % or ₫ is joined by one U+00A0, and no
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/lib/versus-verdict.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { formatDecimal } from './locale-format';

/**
* Whether two 1v1 scores are level as the page shows them, at two decimals
* (DOS/Rate#1209): 5.00 vs 5.00 is a tie, not a lead for either model.
* Whether two 1v1 scores are level as the page shows them (DOS/Rate#1209):
* at one decimal, like the comparison matrix and every rating on the site
* (DOS/Rate#1470). 5.0 vs 5.0 is a tie, not a lead for either model.
*/
export function isVersusScoreTie(scoreA: number, scoreB: number): boolean {
if (!Number.isFinite(scoreA) || !Number.isFinite(scoreB)) return false;
return scoreA.toFixed(2) === scoreB.toFixed(2);
return scoreA.toFixed(1) === scoreB.toFixed(1);
}

export interface VersusVerdictInput {
Expand Down Expand Up @@ -40,7 +41,7 @@ export function versusVerdictCopy(
input: VersusVerdictInput,
locale?: string | null
): VersusVerdictCopy {
const score = formatDecimal(input.score, locale, 2);
const score = formatDecimal(input.score, locale, 1);
if (input.tie) {
return {
key: 'versusAutoVerdictTie',
Expand Down
33 changes: 27 additions & 6 deletions apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11415,10 +11415,29 @@ async function runCoreArchitectureSuite() {
'custom'
);
assert.equal(selfRes.tie, true);
assert.equal(selfRes.delta, '0.00');
assert.equal(selfRes.delta, '0.0');
const { isVersusScoreTie } = await import('../../../lib/versus-verdict');
assert.equal(isVersusScoreTie(5, 4.996), true, 'equal at two decimals');
assert.equal(isVersusScoreTie(4.98, 4.97), false);
// One decimal, as the matrix and every rating show it (DOS/Rate#1470).
assert.equal(isVersusScoreTie(5, 4.96), true, 'equal at one decimal');
assert.equal(isVersusScoreTie(4.98, 5), true, '5.0 against 5.0');
assert.equal(isVersusScoreTie(4.9, 4.8), false);
// A model with no buyer rating keeps its composite as the base, and the
// gap is the one between the scores as shown (DOS/Rate#1470).
const expertOnly = computeVersusWeightedResult(
{ ...scoreItemA, userScore5: 0, combinedScore5: 4.5 },
{ ...scoreItemB, userScore5: 4.2, combinedScore5: 4.2 },
{ screen: 20, performance: 20, camera: 20, battery: 20, value: 20 },
'default'
);
assert.equal(expertOnly.finalA, 4.5);
assert.equal(expertOnly.delta, '0.3');
const shownGap = computeVersusWeightedResult(
{ ...scoreItemA, userScore5: 4.46 },
{ ...scoreItemB, userScore5: 4.34 },
{ screen: 20, performance: 20, camera: 20, battery: 20, value: 20 },
'default'
);
assert.equal(shownGap.delta, '0.2', '4.5 against 4.3');
assert.equal(isVersusScoreTie(Number.NaN, 5), false);
// The verdict follows the persona: A leads on the default weights, B on
// the camera persona, and the verdict names each in turn with its
Expand All @@ -11430,6 +11449,7 @@ async function runCoreArchitectureSuite() {
slug: 'cam-a',
name: 'Model A',
combinedScore5: 4.9,
userScore5: 4.9,
expertScore10: null,
criteria: { screen: 5, performance: 5, camera: 3, battery: 5, value: 5 },
};
Expand All @@ -11438,6 +11458,7 @@ async function runCoreArchitectureSuite() {
slug: 'cam-b',
name: 'Model B',
combinedScore5: 4.6,
userScore5: 4.6,
expertScore10: null,
criteria: { screen: 4, performance: 4, camera: 5, battery: 4, value: 4 },
};
Expand Down Expand Up @@ -11468,9 +11489,9 @@ async function runCoreArchitectureSuite() {
byCamera.key === 'versusAutoVerdict'
) {
assert.equal(byDefault.values.winner, 'Model A');
assert.equal(byDefault.values.score, '4.90');
assert.equal(byDefault.values.score, '4.9');
assert.equal(byCamera.values.winner, 'Model B');
assert.equal(byCamera.values.score, '4.50');
assert.equal(byCamera.values.score, '4.5');
}
assert.equal(byDefault.weightsLabel, null);
assert.equal(byCamera.weightsLabel, 'Chụp ảnh / Âm thanh');
Expand All @@ -11485,7 +11506,7 @@ async function runCoreArchitectureSuite() {
}),
{
key: 'versusAutoVerdictTie',
values: { score: '5.00' },
values: { score: '5.0' },
weightsLabel: null,
}
);
Expand Down
Loading