diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index f3188c6e0..beda56ed6 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -134,6 +134,7 @@ "tieDeltaNote": "(stejné skóre při aktuálních vahách)", "tie1v1Badge": "Remíza 1v1", "versusAutoVerdictTie": "Automatický verdikt: oba modely mají stejné celkové skóre ({score}); rozhodněte podle ceny a parametrů, které potřebujete.", + "versusAutoVerdictNoScore": "Automatický verdikt: ani jeden model zatím nemá hodnocení; rozhodněte podle ceny a parametrů, které potřebujete.", "versusVerdictWeights": "(Váhy: {weights}.)", "compareMetaTitleWithNames": "Porovnání {names} - Hodnocení a 10 skupin parametrů | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index e290e6683..270b8366f 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -134,6 +134,7 @@ "tieDeltaNote": "(same score with the current weights)", "tie1v1Badge": "1v1 Tie", "versusAutoVerdictTie": "Automatic verdict: the two models are level on overall score ({score}); choose on price and the specs you need.", + "versusAutoVerdictNoScore": "Automatic verdict: neither model has a rating yet; choose on price and the specs you need.", "versusVerdictWeights": "(Weights: {weights}.)", "compareMetaTitleWithNames": "Compare {names} - Ratings and 10 spec groups | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index fec693473..0eec6824f 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -134,6 +134,7 @@ "tieDeltaNote": "(cùng điểm theo trọng số hiện tại)", "tie1v1Badge": "Ngang điểm 1v1", "versusAutoVerdictTie": "Kết luận tự động: hai mẫu ngang điểm tổng hợp ({score}), chọn theo giá và thông số bạn cần.", + "versusAutoVerdictNoScore": "Kết luận tự động: cả hai mẫu chưa có đánh giá, chọn theo giá và thông số bạn cần.", "versusVerdictWeights": "(Theo trọng số: {weights}.)", "compareMetaTitleWithNames": "So sánh {names} - Điểm đánh giá và 10 nhóm thông số | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", diff --git a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts index 84993d111..1a05d83ef 100644 --- a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts +++ b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts @@ -883,7 +883,20 @@ async function testSpecMatchesBodies() { get(`/api/v1/rate/versus?pair=${PHONE.slug}-vs-${PHONE_2.slug}`) ); assert.equal(vs.status, 200); - expectPaths('/api/v1/rate/versus', await vs.json(), [ + const vsBody = await vs.json(); + // The tie and the winner read the /10 scores as shown, at one decimal + // (#1545): a tie only when they are equal, else the higher one wins. + const vsA10 = vsBody.product_a.custom_weighted_score_10; + const vsB10 = vsBody.product_b.custom_weighted_score_10; + assert.equal(vsBody.is_tie, vsA10 === vsB10); + if (!vsBody.is_tie) { + assert.equal(vsBody.overall_winner.side, vsA10 > vsB10 ? 'A' : 'B'); + assert.equal( + vsBody.overall_winner.margin_10, + Number(Math.abs(vsA10 - vsB10).toFixed(1)) + ); + } + expectPaths('/api/v1/rate/versus', vsBody, [ 'is_tie', 'versus_pair', ...under('deal_radar.productA', radar), diff --git a/apps/web/src/app/api/v1/rate/versus/route.ts b/apps/web/src/app/api/v1/rate/versus/route.ts index 76aeba948..089388a7c 100644 --- a/apps/web/src/app/api/v1/rate/versus/route.ts +++ b/apps/web/src/app/api/v1/rate/versus/route.ts @@ -7,6 +7,7 @@ import { getMasterProductOffersBatch, } from '@/server/rate'; import { radarExportFields } from '@/lib/deal-radar'; +import { versusApiWinner } from '@/lib/versus-verdict'; import { comparePositiveMinPrices, scoreMasterProductForLeaderboard, @@ -305,18 +306,16 @@ export async function GET(req: NextRequest) { scoreB.minPrice ); - const winnerSide: 'A' | 'B' = - customScoreA5 > customScoreB5 - ? 'A' - : customScoreB5 > customScoreA5 - ? 'B' - : scoreA.combinedScore5 > scoreB.combinedScore5 - ? 'A' - : scoreB.combinedScore5 > scoreA.combinedScore5 - ? 'B' - : priceSide === 'B' - ? 'B' - : 'A'; + // The winner and the tie read the scores as the response shows them, at + // one decimal; the composite and then the price only break a tie + // (DOS/Rate#1545, D3). + const { tie: isTie, side: winnerSide } = versusApiWinner( + customScoreA10, + customScoreB10, + scoreA.combinedScore5, + scoreB.combinedScore5, + priceSide + ); const winnerScore = winnerSide === 'A' ? scoreA : scoreB; const winnerCustom10 = winnerSide === 'A' ? customScoreA10 : customScoreB10; @@ -399,11 +398,9 @@ export async function GET(req: NextRequest) { productA: radarSignalA, productB: radarSignalB, }, - // Level at two decimals: overall_winner is then only the tie-break - // (DOS/Rate#1209). - is_tie: - customScoreA5.toFixed(2) === customScoreB5.toFixed(2) && - scoreA.combinedScore5.toFixed(2) === scoreB.combinedScore5.toFixed(2), + // Level at the shown precision: overall_winner is then only the + // tie-break (DOS/Rate#1209, DOS/Rate#1545). + is_tie: isTie, overall_winner: { side: winnerSide, slug: winnerScore.slug, diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index d56406d4c..4680e6fa4 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -187,6 +187,10 @@ export default function VersusInteractiveScoreboard({ const scoreA = swapped ? secondScore : firstScore; const scoreB = swapped ? firstScore : secondScore; const t = useTranslations('compare'); + const tStars = useTranslations('starRating'); + // A side with no score reads like its stars, not "0.0" (DOS/Rate#1545). + const summaryScore = (score: number): string => + score > 0 ? formatDecimal(score, locale, 1) : tStars('empty'); const locale = useLocale(); const useCaseLabelId = useId(); const slidersPanelId = useId(); @@ -479,9 +483,11 @@ export default function VersusInteractiveScoreboard({ aria-live="polite" > {/* Narrows the values to each key's own placeholders. */} - {verdict.key === 'versusAutoVerdictTie' - ? t(verdict.key, verdict.values) - : t(verdict.key, verdict.values)} + {verdict.key === 'versusAutoVerdictNoScore' + ? t(verdict.key) + : verdict.key === 'versusAutoVerdictTie' + ? t(verdict.key, verdict.values) + : t(verdict.key, verdict.values)} {verdict.weightsLabel && ` ${t('versusVerdictWeights', { weights: verdict.weightsLabel })}`}
@@ -650,8 +656,8 @@ export default function VersusInteractiveScoreboard({ )}