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
1 change: 1 addition & 0 deletions apps/web/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
31 changes: 14 additions & 17 deletions apps/web/src/app/api/v1/rate/versus/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getMasterProductOffersBatch,
} from '@/server/rate';
import { radarExportFields } from '@/lib/deal-radar';
import { versusApiWinner } from '@/lib/versus-verdict';
import {
comparePositiveMinPrices,
scoreMasterProductForLeaderboard,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 11 additions & 5 deletions apps/web/src/components/compare/VersusInteractiveScoreboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 })}`}
</p>
Expand Down Expand Up @@ -650,8 +656,8 @@ export default function VersusInteractiveScoreboard({
)}
</div>
<div className="rate-tabular small text-warning fw-bold">
{scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 1)} •{' '}
{scoreB.brand}: {formatDecimal(weightedResult.finalB, locale, 1)}
{scoreA.brand}: {summaryScore(weightedResult.finalA)} •{' '}
{scoreB.brand}: {summaryScore(weightedResult.finalB)}
</div>
</div>

Expand Down
26 changes: 25 additions & 1 deletion apps/web/src/lib/__tests__/locale-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`.
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/lib/versus-verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -21,6 +47,11 @@ export interface VersusVerdictInput {
}

export type VersusVerdictCopy =
| {
key: 'versusAutoVerdictNoScore';
values: Record<string, never>;
weightsLabel: string | null;
}
| {
key: 'versusAutoVerdictTie';
values: { score: string };
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/server/rate/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<slugA>-vs-<slugB>` 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=<slugA>-vs-<slugB>` 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: [
{
Expand Down
Loading