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({ )}
- {scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 1)} •{' '} - {scoreB.brand}: {formatDecimal(weightedResult.finalB, locale, 1)} + {scoreA.brand}: {summaryScore(weightedResult.finalA)} •{' '} + {scoreB.brand}: {summaryScore(weightedResult.finalB)}
diff --git a/apps/web/src/lib/__tests__/locale-format.test.ts b/apps/web/src/lib/__tests__/locale-format.test.ts index e182d50da..6b53579de 100644 --- a/apps/web/src/lib/__tests__/locale-format.test.ts +++ b/apps/web/src/lib/__tests__/locale-format.test.ts @@ -7,7 +7,7 @@ import { radarExplanation } from '../product-detail-copy'; import { weightExplanationCopy } from '../review-display'; import { fitTitle } from '../seo'; import { localizeSpecValue } from '../spec-localizer'; -import { versusVerdictCopy } from '../versus-verdict'; +import { versusApiWinner, versusVerdictCopy } from '../versus-verdict'; import { NBSP, collapseWhitespace, @@ -313,6 +313,30 @@ assert.deepEqual( versusVerdictCopy({ ...versusInput, tie: true }, 'cs').values, { score: '4,9' } ); +// Neither model has a score: no "0.0" tie (DOS/Rate#1545). +assert.equal( + versusVerdictCopy({ ...versusInput, tie: true, score: 0 }).key, + 'versusAutoVerdictNoScore' +); + +// The 1v1 API decides on the /10 scores at one decimal; the composite and +// then the lower price only break a tie (DOS/Rate#1545). +assert.deepEqual(versusApiWinner(8.9, 8.7, 4.0, 4.9, 'B'), { + tie: false, + side: 'A', +}); +assert.deepEqual(versusApiWinner(9.04, 8.96, 4.0, 4.9, 'A'), { + tie: true, + side: 'B', +}); +assert.deepEqual(versusApiWinner(9.0, 9.0, 4.5, 4.5, 'B'), { + tie: true, + side: 'B', +}); +assert.deepEqual(versusApiWinner(9.0, 9.0, 4.5, 4.5, 'equal'), { + tie: true, + side: 'A', +}); // cs.json: a placeholder before % or ₫ is joined by one U+00A0, and no // rating scale is written `/ 5.0`. diff --git a/apps/web/src/lib/versus-verdict.ts b/apps/web/src/lib/versus-verdict.ts index 1eac07024..8ce1659d3 100644 --- a/apps/web/src/lib/versus-verdict.ts +++ b/apps/web/src/lib/versus-verdict.ts @@ -10,6 +10,32 @@ export function isVersusScoreTie(scoreA: number, scoreB: number): boolean { return scoreA.toFixed(1) === scoreB.toFixed(1); } +/** + * The 1v1 API's winner and tie (DOS/Rate#1545): the persona scores on 10, at + * one decimal as the response shows them, decide; on a tie the composite, + * then the lower price, then side A only pick `overall_winner`. + */ +export function versusApiWinner( + score10A: number, + score10B: number, + combined5A: number, + combined5B: number, + lowerPriceSide: string | null | undefined +): { tie: boolean; side: 'A' | 'B' } { + const a = Number.isFinite(score10A) ? Number(score10A.toFixed(1)) : 0; + const b = Number.isFinite(score10B) ? Number(score10B.toFixed(1)) : 0; + if (a !== b) return { tie: false, side: a > b ? 'A' : 'B' }; + const side: 'A' | 'B' = + combined5A > combined5B + ? 'A' + : combined5B > combined5A + ? 'B' + : lowerPriceSide === 'B' + ? 'B' + : 'A'; + return { tie: true, side }; +} + export interface VersusVerdictInput { tie: boolean; /** The leader's score under the weights in use (either side on a tie). */ @@ -21,6 +47,11 @@ export interface VersusVerdictInput { } export type VersusVerdictCopy = + | { + key: 'versusAutoVerdictNoScore'; + values: Record; + weightsLabel: string | null; + } | { key: 'versusAutoVerdictTie'; values: { score: string }; @@ -41,6 +72,15 @@ export function versusVerdictCopy( input: VersusVerdictInput, locale?: string | null ): VersusVerdictCopy { + // Level because neither model has a score yet: say so, as the stars do, + // instead of a "0.0" tie (DOS/Rate#1545). + if (input.tie && !(input.score > 0)) { + return { + key: 'versusAutoVerdictNoScore', + values: {}, + weightsLabel: input.weightsLabel, + }; + } const score = formatDecimal(input.score, locale, 1); if (input.tie) { return { diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index 457893dcc..c6e68107a 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -627,7 +627,7 @@ function buildBaseRateOpenApiSpec( summary: 'Get programmatic 1v1 Head-to-Head JReviews verdict, 5-criteria scoreboard & price delta', description: - 'Evaluates two same-category master products (`?pair=-vs-` or `?slugA=...&slugB=...`) with optional persona/custom 5-criteria weights (`persona`, `w_screen`, `w_performance`, `w_camera`, `w_battery`, `w_value`) and returns the overall winner (with `is_tie` true when both scores are level at two decimals; `overall_winner` is then only the price or side-A tie-break), per-criterion head-to-head breakdown, price delta, Deal Radar signals (`deal_radar`), and 10-group specs.', + 'Evaluates two same-category master products (`?pair=-vs-` or `?slugA=...&slugB=...`) with optional persona/custom 5-criteria weights (`persona`, `w_screen`, `w_performance`, `w_camera`, `w_battery`, `w_value`) and returns the overall winner (with `is_tie` true when both `custom_weighted_score_10` values are equal at one decimal; `overall_winner` is then only the tie-break: the combined score, then the lower price, then side A), per-criterion head-to-head breakdown, price delta, Deal Radar signals (`deal_radar`), and 10-group specs.', tags: ['Comparison & Watchlist'], parameters: [ {