diff --git a/package.json b/package.json index 6727bb8..771c330 100644 --- a/package.json +++ b/package.json @@ -50,22 +50,31 @@ "typescript": "^5.1.3" }, "jest": { - "preset": "jest-expo", + "projects": [ + { + "preset": "jest-expo/ios", + "setupFilesAfterEnv": [ + "./spec/specHelpers/jest/setupFilesAfterEnv/useReactNativeSpecificJestMatchers.ts", + "./spec/specHelpers/jest/setupFilesAfterEnv/jestCustomMatchers.ts", + "./spec/specHelpers/jest/setupFilesAfterEnv/mockExpoLibraries.ts" + ] + }, + { + "preset": "jest-expo/android", + "setupFilesAfterEnv": [ + "./spec/specHelpers/jest/setupFilesAfterEnv/useReactNativeSpecificJestMatchers.ts", + "./spec/specHelpers/jest/setupFilesAfterEnv/jestCustomMatchers.ts", + "./spec/specHelpers/jest/setupFilesAfterEnv/mockExpoLibraries.ts" + ] + } + ], "transformIgnorePatterns": [ "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)" ], - "setupFilesAfterEnv": [ - "./spec/specHelpers/jest/setupFilesAfterEnv/useReactNativeSpecificJestMatchers.ts", - "./spec/specHelpers/jest/setupFilesAfterEnv/jestCustomMatchers.ts", - "./spec/specHelpers/jest/setupFilesAfterEnv/mockExpoLibraries.ts" - ], "verbose": true, "passWithNoTests": true, "coverageProvider": "v8", - "collectCoverageFrom": [ - "./src/**", - "!src/Config.ts" - ], + "collectCoverageFrom": ["./src/**", "!src/Config.ts", "!src/types/**"], "coverageThreshold": { "global": { "lines": 100, @@ -73,10 +82,7 @@ "branches": 100, "statements": 100 } - }, - "coveragePathIgnorePatterns": [ - "./src/types" - ] + } }, "private": true } diff --git a/spec/app/games.test.tsx b/spec/app/games.test.tsx index 6203aea..7e7edcb 100644 --- a/spec/app/games.test.tsx +++ b/spec/app/games.test.tsx @@ -1,4 +1,5 @@ import * as ERTL from "expo-router/testing-library" +import * as ReactNative from "react-native" import * as DateFNS from "date-fns" import type { Game } from "types/Game" import gameFactory from "../specHelpers/factories/game" @@ -28,7 +29,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).toShowTestID("Loading Spinner") + expect(ERTL.screen).toShowTestId("Loading Spinner") }) }, }) @@ -69,7 +70,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) const gameListItems = ERTL.screen.getAllByTestId("Game List Item") @@ -94,7 +95,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) const gameListItems = ERTL.screen.getAllByTestId("Game List Item") @@ -126,7 +127,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) const gameListItems = ERTL.screen.getAllByTestId("Game List Item") @@ -168,7 +169,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) const gameListItems = ERTL.screen.getAllByTestId("Game List Item") @@ -177,18 +178,26 @@ describe("viewing the games tab", () => { expect(ERTL.within(gameListItems[0])).toShowText("2 - 1 W") expect(ERTL.within(gameListItems[1])).toShowText("0 - 3 L") expect(ERTL.within(gameListItems[2])).toShowText("0 - 0 T") - expect( - ERTL.within(gameListItems[0]).getByText("2 - 1 W").props.style - .color, - ).toEqual("green") - expect( - ERTL.within(gameListItems[1]).getByText("0 - 3 L").props.style - .color, - ).toEqual("red") - expect( - ERTL.within(gameListItems[2]).getByText("0 - 0 T").props.style - .color, - ).toEqual("gray") + + const winLabelColor = ERTL.within(gameListItems[0]).getByText( + "2 - 1 W", + ).props.style.color + const lossLabelColor = ERTL.within(gameListItems[1]).getByText( + "0 - 3 L", + ).props.style.color + const tieLabelColor = ERTL.within(gameListItems[2]).getByText( + "0 - 0 T", + ).props.style.color + + if (ReactNative.Platform.OS === "ios") { + expect(winLabelColor).toEqual({ semantic: ["systemGreen"] }) + expect(lossLabelColor).toEqual({ semantic: ["systemRed"] }) + expect(tieLabelColor).toEqual({ semantic: ["systemGray"] }) + } else { + expect(winLabelColor).toEqual("green") + expect(lossLabelColor).toEqual("red") + expect(tieLabelColor).toEqual("gray") + } }) }, }) @@ -209,7 +218,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) await ERTL.waitFor(() => { @@ -231,7 +240,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) const gameListItems = ERTL.screen.getAllByTestId("Game List Item") @@ -255,7 +264,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) }, }) @@ -270,7 +279,7 @@ describe("viewing the games tab", () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) }, }) @@ -324,7 +333,9 @@ describe("viewing the games tab", () => { test: async () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) }) @@ -336,14 +347,16 @@ describe("viewing the games tab", () => { test: async () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) await mockGamesFromApi({ response: "Network Error", test: async () => { - ERTL.fireEvent.press(ERTL.screen.getByText("Reload")) + ERTL.fireEvent.press(ERTL.screen.getByTestId("Reload Button")) await ERTL.waitFor(() => expect(ERTL.screen).not.toShowText( @@ -360,17 +373,19 @@ describe("viewing the games tab", () => { test: async () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) await mockGamesFromApi({ response: "Network Error", test: async () => { - ERTL.fireEvent.press(ERTL.screen.getByText("Reload")) + ERTL.fireEvent.press(ERTL.screen.getByTestId("Reload Button")) await ERTL.waitFor(() => - expect(ERTL.screen).not.toShowText("Reload"), + expect(ERTL.screen).not.toShowTestId("Reload Button"), ) }, }) @@ -382,17 +397,19 @@ describe("viewing the games tab", () => { test: async () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) await mockGamesFromApi({ response: "Network Error", test: async () => { - ERTL.fireEvent.press(ERTL.screen.getByText("Reload")) + ERTL.fireEvent.press(ERTL.screen.getByTestId("Reload Button")) await ERTL.waitFor(() => - expect(ERTL.screen).toShowTestID("Loading Spinner"), + expect(ERTL.screen).toShowTestId("Loading Spinner"), ) }, }) @@ -405,7 +422,9 @@ describe("viewing the games tab", () => { test: async () => { ERTL.renderRouter("src/app", { initialUrl: "/games" }) - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) @@ -414,7 +433,7 @@ describe("viewing the games tab", () => { await mockGamesFromApi({ response: [game], test: async () => { - ERTL.fireEvent.press(ERTL.screen.getByText("Reload")) + ERTL.fireEvent.press(ERTL.screen.getByTestId("Reload Button")) await ERTL.waitFor(() => { expect(ERTL.screen).toShowText(gameDate(game)) diff --git a/spec/app/index.test.tsx b/spec/app/index.test.tsx index 702e8a9..752ecdd 100644 --- a/spec/app/index.test.tsx +++ b/spec/app/index.test.tsx @@ -20,7 +20,7 @@ describe("opening the app", () => { ERTL.renderRouter("src/app") await ERTL.waitFor(() => { - expect(ERTL.screen).toShowTestID("Loading Spinner") + expect(ERTL.screen).toShowTestId("Loading Spinner") }) }, }) @@ -59,7 +59,7 @@ describe("opening the app", () => { ERTL.renderRouter("src/app") await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) const playerListItems = ERTL.screen.getAllByTestId("Player List Item") @@ -136,7 +136,7 @@ describe("opening the app", () => { ERTL.renderRouter("src/app") await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) }, }) @@ -151,7 +151,7 @@ describe("opening the app", () => { ERTL.renderRouter("src/app") await ERTL.waitFor(() => { - expect(ERTL.screen).not.toShowTestID("Loading Spinner") + expect(ERTL.screen).not.toShowTestId("Loading Spinner") }) }, }) @@ -205,7 +205,9 @@ describe("opening the app", () => { test: async () => { ERTL.renderRouter("src/app") - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) }) @@ -217,14 +219,16 @@ describe("opening the app", () => { test: async () => { ERTL.renderRouter("src/app") - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) await mockPlayersFromApi({ response: "Network Error", test: async () => { - ERTL.fireEvent.press(ERTL.screen.getByText("Reload")) + ERTL.fireEvent.press(ERTL.screen.getByTestId("Reload Button")) await ERTL.waitFor(() => expect(ERTL.screen).not.toShowText( @@ -241,17 +245,19 @@ describe("opening the app", () => { test: async () => { ERTL.renderRouter("src/app") - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) await mockPlayersFromApi({ response: "Network Error", test: async () => { - ERTL.fireEvent.press(ERTL.screen.getByText("Reload")) + ERTL.fireEvent.press(ERTL.screen.getByTestId("Reload Button")) await ERTL.waitFor(() => - expect(ERTL.screen).not.toShowText("Reload"), + expect(ERTL.screen).not.toShowTestId("Reload Button"), ) }, }) @@ -263,17 +269,19 @@ describe("opening the app", () => { test: async () => { ERTL.renderRouter("src/app") - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) await mockPlayersFromApi({ response: "Network Error", test: async () => { - ERTL.fireEvent.press(ERTL.screen.getByText("Reload")) + ERTL.fireEvent.press(ERTL.screen.getByTestId("Reload Button")) await ERTL.waitFor(() => - expect(ERTL.screen).toShowTestID("Loading Spinner"), + expect(ERTL.screen).toShowTestId("Loading Spinner"), ) }, }) @@ -286,7 +294,9 @@ describe("opening the app", () => { test: async () => { ERTL.renderRouter("src/app") - await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Reload")) + await ERTL.waitFor(() => + expect(ERTL.screen).toShowTestId("Reload Button"), + ) }, }) @@ -295,7 +305,7 @@ describe("opening the app", () => { playerFactory({ first_name: "Kelly", last_name: "Kapoor" }), ], test: async () => { - ERTL.fireEvent.press(ERTL.screen.getByText("Reload")) + ERTL.fireEvent.press(ERTL.screen.getByTestId("Reload Button")) await ERTL.waitFor(() => expect(ERTL.screen).toShowText("Kelly Kapoor"), diff --git a/spec/specHelpers/jest/setupFilesAfterEnv/jestCustomMatchers.ts b/spec/specHelpers/jest/setupFilesAfterEnv/jestCustomMatchers.ts index 4e9a778..43129a0 100644 --- a/spec/specHelpers/jest/setupFilesAfterEnv/jestCustomMatchers.ts +++ b/spec/specHelpers/jest/setupFilesAfterEnv/jestCustomMatchers.ts @@ -15,7 +15,7 @@ expect.extend({ : `expected "${text}" to be shown`, } }, - toShowTestID: (component: RenderResult, testID: string) => { + toShowTestId: (component: RenderResult, testID: string) => { const pass = component.queryByTestId(testID) !== null return { pass, diff --git a/src/components/CenteredReloadButton.tsx b/src/components/CenteredReloadButton.tsx index b6cc141..1c01acc 100644 --- a/src/components/CenteredReloadButton.tsx +++ b/src/components/CenteredReloadButton.tsx @@ -8,7 +8,11 @@ const CenteredReloadButton = ({ - + ) diff --git a/src/components/GameListItem.tsx b/src/components/GameListItem.tsx index bb9b07b..0f8654f 100644 --- a/src/components/GameListItem.tsx +++ b/src/components/GameListItem.tsx @@ -1,6 +1,9 @@ import React from "react" import * as ReactNative from "react-native" import * as DateFNS from "date-fns" +import useTheme from "hooks/useTheme" +import useGameOutcome from "hooks/useGameOutcome" +import useGameScoreColor from "hooks/useGameScoreColor" import type { Game } from "types/Game" const sideColumnWidth = 120 @@ -12,45 +15,26 @@ interface GameListItemProps { } const GameListItem = ({ game }: GameListItemProps): React.ReactElement => { - const formattedDate = React.useMemo((): string => { + const dateLabel = React.useMemo((): string => { return DateFNS.format(DateFNS.parseISO(game.played_at), "MMM d") }, [game.played_at]) - const formattedTime = React.useMemo((): string => { + const timeLabel = React.useMemo((): string => { return DateFNS.format(DateFNS.parseISO(game.played_at), "h:mm a") }, [game.played_at]) - const outcome = React.useMemo((): "win" | "loss" | "tie" | "unplayed" => { - if (game.goals_for !== undefined && game.goals_against !== undefined) { - if (game.goals_for > game.goals_against) { - return "win" - } else if (game.goals_for < game.goals_against) { - return "loss" - } - return "tie" - } - return "unplayed" - }, [game.goals_for, game.goals_against]) + const gameOutcome = useGameOutcome(game) + + const gameScoreColor = useGameScoreColor(gameOutcome) - const formattedScore = React.useMemo((): string => { - if (outcome !== "unplayed") { - return `${game.goals_for} - ${game.goals_against} ${outcome.at(0)!.toUpperCase()}` + const scoreLabel = React.useMemo((): string => { + if (gameOutcome !== "Unplayed") { + return `${game.goals_for} - ${game.goals_against} ${gameOutcome.at(0)}` } return "" - }, [game.goals_for, game.goals_against, outcome]) + }, [game.goals_for, game.goals_against, gameOutcome]) - const scoreColor = React.useMemo((): string => { - switch (outcome) { - case "win": - return "green" - case "loss": - return "red" - case "tie": - return "gray" - case "unplayed": - return "transparent" - } - }, [outcome]) + const theme = useTheme() return ( { style={{ flexDirection: "row", width: sideColumnWidth }} > - {formattedTime} + {timeLabel} + + - {formattedDate} + {dateLabel} - + {game.rink} { - {formattedScore} + {scoreLabel} ( - +}: ListItemSeparatorProps): React.ReactElement => { + const theme = useTheme() + + return ( - -) + > + + + ) +} export default ListItemSeparator diff --git a/src/components/PlayerListItem.tsx b/src/components/PlayerListItem.tsx index 2f689f2..f1e47a0 100644 --- a/src/components/PlayerListItem.tsx +++ b/src/components/PlayerListItem.tsx @@ -1,3 +1,4 @@ +import useTheme from "hooks/useTheme" import React from "react" import * as ReactNative from "react-native" import type { Player } from "types/Player" @@ -33,6 +34,8 @@ const PlayerListItem = ({ [player.phone_number], ) + const theme = useTheme() + return ( - + {player.first_name} {" "} @@ -57,14 +60,23 @@ const PlayerListItem = ({ {formattedPhoneNumber && ( {formattedPhoneNumber} )} {player.jersey_number !== undefined && ( - + #{player.jersey_number} )} diff --git a/src/hooks/useGameOutcome.ts b/src/hooks/useGameOutcome.ts new file mode 100644 index 0000000..1766188 --- /dev/null +++ b/src/hooks/useGameOutcome.ts @@ -0,0 +1,21 @@ +import React from "react" +import type { Game } from "types/Game" +import type { GameOutcome } from "types/GameOutcome" + +const useGameOutcome = (game: Game): GameOutcome => { + const gameOutcome = React.useMemo((): GameOutcome => { + if (game.goals_for !== undefined && game.goals_against !== undefined) { + if (game.goals_for > game.goals_against) { + return "Win" + } else if (game.goals_for < game.goals_against) { + return "Loss" + } + return "Tie" + } + return "Unplayed" + }, [game.goals_for, game.goals_against]) + + return gameOutcome +} + +export default useGameOutcome diff --git a/src/hooks/useGameScoreColor.ts b/src/hooks/useGameScoreColor.ts new file mode 100644 index 0000000..149da0e --- /dev/null +++ b/src/hooks/useGameScoreColor.ts @@ -0,0 +1,35 @@ +import React from "react" +import type { GameOutcome } from "types/GameOutcome" +import { color } from "./useTheme" + +const useGameScoreColor = (gameOutcome: GameOutcome): string => { + const nonIosScoreColor = React.useMemo((): string => { + switch (gameOutcome) { + case "Win": + return "green" + case "Loss": + return "red" + case "Tie": + return "gray" + case "Unplayed": + return "transparent" + } + }, [gameOutcome]) + + const iosScoreColor = React.useMemo((): string => { + switch (gameOutcome) { + case "Win": + return "systemGreen" + case "Loss": + return "systemRed" + case "Tie": + return "systemGray" + case "Unplayed": + return "transparent" + } + }, [gameOutcome]) + + return color({ ios: iosScoreColor, other: nonIosScoreColor }) +} + +export default useGameScoreColor diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index ea0619d..5506171 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -1,20 +1,58 @@ +import React from "react" import * as ReactNative from "react-native" import * as ReactNavigationNative from "@react-navigation/native" import type { ColorScheme } from "types/ColorScheme" -const lightTheme = { +export const color = ({ + ios, + other, +}: { + ios: string + other: string +}): string => { + return ReactNative.Platform.OS === "ios" + ? (ReactNative.PlatformColor(ios) as unknown as string) + : other +} + +interface AppTheme extends ReactNavigationNative.Theme { + // https://reactnavigation.org/docs/themes/#basic-usage + dark: boolean + colors: { + primary: string + background: string + card: string + text: string + border: string + notification: string + + // additions + secondaryLabel: string + } +} + +const lightTheme: AppTheme = { ...ReactNavigationNative.DefaultTheme, colors: { ...ReactNavigationNative.DefaultTheme.colors, - background: "#fff", + primary: color({ ios: "systemPurple", other: "purple" }), + secondaryLabel: color({ ios: "secondaryLabel", other: "gray" }), }, } -const darkTheme = ReactNavigationNative.DarkTheme - -const useTheme = (): ReactNavigationNative.Theme => { - const colorScheme: ColorScheme = ReactNative.useColorScheme() +const darkTheme: AppTheme = { + ...ReactNavigationNative.DarkTheme, + colors: { + ...ReactNavigationNative.DarkTheme.colors, + primary: color({ ios: "systemPurple", other: "purple" }), + secondaryLabel: color({ ios: "secondaryLabel", other: "gray" }), + }, +} +// We don't test other color schemes; it doesn't seem worth the effort. +// By default, only light is tested. +//* c8 ignore start */ +const themeFromColorScheme = (colorScheme: ColorScheme): AppTheme => { switch (colorScheme) { case "light": return lightTheme @@ -24,5 +62,17 @@ const useTheme = (): ReactNavigationNative.Theme => { return lightTheme } } +//* c8 ignore end */ + +const useTheme = (): AppTheme => { + const colorScheme: ColorScheme = ReactNative.useColorScheme() + + const theme = React.useMemo( + () => themeFromColorScheme(colorScheme), + [colorScheme], + ) + + return theme +} export default useTheme diff --git a/src/types/GameOutcome.ts b/src/types/GameOutcome.ts new file mode 100644 index 0000000..89bd484 --- /dev/null +++ b/src/types/GameOutcome.ts @@ -0,0 +1 @@ +export type GameOutcome = "Win" | "Loss" | "Tie" | "Unplayed" diff --git a/src/types/jest.d.ts b/src/types/jest.d.ts index a633213..9987624 100644 --- a/src/types/jest.d.ts +++ b/src/types/jest.d.ts @@ -4,8 +4,7 @@ declare namespace jest { interface Matchers<_R> { toShowText(text: string): CustomMatcherResult - toShowTestID(text: string): CustomMatcherResult - toHaveNavigationBarTitle(title: string): CustomMatcherResult + toShowTestId(text: string): CustomMatcherResult } }