79 lines
2.7 KiB
TypeScript
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 });
|
|
});
|
|
});
|