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 }); }); });