From 9c08b4e941eca548cbfc518f72c6c4db7fbb54ca Mon Sep 17 00:00:00 2001 From: David Horm Date: Mon, 20 Mar 2023 21:01:21 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20update=20=20sort=20score=20-=20MIN(?= =?UTF-8?q?=E2=88=9ABest#,10)=20+=20Best%=20+=20(Rec%=20*=200.8)=20-=20tak?= =?UTF-8?q?e=20average=20when=20more=20than=20one=20-=20refactor=20score?= =?UTF-8?q?=20calc=20to=20service=20-=20add=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useGetCollectionQuery.utils.spec.ts | 5 - .../hooks/useGetCollectionQuery.utils.ts | 15 -- .../player-count-recommendation.service.ts | 50 ++++--- .../useCollectionFilters.spec.tsx | 137 +++++++++++++++++- 4 files changed, 166 insertions(+), 41 deletions(-) diff --git a/src/components/BggCollection/hooks/useGetCollectionQuery.utils.spec.ts b/src/components/BggCollection/hooks/useGetCollectionQuery.utils.spec.ts index bffb086..1c87cc3 100644 --- a/src/components/BggCollection/hooks/useGetCollectionQuery.utils.spec.ts +++ b/src/components/BggCollection/hooks/useGetCollectionQuery.utils.spec.ts @@ -119,7 +119,6 @@ describe(transformToBoardGame.name, () => { "RecommendedPercent": 16, "playerCountValue": 1, "playerCountLabel": "1", - "sortScore": 102, }, { "Best": 5, @@ -130,7 +129,6 @@ describe(transformToBoardGame.name, () => { "RecommendedPercent": 57, "playerCountValue": 2, "playerCountLabel": "2", - "sortScore": 57, }, { "Best": 0, @@ -141,7 +139,6 @@ describe(transformToBoardGame.name, () => { "RecommendedPercent": 29, "playerCountValue": 3, "playerCountLabel": "3", - "sortScore": -34, }, { "Best": 0, @@ -152,7 +149,6 @@ describe(transformToBoardGame.name, () => { "RecommendedPercent": 12, "playerCountValue": 4, "playerCountLabel": "4", - "sortScore": -61, }, { "Best": 0, @@ -163,7 +159,6 @@ describe(transformToBoardGame.name, () => { "RecommendedPercent": 0, "playerCountValue": 5, "playerCountLabel": "4+", - "sortScore": -80, }, ], }; diff --git a/src/components/BggCollection/hooks/useGetCollectionQuery.utils.ts b/src/components/BggCollection/hooks/useGetCollectionQuery.utils.ts index 6351b64..92ff277 100644 --- a/src/components/BggCollection/hooks/useGetCollectionQuery.utils.ts +++ b/src/components/BggCollection/hooks/useGetCollectionQuery.utils.ts @@ -124,18 +124,6 @@ const calcPercentAndAddSortScore = ( const RecommendedPercent = Math.round((Recommended * 100) / totalVotes); const NotRecommendedPercent = Math.round((notRec * 100) / totalVotes); - const maybeSortScore = Math.round( - Math.min(Math.sqrt(Best), 10) + - BestPercent + - RecommendedPercent * 0.8 - - NotRecommendedPercent * 0.8 - ); - - const sortScore = - totalVotes === 0 || Number.isNaN(maybeSortScore) - ? Number.NEGATIVE_INFINITY - : maybeSortScore; - return { ...playerRec, @@ -147,9 +135,6 @@ const calcPercentAndAddSortScore = ( /** Percentage of users not recommending this player count. */ NotRecommendedPercent, - - /** 2xBest Raw + Best % + Recommended Raw + Recommended % - Not Recommended Row - Not Recommended % */ - sortScore, }; }; diff --git a/src/services/filter-sort-services/player-count-recommendation.service.ts b/src/services/filter-sort-services/player-count-recommendation.service.ts index a4a713c..6a83159 100644 --- a/src/services/filter-sort-services/player-count-recommendation.service.ts +++ b/src/services/filter-sort-services/player-count-recommendation.service.ts @@ -1,4 +1,5 @@ -import { CollectionFilterState, SimpleBoardGame } from "@/types"; +import { CollectionFilterState, SimpleBoardGame, BoardGame } from "@/types"; +import { g } from "vitest/dist/index-761e769b"; import { numberSort, SortFn } from "./sort.service"; /** Used to determine which bar to highlight in the graph */ @@ -21,22 +22,37 @@ const addIsPlayerCountWithinRange = })); }; -const calcSortScoreSum = ( - game: SimpleBoardGame, - minRange: number, - maxRange: number -): number => - game.recommendedPlayerCount - .filter( - (g) => minRange <= g.playerCountValue && g.playerCountValue <= maxRange - ) - .reduce((prev, curr) => curr.sortScore + prev, 0); - -const sort: SortFn = (dir, a, b, filterState) => { - const [minRange, maxRange] = filterState.playerCountRange; - - const valueA = calcSortScoreSum(a, minRange, maxRange); - const valueB = calcSortScoreSum(b, minRange, maxRange); +/** + * MIN(√Best#,10) + Best% + (Rec% * 0.8) + * See [v1.1.3 in this reply](https://boardgamegeek.com/thread/3028512/article/41805927#41805927) for more details why this formula. + */ +const calcSortScore = ({ + Best, + BestPercent, + RecommendedPercent, +}: BoardGame["recommendedPlayerCount"][number]) => { + const maybeSortScore = Math.round( + Math.min(Math.sqrt(Best), 10) + BestPercent + RecommendedPercent * 0.8 + ); + + return Number.isNaN(maybeSortScore) + ? Number.NEGATIVE_INFINITY + : maybeSortScore; +}; + +const calcSortScoreAverage = (game: BoardGame): number => { + const sortScores = game.recommendedPlayerCount + .filter((g) => g.isPlayerCountWithinRange) + .map(calcSortScore); + + const sortScoreSum = sortScores.reduce((a, b) => a + b, 0); + + return sortScoreSum / sortScores.length; +}; + +const sort: SortFn = (dir, a, b) => { + const valueA = calcSortScoreAverage(a); + const valueB = calcSortScoreAverage(b); return numberSort(dir, valueA, valueB); }; diff --git a/src/services/filter-sort-services/useCollectionFilters.spec.tsx b/src/services/filter-sort-services/useCollectionFilters.spec.tsx index bc3a48e..f89bffe 100644 --- a/src/services/filter-sort-services/useCollectionFilters.spec.tsx +++ b/src/services/filter-sort-services/useCollectionFilters.spec.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import type { SimpleBoardGame } from "@/types"; +import type { BoardGame, SimpleBoardGame } from "@/types"; import { cleanup, render, screen } from "@testing-library/react"; import { describe, test, expect, afterEach } from "vitest"; import { @@ -319,6 +319,9 @@ describe(applyFiltersAndSorts.name, () => { afterEach(() => cleanup()); + // When creating a list of mock games to later sort, then create them in the initial 2-3-1 order + const DEFAULT_GAME_NAMES = ["2", "3", "1"]; + const GIVEN_GAME_PROPS: Record[]> = { "name": ["second", "third", "first"].map((name) => ({ name })), "same min max playtime": [20, 30, 10].map((time) => ({ @@ -334,7 +337,6 @@ describe(applyFiltersAndSorts.name, () => { "averageRating": [2, 3, 1].map((averageRating) => ({ averageRating })), }; - // TODO: add test cases for Player Count Recommendation // TODO: add test cases for user ratings test.each` @@ -352,9 +354,8 @@ describe(applyFiltersAndSorts.name, () => { `( "GIVEN games ordered 2-3-1, WHEN sortBys=$sortBys, THEN expectedOrder=$expectedOrder", async ({ gamesProps, sortBys, expectedOrder }) => { - const DEFAULT_NAMES = ["2", "3", "1"]; const games = (gamesProps as Partial[]).map( - (game, i) => buildMockGame({ name: DEFAULT_NAMES[i], ...game }) + (game, i) => buildMockGame({ name: DEFAULT_GAME_NAMES[i], ...game }) ); render(); @@ -365,5 +366,133 @@ describe(applyFiltersAndSorts.name, () => { expect(actualOrder).toEqual(expectedOrder); } ); + + /** + * Build mock recommendedPlayerCount + * @param [Best, Recommended, NotRecommended, playerCountValue?] + */ + const buildMockRecs = ([ + Best, + Recommended, + NotRecommended, + playerCountValue = 1, + ]: number[]): BoardGame["recommendedPlayerCount"][number] => { + const sum = Best + Recommended + NotRecommended; + + return { + Best, + Recommended, + "Not Recommended": 0 - NotRecommended, + "isPlayerCountWithinRange": true, + playerCountValue, + "playerCountLabel": playerCountValue.toString(), + "BestPercent": Math.round((Best / sum) * 100), + "RecommendedPercent": Math.round((Recommended / sum) * 100), + "NotRecommendedPercent": Math.round((NotRecommended / sum) * 100), + }; + }; + + const GIVEN_PLAYER_RECS: Record[]> = { + "best is best": [ + [1, 2, 1], + [2, 1, 1], + [1, 1, 2], + ].map((recs, i) => + buildMockGame({ + name: DEFAULT_GAME_NAMES[i], + recommendedPlayerCount: [buildMockRecs(recs)], + }) + ), + + /** + * Just because a game has 100% Best votes doesn't mean it should rank to the top. Give bonus points to those with more points. + * + * See up to [v1.1.3 explanation](https://boardgamegeek.com/thread/3028512/article/41805927#41805927) for more reason why. + */ + "bonus points for number of votes": [ + [1300, 400, 40], // Catan (13) + [350, 20, 2], // Galaxy Trucker (31481) + [6, 0, 0], // Orient Express (2363) // TODO: fix so [10,0,0] => 1st when ASC + ].map((recs, i) => + buildMockGame({ + name: DEFAULT_GAME_NAMES[i], + recommendedPlayerCount: [buildMockRecs(recs)], + }) + ), + + "average ranges": [ + [ + [1, 1, 1], + [1, 2, 1], + ], + [ + [1, 1, 1], + [2, 1, 1], + ], + [ + [1, 1, 1], + [1, 1, 2], + ], + ].map((recs, i) => + buildMockGame({ + name: DEFAULT_GAME_NAMES[i], + maxPlayers: recs.length, + recommendedPlayerCount: recs.map((r, j) => + buildMockRecs([...r, j + 1]) + ), + }) + ), + + "party game ranges": [ + [ + [15, 90, 67], + [14, 82, 76], + ], // Deception (156129) + [ + [3, 45, 32], + [11, 45, 26], + ], // Wavelength (262543) + [ + [0, 3, 7], + [3, 4, 3], + ], // Electronic Catch Phrase (135262) + ].map((recs, i) => + buildMockGame({ + name: DEFAULT_GAME_NAMES[i], + maxPlayers: recs.length, + recommendedPlayerCount: recs.map((r, j) => + buildMockRecs([...r, j + 1]) + ), + }) + ), + }; + + test.each` + givenScenario | sortBys | expectedOrder + ${"best is best"} | ${["Player Count Recommendation"]} | ${["3", "2", "1"]} + ${"best is best"} | ${["Player Count Recommendation", "Player Count Recommendation"]} | ${["1", "2", "3"]} + ${"bonus points for number of votes"} | ${["Player Count Recommendation"]} | ${["3", "2", "1"]} + ${"bonus points for number of votes"} | ${["Player Count Recommendation", "Player Count Recommendation"]} | ${["1", "2", "3"]} + ${"average ranges"} | ${["Player Count Recommendation"]} | ${["3", "2", "1"]} + ${"average ranges"} | ${["Player Count Recommendation", "Player Count Recommendation"]} | ${["1", "2", "3"]} + ${"party game ranges"} | ${["Player Count Recommendation"]} | ${["3", "2", "1"]} + ${"party game ranges"} | ${["Player Count Recommendation", "Player Count Recommendation"]} | ${["1", "2", "3"]} + `( + "GIVEN $givenScenario with games ordered 2-3-1, WHEN sortBys=$sortBys, THEN expectedOrder=$expectedOrder", + async ({ givenScenario, sortBys, expectedOrder }) => { + render( + + ); + + const actual = await screen.getByTestId(`actual`)?.innerHTML; + + const actualOrder = JSON.parse(actual).map((g: any) => g.name); + + expect(actualOrder).toEqual(expectedOrder); + } + ); }); });