diff --git a/app.json b/app.json index 46d6b54..476dcb4 100644 --- a/app.json +++ b/app.json @@ -9,7 +9,8 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.pokerchipshelper" }, "android": { "adaptiveIcon": { diff --git a/components/BuyInSelector.tsx b/components/BuyInSelector.tsx index d9356a1..93eae83 100644 --- a/components/BuyInSelector.tsx +++ b/components/BuyInSelector.tsx @@ -10,6 +10,14 @@ interface BuyInSelectorProps { } const defaultBuyInOptions = [10, 25, 50]; +const MIN = 1; +const MAX = 200; + +const parseRoundClamp = (num: string): number => { + const parsed = parseFloat(num); + const rounded = Math.round(parsed); + return Math.min(Math.max(rounded, MIN), MAX); +}; const BuyInSelector: React.FC = ({ setBuyInAmount, @@ -19,9 +27,9 @@ const BuyInSelector: React.FC = ({ const [buyInAmount, setBuyInAmountState] = useState(null); const handleCustomAmountChange = (value: string) => { - const numericValue = parseFloat(value); + const numericValue = parseRoundClamp(value); if (!isNaN(numericValue) && numericValue >= 0) { - setCustomAmount(value); + setCustomAmount(numericValue.toString()); setBuyInAmountState(numericValue); setBuyInAmount(numericValue); } else { @@ -50,18 +58,17 @@ const BuyInSelector: React.FC = ({ ))} - {i18n.t("custom_buy_in")} - - {`${i18n.t("selected_buy_in")}: `} + {`${i18n.t("selected_buy_in")} `} {buyInAmount !== null ? `${selectedCurrency} ${buyInAmount}` : i18n.t("none")} diff --git a/components/ChipDistributionSummary.tsx b/components/ChipDistributionSummary.tsx index 01a88d4..c2a4a38 100644 --- a/components/ChipDistributionSummary.tsx +++ b/components/ChipDistributionSummary.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { View, Text, StyleSheet } from "react-native"; +import { View, Text, Alert } from "react-native"; import { ColorValue } from "react-native"; import i18n from "@/i18n/i18n"; -import styles from "@/styles/styles"; +import styles, { COLORS } from "@/styles/styles"; +import { MaterialIcons } from "@expo/vector-icons"; interface ChipDistributionSummaryProps { playerCount: number; @@ -12,6 +13,8 @@ interface ChipDistributionSummaryProps { selectedCurrency: string; } +const reverseFib: number[] = [8, 5, 3, 2, 1]; + const ChipDistributionSummary = ({ playerCount, buyInAmount, @@ -20,11 +23,15 @@ const ChipDistributionSummary = ({ selectedCurrency = "$", }: ChipDistributionSummaryProps) => { const validDenominations: validDenomination[] = [ - 0.05, 0.1, 0.25, 0.5, 1, 2, 2.5, 5, 10, 20, 50, 100, + 0.05, 0.1, 0.25, 1, 5, 10, 20, 50, 100, ]; const [denominations, setDenominations] = useState([]); const [distributions, setDistributions] = useState([]); + const showAlert = () => { + Alert.alert(i18n.t("warning"), i18n.t("chip_value_warn")); + }; + type validDenomination = | 0.05 | 0.1 @@ -36,120 +43,112 @@ const ChipDistributionSummary = ({ | 5 | 10 | 20 + | 25 | 50 | 100; const findFloorDenomination = (target: number): validDenomination => { let current: validDenomination = validDenominations[0]; - validDenominations.forEach((value, index) => { + validDenominations.forEach((value, _) => { if (value < target) current = value; }); return current; }; - const maxDenomination = useMemo(() => { - if (totalChipsCount.length > 3) { - return findFloorDenomination(buyInAmount / 3); - } else { - return findFloorDenomination(buyInAmount / 4); + const round = useCallback((num: number) => Math.round(num * 100) / 100, []); + + // Bound for the value of the highest chip + // This is somewhat arbitray and imperfect, but 1/3 to 1/5 is reasonable depending on the number of colors. + // Could be possibly improved based on value of buy in amount + const maxDenomination: validDenomination = useMemo(() => { + let max: validDenomination; + switch (totalChipsCount.length) { + case 5: + case 4: + max = findFloorDenomination(buyInAmount / 3); + break; + case 3: + max = findFloorDenomination(buyInAmount / 4); + break; + case 2: + case 1: + default: + max = findFloorDenomination(buyInAmount / 5); + break; } - }, [totalChipsCount]); + return max; + }, [totalChipsCount, buyInAmount]); const potValue = useMemo( () => buyInAmount * playerCount, [buyInAmount, playerCount] ); + // The total value of all chips distributed to a single player. Ideally should be equal to buyInAmount const totalValue = useMemo(() => { let value = 0; - for (let i = 0; i < totalChipsCount.length; i++) { + for (let i = 0; i < distributions.length; i++) { value += distributions[i] * denominations[i]; } return value; }, [distributions, denominations]); + // Maximum quantity of each chip color which may be distributed to a single player before running out const maxPossibleDistribution = useMemo( () => totalChipsCount.map((v) => Math.floor(v / playerCount)), [totalChipsCount, playerCount] ); - const redenominate = useCallback( - ( - invalidDenomination: validDenomination[], - shuffleIndex: number - ): validDenomination[] => { - let moved = false; - const newDenomination: validDenomination[] = []; - for (let i = invalidDenomination.length - 1; i >= 0; i--) { - if (i > shuffleIndex) { - newDenomination.push(invalidDenomination[i]); - } else if (i == shuffleIndex) { - newDenomination.push(invalidDenomination[i]); - } else if (i < shuffleIndex && !moved) { - const nextLowestDenominationIndex = Math.max( - validDenominations.indexOf(invalidDenomination[i]) - 1, - 0 - ); - newDenomination.push(validDenominations[nextLowestDenominationIndex]); - moved = true; - } else { - newDenomination.push(invalidDenomination[i]); - } - } - newDenomination.reverse(); - return newDenomination; - }, - [] - ); - + // Dynamically set denominations and distributions from changing inputs useEffect(() => { let testDenomination: validDenomination[] = []; - const numColors = totalChipsCount.length; - const testDistribution: number[] = []; - for (let i = 0; i < numColors; ++i) { - testDistribution.push(0); - } + const totalNumColors = totalChipsCount.length; + // Start with max denominations, then push on the next adjacent lower denomination testDenomination.push(maxDenomination); let currentDenominationIndex: number = validDenominations.indexOf(maxDenomination); - for (let i = numColors - 2; i >= 0; i = i - 1) { + for ( + let i = totalNumColors - 2; + i >= 0 && currentDenominationIndex > 0; + i = i - 1 + ) { currentDenominationIndex -= 1; const currentDemoniation = validDenominations[currentDenominationIndex]; testDenomination.push(currentDemoniation); } testDenomination.reverse(); + let numColors = testDenomination.length; - let remainingValue = buyInAmount; - let fail = true; - let failCount = 0; - while (fail && failCount < 1) { - let stop = false; - while (remainingValue > 0 && !stop) { - let distributed = false; - for (let i = numColors - 1; i >= 0; i = i - 1) { - if (testDistribution[i] < maxPossibleDistribution[i]) { - if (remainingValue >= testDenomination[i]) { - testDistribution[i] = testDistribution[i] + 1; - remainingValue = remainingValue - testDenomination[i]; - distributed = true; - } - } - } - if (distributed == false) { - stop = true; - } - } - if (remainingValue !== 0) { - const redenominateIndex = failCount % numColors; - testDenomination = redenominate(testDenomination, redenominateIndex); - failCount += 1; - fail = true; - } else { - fail = false; - } + const testDistribution: number[] = []; + for (let i = 0; i < numColors; ++i) { + testDistribution.push(0); } + // Distribute the chips using the test denomination with a reverse fibbonaci preference + // Not optimal, nor correct under all inputs but works for most inputs + // Algorithm could be improved with more complexity and optimization (re-tries, redenominating, etc.) + let remainingValue = buyInAmount; + let stop = false; + while (remainingValue > 0 && !stop) { + let distributed = false; + for (let i = numColors - 1; i >= 0; i = i - 1) { + for ( + let j = reverseFib[i]; + j > 0 && + remainingValue >= testDenomination[i] && + testDistribution[i] < maxPossibleDistribution[i]; + j = j - 1 + ) { + testDistribution[i] = testDistribution[i] + 1; + remainingValue = round(remainingValue - testDenomination[i]); + distributed = true; + } + } + if (distributed == false) { + stop = true; + } + } setDenominations(testDenomination); setDistributions(testDistribution); }, [totalChipsCount, maxDenomination, buyInAmount, playerCount]); @@ -157,24 +156,39 @@ const ChipDistributionSummary = ({ return ( <> - {totalChipsCount.map((_, index) => ( - - - {`${distributions[index]} ${i18n.t("chips")}: ${selectedCurrency}${denominations[index]} ${i18n.t("each")}`} - - - ))} + {distributions.map((distribution, index) => { + return ( + distribution > 0 && ( + + + {`${distribution} ${i18n.t("chips")}: ${selectedCurrency}${denominations[index]} ${i18n.t("each")}`} + + + ) + ); + })} - - {i18n.t("total_value")}: {selectedCurrency} {totalValue} - + + + {i18n.t("total_value")}: {selectedCurrency} {round(totalValue)}{" "} + + {round(totalValue) !== buyInAmount && ( + + )} + {selectedCurrency} {potValue} {i18n.t("pot")} diff --git a/components/ChipsSelector.tsx b/components/ChipsSelector.tsx index 4344e93..b77d54b 100644 --- a/components/ChipsSelector.tsx +++ b/components/ChipsSelector.tsx @@ -14,6 +14,7 @@ import styles from "@/styles/styles"; import i18n from "@/i18n/i18n"; const colors: ColorValue[] = ["white", "red", "green", "blue", "black"]; +const defaults = [100, 50, 50, 50, 50]; const ChipInputModal = ({ showModal, @@ -152,7 +153,7 @@ const ChipsSelector = ({ } else if (numberOfChips > totalChipsCount.length) { for (let colorIndex = 0; colorIndex < numberOfChips; ++colorIndex) { if (colorIndex >= newTotalChipsCount.length) { - const defaultTotal = 100 - colorIndex * 20; + const defaultTotal = defaults[colorIndex]; newTotalChipsCount.push(defaultTotal); } } diff --git a/components/__tests__/BuyInSelector.test.tsx b/components/__tests__/BuyInSelector.test.tsx index ce29dbf..f9a8872 100644 --- a/components/__tests__/BuyInSelector.test.tsx +++ b/components/__tests__/BuyInSelector.test.tsx @@ -39,7 +39,9 @@ describe("BuyInSelector Component", () => { expect(getByText("$ 10")).toBeTruthy(); expect(getByText("$ 25")).toBeTruthy(); expect(getByText("$ 50")).toBeTruthy(); - expect(getByPlaceholderText("Enter custom buy-in")).toBeTruthy(); + expect( + getByPlaceholderText("Or, enter a custom amount: 1 - 200") + ).toBeTruthy(); expect(queryByText(/Selected Buy-in:.*None/i)).toBeTruthy(); }); @@ -53,24 +55,36 @@ describe("BuyInSelector Component", () => { it("sets a custom buy-in amount correctly", () => { const { getByPlaceholderText } = renderComponent(); - fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "100"); + fireEvent.changeText( + getByPlaceholderText("Or, enter a custom amount: 1 - 200"), + "100" + ); expect(setBuyInAmount).toHaveBeenCalledWith(100); }); - it("resets custom amount if invalid input is entered", () => { + it("bound and validate custom amount if invalid input is entered", () => { const { getByPlaceholderText } = renderComponent(); - fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "-10"); - expect(setBuyInAmount).toHaveBeenCalledWith(25); // Default reset + fireEvent.changeText( + getByPlaceholderText("Or, enter a custom amount: 1 - 200"), + "-10" + ); + expect(setBuyInAmount).toHaveBeenCalledWith(1); // Min value - fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "abc"); - expect(setBuyInAmount).toHaveBeenCalledWith(25); + fireEvent.changeText( + getByPlaceholderText("Or, enter a custom amount: 1 - 200"), + "abc" + ); + expect(setBuyInAmount).toHaveBeenCalledWith(1); }); it("clears the custom amount when selecting a predefined option", () => { const { getByPlaceholderText, getByText } = renderComponent(); - fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "100"); + fireEvent.changeText( + getByPlaceholderText("Or, enter a custom amount: 1 - 200"), + "100" + ); fireEvent.press(getByText("$ 50")); expect(setBuyInAmount).toHaveBeenCalledWith(50); }); @@ -78,13 +92,22 @@ describe("BuyInSelector Component", () => { it("handles valid and invalid input for custom amount correctly", () => { const { getByPlaceholderText } = renderComponent(); - fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "75"); + fireEvent.changeText( + getByPlaceholderText("Or, enter a custom amount: 1 - 200"), + "75" + ); expect(setBuyInAmount).toHaveBeenCalledWith(75); - fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "-5"); - expect(setBuyInAmount).toHaveBeenCalledWith(25); + fireEvent.changeText( + getByPlaceholderText("Or, enter a custom amount: 1 - 200"), + "-5" + ); + expect(setBuyInAmount).toHaveBeenCalledWith(1); - fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "abc"); + fireEvent.changeText( + getByPlaceholderText("Or, enter a custom amount: 1 - 200"), + "abc" + ); expect(setBuyInAmount).toHaveBeenCalledWith(25); }); @@ -98,7 +121,7 @@ describe("BuyInSelector Component", () => { it("resets to default buy-in when custom input is cleared", () => { const { getByPlaceholderText } = renderComponent(); - const input = getByPlaceholderText("Enter custom buy-in"); + const input = getByPlaceholderText("Or, enter a custom amount: 1 - 200"); fireEvent.changeText(input, "75"); expect(setBuyInAmount).toHaveBeenCalledWith(75); @@ -110,7 +133,10 @@ describe("BuyInSelector Component", () => { it("updates state correctly when selecting predefined buy-in after entering a custom amount", () => { const { getByPlaceholderText, getByText } = renderComponent(); - fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "200"); + fireEvent.changeText( + getByPlaceholderText("Or, enter a custom amount: 1 - 200"), + "200" + ); expect(setBuyInAmount).toHaveBeenCalledWith(200); fireEvent.press(getByText("$ 10")); diff --git a/components/__tests__/ChipDistributionSummary.test.tsx b/components/__tests__/ChipDistributionSummary.test.tsx index ef66610..f0ce2cb 100644 --- a/components/__tests__/ChipDistributionSummary.test.tsx +++ b/components/__tests__/ChipDistributionSummary.test.tsx @@ -2,13 +2,20 @@ import React from "react"; import { render } from "@testing-library/react-native"; import ChipDistributionSummary from "../ChipDistributionSummary"; +jest.mock("@expo/vector-icons", () => { + const { Text } = require("react-native"); + return { + MaterialIcons: () => TestIcon, + }; +}); + describe("ChipDistributionSummary Component", () => { test("renders correctly with valid data", () => { const playerCount = 4; const totalChipsCount = [100, 80, 60, 40, 20]; const buyInAmount = 20; - const expectedDistribution = [2, 2, 1, 2, 2]; - const expectedDenominations = [0.5, 1, 2, 2.5, 5]; + const expectedDistribution = [16, 12, 8, 6, 2]; + const expectedDenominations = [0.05, 0.1, 0.25, 1, 5]; const { getByText } = render(