Files
web_sport/apps/api/src/modules/reviews/review-summary.spec.ts
T
2026-08-13 23:20:23 +07:00

79 lines
2.7 KiB
TypeScript

import { REVIEW_RATING_MAX, REVIEW_RATING_MIN } from '@sport/types';
/**
* The rating aggregate, extracted so it can be pinned without a database.
*
* These four lines decide the number under every product name in the store. A
* mistake here is not a crash — it is a product quietly displaying 4.5 stars it
* did not earn, on every page, until somebody notices by eye.
*/
export function summarise(ratings: readonly number[]): {
average: number;
count: number;
distribution: { rating: number; count: number }[];
} {
const count = ratings.length;
const sum = ratings.reduce((total, rating) => total + rating, 0);
const distribution: { rating: number; count: number }[] = [];
for (let rating = REVIEW_RATING_MAX; rating >= REVIEW_RATING_MIN; rating -= 1) {
distribution.push({ rating, count: ratings.filter((value) => value === rating).length });
}
return {
average: count === 0 ? 0 : Math.round((sum / count) * 10) / 10,
count,
distribution,
};
}
describe('review summary', () => {
it('averages to one decimal place', () => {
expect(summarise([5, 4, 4]).average).toBe(4.3);
});
it('does not divide by zero on an unreviewed product', () => {
const summary = summarise([]);
expect(summary.average).toBe(0);
expect(summary.count).toBe(0);
});
it('reports every star level, including the empty ones', () => {
// A distribution with gaps renders as a bar chart with missing bars rather
// than bars at zero, which reads as a broken chart.
const summary = summarise([5, 5, 1]);
expect(summary.distribution).toEqual([
{ rating: 5, count: 2 },
{ rating: 4, count: 0 },
{ rating: 3, count: 0 },
{ rating: 2, count: 0 },
{ rating: 1, count: 1 },
]);
});
it('orders the distribution from best to worst', () => {
expect(summarise([3]).distribution.map((bucket) => bucket.rating)).toEqual([5, 4, 3, 2, 1]);
});
it('distinguishes a polarised product from a mediocre one', () => {
// Both average 3.0. The distribution is the only thing that tells a shopper
// which one has a sizing problem, which is why it is sent alongside.
const mediocre = summarise([3, 3, 3, 3]);
const polarised = summarise([5, 5, 1, 1]);
expect(mediocre.average).toBe(polarised.average);
expect(mediocre.distribution).not.toEqual(polarised.distribution);
});
it('rounds half up so 4.25 does not read as 4.2', () => {
expect(summarise([5, 4, 4, 4]).average).toBe(4.3);
});
it('keeps a whole number whole', () => {
// 4.0 rather than 4 matters for display, but the value must still equal 4.
expect(summarise([4, 4, 4])).toMatchObject({ average: 4, count: 3 });
});
});