diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index 9bf985c0..d56406d4 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -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; } @@ -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), @@ -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 @@ -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 @@ -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) => [ @@ -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), @@ -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), @@ -635,7 +642,7 @@ export default function VersusInteractiveScoreboard({ delta: formatDecimal( Number(weightedResult.delta), locale, - 2 + 1 ), })} @@ -643,8 +650,8 @@ export default function VersusInteractiveScoreboard({ )}
- {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)}
@@ -658,7 +665,7 @@ export default function VersusInteractiveScoreboard({ {scoreA.name}
- + {scoreA.expertScore10 != null ? `Lab ${formatDecimal(scoreA.expertScore10, locale, 1)}/10` @@ -679,7 +686,7 @@ export default function VersusInteractiveScoreboard({ {scoreB.name}
- + {scoreB.expertScore10 != null ? `Lab ${formatDecimal(scoreB.expertScore10, locale, 1)}/10` diff --git a/apps/web/src/lib/__tests__/locale-format.test.ts b/apps/web/src/lib/__tests__/locale-format.test.ts index 308c6ff9..e182d50d 100644 --- a/apps/web/src/lib/__tests__/locale-format.test.ts +++ b/apps/web/src/lib/__tests__/locale-format.test.ts @@ -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 diff --git a/apps/web/src/lib/versus-verdict.ts b/apps/web/src/lib/versus-verdict.ts index 0e0864f9..1eac0702 100644 --- a/apps/web/src/lib/versus-verdict.ts +++ b/apps/web/src/lib/versus-verdict.ts @@ -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 { @@ -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', 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 62cf4040..42c24dbf 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 @@ -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 @@ -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 }, }; @@ -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 }, }; @@ -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'); @@ -11485,7 +11506,7 @@ async function runCoreArchitectureSuite() { }), { key: 'versusAutoVerdictTie', - values: { score: '5.00' }, + values: { score: '5.0' }, weightsLabel: null, } );