}) {
- const [email, setEmail] = useState("");
- const [isLoggingIn, setIsLoggingIn] = useState(false);
- const [error, setError] = useState("");
+ const [email, setEmail] = useState('')
+ const [isLoggingIn, setIsLoggingIn] = useState(false)
+ const [error, setError] = useState('')
async function logIn(event: FormEvent) {
- event.preventDefault();
- if (isLoggingIn) return;
- setIsLoggingIn(true);
- setError("");
+ event.preventDefault()
+ if (isLoggingIn) return
+ setIsLoggingIn(true)
+ setError('')
try {
- await devLogin(email);
- await onLoggedIn();
+ await devLogin(email)
+ await onLoggedIn()
} catch (problem) {
- setError(describeError(problem).message);
+ setError(describeError(problem).message)
} finally {
- setIsLoggingIn(false);
+ setIsLoggingIn(false)
}
}
@@ -58,11 +58,11 @@ export default function DevLoginButton({
required
value={email}
onChange={(event) => {
- setEmail(event.target.value);
- setError("");
+ setEmail(event.target.value)
+ setError('')
}}
aria-invalid={Boolean(error)}
- aria-describedby={error ? "dev-login-error" : undefined}
+ aria-describedby={error ? 'dev-login-error' : undefined}
className="h-11 w-full rounded-md border border-(--field-border) bg-(--field-bg) px-3 text-(--text-color) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)"
/>
{error && (
)}
- );
+ )
}
diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx
index 20c5f8f..86c95da 100644
--- a/src/components/gymnasts/GymnastList.tsx
+++ b/src/components/gymnasts/GymnastList.tsx
@@ -1,20 +1,17 @@
-import GymnastRow from "@/components/gymnasts/GymnastRow";
-import type { GymnastSummary } from "@/lib/gymnasts";
+import GymnastRow from '@/components/gymnasts/GymnastRow'
+import type { GymnastSummary } from '@/lib/gymnasts'
export default function GymnastList({
gymnasts,
onDeleted,
onFailed,
}: {
- gymnasts: GymnastSummary[];
- onDeleted: (name: string) => void;
- onFailed: (message: string) => void;
+ gymnasts: GymnastSummary[]
+ onDeleted: (name: string) => void
+ onFailed: (message: string) => void
}) {
return (
-
+
Gymnast list
@@ -35,5 +32,5 @@ export default function GymnastList({
)}
- );
-}
\ No newline at end of file
+ )
+}
diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx
index ffe806e..da1a20e 100644
--- a/src/components/navbar/Navbar.tsx
+++ b/src/components/navbar/Navbar.tsx
@@ -1,41 +1,41 @@
-import React, { useState } from 'react';
-import { Menu, X } from 'lucide-react';
-import { Link, NavLink, useNavigate } from 'react-router-dom';
-import { useAuth } from '@/components/auth/useAuth';
-import { logout } from '@/lib/apollo/client';
+import React, { useState } from 'react'
+import { Menu, X } from 'lucide-react'
+import { Link, NavLink, useNavigate } from 'react-router-dom'
+import { useAuth } from '@/components/auth/useAuth'
+import { logout } from '@/lib/apollo/client'
interface NavbarProps {
- isDarkMode: boolean;
- onToggleDarkMode: () => void;
+ isDarkMode: boolean
+ onToggleDarkMode: () => void
}
export const Navbar: React.FC = ({
isDarkMode,
onToggleDarkMode,
}) => {
- const [menuOpen, setMenuOpen] = useState(false);
+ const [menuOpen, setMenuOpen] = useState(false)
// The same answer as the pages read: who is logged in, as the server says it.
- const { status } = useAuth();
- const isLoggedIn = status === 'authenticated';
- const navigate = useNavigate();
+ const { status } = useAuth()
+ const isLoggedIn = status === 'authenticated'
+ const navigate = useNavigate()
async function logOut() {
- setMenuOpen(false);
+ setMenuOpen(false)
// Leave a page that is for coaches first, so the visitor is not sent to the
// login page by the page they are leaving.
- void navigate('/elements');
- await logout();
+ void navigate('/elements')
+ await logout()
}
const focusRing =
- 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4';
+ 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4'
const authButton =
- 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color';
+ 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color'
const navLinks = [
{ path: '/elements', label: 'Search' },
{ path: '/calculator', label: 'Calculator' },
{ path: '/about', label: 'About' },
...(isLoggedIn ? [{ path: '/gymnasts', label: 'Gymnasts' }] : []),
- ];
+ ]
return (
@@ -84,9 +84,7 @@ export const Navbar: React.FC = ({
aria-hidden="true"
className="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-primary-color text-xs transition-transform duration-200 ease-in-out"
style={{
- transform: isDarkMode
- ? 'translateX(22px)'
- : 'translateX(0)',
+ transform: isDarkMode ? 'translateX(22px)' : 'translateX(0)',
}}
>
{isDarkMode ? '🌙' : '☀️'}
@@ -104,7 +102,10 @@ export const Navbar: React.FC = ({
Log out
) : (
-
+
Log in
))}
@@ -125,5 +126,5 @@ export const Navbar: React.FC = ({
- );
-};
\ No newline at end of file
+ )
+}
diff --git a/src/components/skipLink/SkipLink.tsx b/src/components/skipLink/SkipLink.tsx
index 9fb8807..89eebb5 100644
--- a/src/components/skipLink/SkipLink.tsx
+++ b/src/components/skipLink/SkipLink.tsx
@@ -1,5 +1,5 @@
interface SkipLinkProps {
- mainId?: string;
+ mainId?: string
}
export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => {
@@ -10,5 +10,5 @@ export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => {
>
Skip to main content
- );
-};
\ No newline at end of file
+ )
+}
diff --git a/src/lib/devLogin.ts b/src/lib/devLogin.ts
index 08df1a1..e6ad724 100644
--- a/src/lib/devLogin.ts
+++ b/src/lib/devLogin.ts
@@ -3,4 +3,4 @@
* build has no development login (see "Login for development" in docs/API.md).
*/
export const isDevLoginEnabled = (): boolean =>
- import.meta.env.VITE_DEV_LOGIN === "true";
+ import.meta.env.VITE_DEV_LOGIN === 'true'
diff --git a/src/lib/gymnasts.ts b/src/lib/gymnasts.ts
index 91072b2..aa625c6 100644
--- a/src/lib/gymnasts.ts
+++ b/src/lib/gymnasts.ts
@@ -3,20 +3,20 @@ import {
nameProblem,
normalizeName,
type NameProblem,
-} from "../../shared/names";
+} from '../../shared/names'
export type GymnastSummary = {
- id: string;
- name: string;
-};
+ id: string
+ name: string
+}
export function getGymnastInitials(name: string): string {
- const words = name.trim().split(/\s+/).filter(Boolean);
+ const words = name.trim().split(/\s+/).filter(Boolean)
- if (words.length === 0) return "?";
- if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
+ if (words.length === 0) return '?'
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
- return `${words[0][0]}${words.at(-1)?.[0] ?? ""}`.toUpperCase();
+ return `${words[0][0]}${words.at(-1)?.[0] ?? ''}`.toUpperCase()
}
/**
@@ -25,10 +25,10 @@ export function getGymnastInitials(name: string): string {
* emoji is two. Twice the limit never stops a valid name; the exact check is
* getGymnastNameError.
*/
-export const GYMNAST_NAME_INPUT_MAX_LENGTH = MAX_NAME_LENGTH * 2;
+export const GYMNAST_NAME_INPUT_MAX_LENGTH = MAX_NAME_LENGTH * 2
/** The most characters the form lets a gymnast name have. It is the rule of the server. */
-export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH;
+export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH
/**
* What is typed or pasted in the name field, cut to the most characters a name
@@ -36,25 +36,25 @@ export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH;
* (code points) like the server does, so an emoji is one and 70 of them fit.
*/
export function limitGymnastNameInput(value: string): string {
- const characters = [...value];
+ const characters = [...value]
return characters.length > MAX_GYMNAST_NAME_LENGTH
- ? characters.slice(0, MAX_GYMNAST_NAME_LENGTH).join("")
- : value;
+ ? characters.slice(0, MAX_GYMNAST_NAME_LENGTH).join('')
+ : value
}
/** The name as the server stores it (the rule is in shared/names.ts). */
export function normalizeGymnastName(name: string): string {
- return normalizeName(name);
+ return normalizeName(name)
}
const NAME_MESSAGES: Record = {
- empty: "Name is required",
- "too-long": `Names must be ${MAX_NAME_LENGTH} characters or fewer.`,
-};
+ empty: 'Name is required',
+ 'too-long': `Names must be ${MAX_NAME_LENGTH} characters or fewer.`,
+}
export function getGymnastNameError(name: string): string | undefined {
- const problem = nameProblem(name);
+ const problem = nameProblem(name)
- return problem ? NAME_MESSAGES[problem] : undefined;
+ return problem ? NAME_MESSAGES[problem] : undefined
}
diff --git a/src/pages/YourGymnastsPage.tsx b/src/pages/YourGymnastsPage.tsx
index c84213c..8f9168a 100644
--- a/src/pages/YourGymnastsPage.tsx
+++ b/src/pages/YourGymnastsPage.tsx
@@ -1,102 +1,102 @@
-import { useEffect, useRef, useState } from "react";
-import { useMutation, useQuery } from "@apollo/client/react";
-import { Link } from "react-router-dom";
-import { Check, Plus, X } from "lucide-react";
-import { useAuth } from "@/components/auth/useAuth";
-import PageTitle from "@/components/PageTitle";
-import { Button } from "@/components/ui/button";
-import { PILL } from "@/components/ui/pill";
-import GymnastList from "@/components/gymnasts/GymnastList";
-import { describeError } from "@/lib/apollo/errors";
+import { useEffect, useRef, useState } from 'react'
+import { useMutation, useQuery } from '@apollo/client/react'
+import { Link } from 'react-router-dom'
+import { Check, Plus, X } from 'lucide-react'
+import { useAuth } from '@/components/auth/useAuth'
+import PageTitle from '@/components/PageTitle'
+import { Button } from '@/components/ui/button'
+import { PILL } from '@/components/ui/pill'
+import GymnastList from '@/components/gymnasts/GymnastList'
+import { describeError } from '@/lib/apollo/errors'
import {
CREATE_GYMNAST_MUTATION,
GYMNASTS_QUERY,
-} from "@/lib/gymnastOperations";
+} from '@/lib/gymnastOperations'
import {
GYMNAST_NAME_INPUT_MAX_LENGTH,
getGymnastNameError,
limitGymnastNameInput,
normalizeGymnastName,
-} from "@/lib/gymnasts";
+} from '@/lib/gymnasts'
export default function YourGymnastsPage() {
- const { data, error: loadError, loading, refetch } = useQuery(GYMNASTS_QUERY);
+ const { data, error: loadError, loading, refetch } = useQuery(GYMNASTS_QUERY)
// The list is fetched again after a new gymnast: the API sorts the gymnasts
// by name, so the cache cannot know where the new one belongs.
const [createGymnast, { loading: isSaving }] = useMutation(
CREATE_GYMNAST_MUTATION,
{ refetchQueries: [GYMNASTS_QUERY] },
- );
- const [isFormOpen, setIsFormOpen] = useState(false);
- const [name, setName] = useState("");
- const [error, setError] = useState("");
- const [announcement, setAnnouncement] = useState("");
- const nameInput = useRef(null);
- const addButton = useRef(null);
- const wasFormOpen = useRef(false);
+ )
+ const [isFormOpen, setIsFormOpen] = useState(false)
+ const [name, setName] = useState('')
+ const [error, setError] = useState('')
+ const [announcement, setAnnouncement] = useState('')
+ const nameInput = useRef(null)
+ const addButton = useRef(null)
+ const wasFormOpen = useRef(false)
// A second submit before the first has an answer (Enter twice) must not
// create a second gymnast. The state of the mutation changes one render too late.
- const isSubmitting = useRef(false);
+ const isSubmitting = useRef(false)
// The same answer as the menu reads. Nobody logged in, or a session that has
// ended, is not the same as a coach with no gymnasts, so it must not look like
// an empty list. (A visitor who is not logged in does not get here from the
// menu: ProtectedRoute sends them to the login page. This is for a session
// that ends while the page is open.)
- const isLoggedOut = useAuth().status === "anonymous";
- const [deleteError, setDeleteError] = useState("");
- const loadProblem = loadError ? describeError(loadError) : null;
+ const isLoggedOut = useAuth().status === 'anonymous'
+ const [deleteError, setDeleteError] = useState('')
+ const loadProblem = loadError ? describeError(loadError) : null
useEffect(() => {
if (isFormOpen) {
- nameInput.current?.focus();
- wasFormOpen.current = true;
+ nameInput.current?.focus()
+ wasFormOpen.current = true
} else if (wasFormOpen.current) {
- addButton.current?.focus();
- wasFormOpen.current = false;
+ addButton.current?.focus()
+ wasFormOpen.current = false
}
- }, [isFormOpen]);
+ }, [isFormOpen])
// The button of the deleted gymnast is gone, so the focus goes to the button
// that adds one, and the visitor is told what happened.
function onDeleted(deletedName: string) {
- setDeleteError("");
- setAnnouncement(`${deletedName} deleted.`);
- addButton.current?.focus();
+ setDeleteError('')
+ setAnnouncement(`${deletedName} deleted.`)
+ addButton.current?.focus()
}
function closeForm() {
- setIsFormOpen(false);
- setName("");
- setError("");
+ setIsFormOpen(false)
+ setName('')
+ setError('')
}
async function addGymnast(event: React.FormEvent) {
- event.preventDefault();
- if (isSubmitting.current) return;
- const normalizedName = normalizeGymnastName(name);
- const nameError = getGymnastNameError(name);
+ event.preventDefault()
+ if (isSubmitting.current) return
+ const normalizedName = normalizeGymnastName(name)
+ const nameError = getGymnastNameError(name)
if (nameError) {
- setError(nameError);
- nameInput.current?.focus();
- return;
+ setError(nameError)
+ nameInput.current?.focus()
+ return
}
- isSubmitting.current = true;
+ isSubmitting.current = true
try {
const result = await createGymnast({
variables: { name: normalizedName },
- });
- const addedName = result.data?.createGymnast.name ?? normalizedName;
- setAnnouncement(`${addedName} added to your gymnasts.`);
- closeForm();
+ })
+ const addedName = result.data?.createGymnast.name ?? normalizedName
+ setAnnouncement(`${addedName} added to your gymnasts.`)
+ closeForm()
} catch (problem) {
// The form stays open with what the user wrote, so it can be corrected.
- setError(describeError(problem).message);
- nameInput.current?.focus();
+ setError(describeError(problem).message)
+ nameInput.current?.focus()
} finally {
- isSubmitting.current = false;
+ isSubmitting.current = false
}
}
@@ -115,9 +115,9 @@ export default function YourGymnastsPage() {
type="button"
className={PILL}
onClick={() => {
- setError("");
- setAnnouncement("");
- setIsFormOpen(true);
+ setError('')
+ setAnnouncement('')
+ setIsFormOpen(true)
}}
>
@@ -136,7 +136,7 @@ export default function YourGymnastsPage() {
className="mt-8 rounded-lg border border-(--box-stroke) bg-(--surface) p-5 text-left sm:p-6"
onSubmit={addGymnast}
onKeyDown={(event) => {
- if (event.key === "Escape") closeForm();
+ if (event.key === 'Escape') closeForm()
}}
noValidate
>
@@ -158,11 +158,11 @@ export default function YourGymnastsPage() {
value={name}
onChange={(event) => {
// Not possible to type or paste more than 70 characters.
- setName(limitGymnastNameInput(event.target.value));
- setError("");
+ setName(limitGymnastNameInput(event.target.value))
+ setError('')
}}
aria-invalid={Boolean(error)}
- aria-describedby={error ? "gymnast-name-error" : undefined}
+ aria-describedby={error ? 'gymnast-name-error' : undefined}
className="h-12 w-full rounded-md border border-(--field-border) bg-(--field-bg) px-3 text-left text-(--text-color) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)"
/>
{error && (
@@ -179,7 +179,7 @@ export default function YourGymnastsPage() {
}
+ render={
+
+ }
>
Log in
@@ -257,5 +259,5 @@ export default function YourGymnastsPage() {
)}
- );
+ )
}
diff --git a/src/styles/global.css b/src/styles/global.css
index 319c665..7feca8b 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -22,7 +22,11 @@
/* The text in the circle of the difficulty badge has the color of the text in
"More variants" (--pill-text), in light and dark mode, so they match. Only the
color changes; the badge itself is not changed. */
-#root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span:first-child {
+#root
+ .elements-page
+ :is(td, dd)
+ .inline-flex.shrink-0.flex-col
+ > span:first-child {
color: var(--pill-text);
}
@@ -30,13 +34,24 @@
be read on the dark table. Only that text changes, and only in dark mode (the
circle and its letter keep their colors, and the badge is not changed). The
selector has shrink-0, which is on the difficulty badge. */
-[data-theme='dark'] #root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span + span {
+[data-theme='dark']
+ #root
+ .elements-page
+ :is(td, dd)
+ .inline-flex.shrink-0.flex-col
+ > span
+ + span {
color: var(--text-color);
}
/* The circle of the badge gets the light color of "More variants" in dark mode,
with dark text, so it stands out from the dark rows. */
-[data-theme='dark'] #root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span:first-child {
+[data-theme='dark']
+ #root
+ .elements-page
+ :is(td, dd)
+ .inline-flex.shrink-0.flex-col
+ > span:first-child {
background-color: var(--pill-bg);
color: var(--pill-text);
}
@@ -52,8 +67,10 @@
margin-inline-end: -0.5rem;
cursor: pointer;
background-color: var(--text-color);
- -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") center / 1rem 1rem no-repeat;
- mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") center / 1rem 1rem no-repeat;
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
+ center / 1rem 1rem no-repeat;
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
+ center / 1rem 1rem no-repeat;
}
#root .elements-page h1 {
@@ -67,7 +84,7 @@
}
#root .your-gymnasts-page h2 {
- font-size: clamp(1.375rem, 4vw, 1.75rem);
- line-height: 1.2;
- margin: 0;
+ font-size: clamp(1.375rem, 4vw, 1.75rem);
+ line-height: 1.2;
+ margin: 0;
}
diff --git a/src/styles/variables.css b/src/styles/variables.css
index aaf48c4..56f2df2 100644
--- a/src/styles/variables.css
+++ b/src/styles/variables.css
@@ -1,149 +1,153 @@
:root {
- /* Color palette */
- --primary-color: #F0EDE7;
- --secondary-color: #E0DFE4;
- --accent-color: #C0D2F6;
- --background-color: #ecf0f1;
- --background-color-element: #F8F7F3;
- --border-color: #EAE0CF;
+ /* Color palette */
+ --primary-color: #f0ede7;
+ --secondary-color: #e0dfe4;
+ --accent-color: #c0d2f6;
+ --background-color: #ecf0f1;
+ --background-color-element: #f8f7f3;
+ --border-color: #eae0cf;
- /* Text colors */
- --text-color: #101844;
- --text-color-dark: #090F2F;
- --text-color-light: #EAE0CF;
+ /* Text colors */
+ --text-color: #101844;
+ --text-color-dark: #090f2f;
+ --text-color-light: #eae0cf;
- /* Font settings */
- --font-family-title: 'Faustina', serif;
- --font-family-text: 'Inter';
- --font-size-page-title: 42px;
- --font-size-element-title: 19px;
- --font-size-row-title: 15px;
- --font-size-subtext: 13px;
+ /* Font settings */
+ --font-family-title: 'Faustina', serif;
+ --font-family-text: 'Inter';
+ --font-size-page-title: 42px;
+ --font-size-element-title: 19px;
+ --font-size-row-title: 15px;
+ --font-size-subtext: 13px;
- /* Box settings */
- --corner-radius: 100px;
- --box-color: #F0EDE7;
- --box-stroke: #888394;
- --box-shadow: 0 2px 5px rgba(3, 2, 2, 0.1);
+ /* Box settings */
+ --corner-radius: 100px;
+ --box-color: #f0ede7;
+ --box-stroke: #888394;
+ --box-shadow: 0 2px 5px rgba(3, 2, 2, 0.1);
- /* The Elements page. Each color has a light and a dark value (the dark
+ /* The Elements page. Each color has a light and a dark value (the dark
ones are in the dark block below), so the page follows the theme. */
- --page-bg: var(--secondary-color);
- --surface: var(--background-color-element);
- /* The rows have no color change on hover in light mode (the design has none), and
+ --page-bg: var(--secondary-color);
+ --surface: var(--background-color-element);
+ /* The rows have no color change on hover in light mode (the design has none), and
in dark mode they do. The buttons and the search field change in both. */
- --surface-hover: var(--surface);
- --row-hover-amount: 0%;
- /* Every other row in the results table: a light tint of the blue accent color */
- --table-row-alt: color-mix(in srgb, var(--background-color-element), var(--accent-color) 40%);
- --table-head-bg: var(--text-color);
- --table-head-text: var(--background-color-element);
- --pill-bg: var(--text-color);
- --pill-text: var(--background-color-element);
- /* The buttons: light in the design. In dark mode they use the light color of
+ --surface-hover: var(--surface);
+ --row-hover-amount: 0%;
+ /* Every other row in the results table: a light tint of the blue accent color */
+ --table-row-alt: color-mix(
+ in srgb,
+ var(--background-color-element),
+ var(--accent-color) 40%
+ );
+ --table-head-bg: var(--text-color);
+ --table-head-text: var(--background-color-element);
+ --pill-bg: var(--text-color);
+ --pill-text: var(--background-color-element);
+ /* The buttons: light in the design. In dark mode they use the light color of
"More variants", so they stand out from the dark page. */
- --button-bg: var(--surface);
- --button-hover-bg: var(--primary-color);
- --button-text: var(--text-color);
- /* The search field: as the design in light mode. In dark mode it is see-through
+ --button-bg: var(--surface);
+ --button-hover-bg: var(--primary-color);
+ --button-text: var(--text-color);
+ /* The search field: as the design in light mode. In dark mode it is see-through
with a tan line, so it can be seen without a solid color. */
- --search-bg: var(--surface);
- --search-hover-bg: var(--primary-color);
- --search-border: transparent;
+ --search-bg: var(--surface);
+ --search-hover-bg: var(--primary-color);
+ --search-border: transparent;
- /* The gymnasts and the login pages. Each has a light and a dark value, so
+ /* The gymnasts and the login pages. Each has a light and a dark value, so
these pages follow the theme like the Elements page does. */
- --avatar-bg: var(--accent-color);
- --avatar-text: var(--text-color-dark);
- /* Text fields: a solid field in light mode, see-through with a tan line in dark mode. */
- --field-bg: var(--background-color-element);
- --field-border: var(--box-stroke);
- /* Delete: red on the card (6.1:1 in light mode, 5.8:1 in dark mode), and the text
+ --avatar-bg: var(--accent-color);
+ --avatar-text: var(--text-color-dark);
+ /* Text fields: a solid field in light mode, see-through with a tan line in dark mode. */
+ --field-bg: var(--background-color-element);
+ --field-border: var(--box-stroke);
+ /* Delete: red on the card (6.1:1 in light mode, 5.8:1 in dark mode), and the text
on a red button. */
- --danger: #B42318;
- --danger-contrast: #FFFFFF;
- /* The save tick: green with a white tick (5.3:1), and 5.0:1 against the card, so
+ --danger: #b42318;
+ --danger-contrast: #ffffff;
+ /* The save tick: green with a white tick (5.3:1), and 5.0:1 against the card, so
the button can be seen. The card color alone would not be enough. */
- --success-bg: #1E7B34;
- --success-contrast: #FFFFFF;
- /* The "No, keep it" button of the delete dialog. Its fill is close to the card
+ --success-bg: #1e7b34;
+ --success-contrast: #ffffff;
+ /* The "No, keep it" button of the delete dialog. Its fill is close to the card
(1.2:1), so it has an edge of 2px that is at least 3:1 on the card (3.4:1). */
- --secondary-button-bg: var(--secondary-color);
- --secondary-button-text: var(--text-color);
- --secondary-button-border: var(--box-stroke);
+ --secondary-button-bg: var(--secondary-color);
+ --secondary-button-text: var(--text-color);
+ --secondary-button-border: var(--box-stroke);
- /* D-value badge */
- --badge-d-value-bg: #BDE981;
- --badge-d-value-text: var(--text-color-dark);
- --border-color: var(--text-color);
+ /* D-value badge */
+ --badge-d-value-bg: #bde981;
+ --badge-d-value-text: var(--text-color-dark);
+ --border-color: var(--text-color);
- /* This part was generate with the help of Gemini 2.5 Flash using the prompt:
+ /* This part was generate with the help of Gemini 2.5 Flash using the prompt:
"Create spacing variables and a dark mode colro pallette based on my previous design" */
- /* Space settings */
- --space-xs: 0.25rem; /* 4px - micro gaps (title to subtext) */
- --space-sm: 0.5rem; /* 8px - small padding (chips, badges) */
- --space-md: 1rem; /* 16px - default padding (inputs, row heights) */
- --space-lg: 1.5rem; /* 24px - component gaps (between search & filters) */
- --space-xl: 2.5rem; /* 40px - page section margins & outer padding */
+ /* Space settings */
+ --space-xs: 0.25rem; /* 4px - micro gaps (title to subtext) */
+ --space-sm: 0.5rem; /* 8px - small padding (chips, badges) */
+ --space-md: 1rem; /* 16px - default padding (inputs, row heights) */
+ --space-lg: 1.5rem; /* 24px - component gaps (between search & filters) */
+ --space-xl: 2.5rem; /* 40px - page section margins & outer padding */
}
/* Dark mode colors */
[data-theme='dark'] {
- /* Main Layout Colors */
- --primary-color: #0A1A3C; /* Deep blue for primary elements */
- --background-color: #090F2F; /* Dark blue background */
- --bg: var(--background-color);
- --text: var(--text-color);
- --header-background: #101844; /* Slightly lighter blue for nav */
- --element-background: #18225C; /* Inputs, dropdowns, table background */
- --surface-selected: #253788; /* Row selection tint */
+ /* Main Layout Colors */
+ --primary-color: #0a1a3c; /* Deep blue for primary elements */
+ --background-color: #090f2f; /* Dark blue background */
+ --bg: var(--background-color);
+ --text: var(--text-color);
+ --header-background: #101844; /* Slightly lighter blue for nav */
+ --element-background: #18225c; /* Inputs, dropdowns, table background */
+ --surface-selected: #253788; /* Row selection tint */
- /* Text Colors */
- --text-color: #EAE0CF; /* Headings and body text */
- --text-color-primary: #EAE0CF; /* Warm light text */
- --text-color-secondary: #A0AEC0; /* Muted text for labels */
- --text-color-on-dark: #090F2F; /* Inverted text for light buttons */
+ /* Text Colors */
+ --text-color: #eae0cf; /* Headings and body text */
+ --text-color-primary: #eae0cf; /* Warm light text */
+ --text-color-secondary: #a0aec0; /* Muted text for labels */
+ --text-color-on-dark: #090f2f; /* Inverted text for light buttons */
- /* UI Components & Borders */
- --border-color: #3B426B;
- --border-subtle: #2A3363;
- --badge-d-value-bg: #82C43C; /* Slightly deeper green for contrast */
- --badge-d-value-text: #090F2F;
+ /* UI Components & Borders */
+ --border-color: #3b426b;
+ --border-subtle: #2a3363;
+ --badge-d-value-bg: #82c43c; /* Slightly deeper green for contrast */
+ --badge-d-value-text: #090f2f;
- /* The Elements page, from the color inspiration (inspo.png): a gray slate
+ /* The Elements page, from the color inspiration (inspo.png): a gray slate
backdrop, darker slate rows, the navbar color in the table heading and a
warm tan for the "More variants" mark. */
- --page-bg: #40444F;
- --surface: #2D3343;
- --surface-hover: #434A60;
- --row-hover-amount: 10%;
- --table-row-alt: #373E52;
- --table-head-bg: var(--primary-color);
- --table-head-text: var(--text-color);
- --pill-bg: #C19F83;
- --pill-text: var(--primary-color);
- --button-bg: var(--pill-bg);
- --button-hover-bg: color-mix(in srgb, var(--pill-bg), white 25%);
- --button-text: var(--pill-text);
- --search-bg: color-mix(in srgb, var(--text-color) 10%, transparent);
- --search-hover-bg: color-mix(in srgb, var(--text-color) 16%, transparent);
- --search-border: var(--pill-bg);
- --avatar-bg: var(--pill-bg);
- --avatar-text: var(--primary-color);
- --field-bg: color-mix(in srgb, var(--text-color) 10%, transparent);
- --field-border: var(--pill-bg);
- --danger: #FF8F8F;
- --danger-contrast: #090F2F;
- /* A mint green for the save tick in dark mode: 7.1:1 on the card, and the dark tick
+ --page-bg: #40444f;
+ --surface: #2d3343;
+ --surface-hover: #434a60;
+ --row-hover-amount: 10%;
+ --table-row-alt: #373e52;
+ --table-head-bg: var(--primary-color);
+ --table-head-text: var(--text-color);
+ --pill-bg: #c19f83;
+ --pill-text: var(--primary-color);
+ --button-bg: var(--pill-bg);
+ --button-hover-bg: color-mix(in srgb, var(--pill-bg), white 25%);
+ --button-text: var(--pill-text);
+ --search-bg: color-mix(in srgb, var(--text-color) 10%, transparent);
+ --search-hover-bg: color-mix(in srgb, var(--text-color) 16%, transparent);
+ --search-border: var(--pill-bg);
+ --avatar-bg: var(--pill-bg);
+ --avatar-text: var(--primary-color);
+ --field-bg: color-mix(in srgb, var(--text-color) 10%, transparent);
+ --field-border: var(--pill-bg);
+ --danger: #ff8f8f;
+ --danger-contrast: #090f2f;
+ /* A mint green for the save tick in dark mode: 7.1:1 on the card, and the dark tick
on it is 10.6:1. */
- --success-bg: #01DF80;
- --success-contrast: #090F2F;
- /* White text (7.1:1) on a dark fill. White text and a fill that is 3:1 on the
+ --success-bg: #01df80;
+ --success-contrast: #090f2f;
+ /* White text (7.1:1) on a dark fill. White text and a fill that is 3:1 on the
card cannot both be had (the fill would have to be both darker and lighter than
a luminance of 0.19), so the edge of the control is a blue-grey line of 2px, 3.6:1
on the card, as the rule for the edge of a control asks (WCAG 1.4.11). */
- --secondary-button-bg: #4F5778;
- --secondary-button-text: #FFFFFF;
- --secondary-button-border: #8088A6; /* a blue-grey, 3.6:1 on the card: it does not draw the eye first */
+ --secondary-button-bg: #4f5778;
+ --secondary-button-text: #ffffff;
+ --secondary-button-border: #8088a6; /* a blue-grey, 3.6:1 on the card: it does not draw the eye first */
}
diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx
index fe11b89..cd9fbd2 100644
--- a/src/tests/App.test.tsx
+++ b/src/tests/App.test.tsx
@@ -1,10 +1,10 @@
// @vitest-environment jsdom
-import { ApolloProvider } from '@apollo/client/react';
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { BrowserRouter } from 'react-router-dom';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { createTestClient, ok, type Handler } from '@/tests/apollo';
-import App from '@/App';
+import { ApolloProvider } from '@apollo/client/react'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createTestClient, ok, type Handler } from '@/tests/apollo'
+import App from '@/App'
/**
* The app with the router and the Apollo client that main.tsx gives it. The real
@@ -12,124 +12,132 @@ import App from '@/App';
* answers, so the pages stay in their loading state.
*/
const renderApp = (handler: Handler = () => new Promise(() => {})) => {
- const { client } = createTestClient(handler);
+ const { client } = createTestClient(handler)
return render(
,
- );
-};
+ )
+}
beforeEach(() => {
- window.localStorage.clear();
- window.history.replaceState({}, '', import.meta.env.BASE_URL);
-});
+ window.localStorage.clear()
+ window.history.replaceState({}, '', import.meta.env.BASE_URL)
+})
afterEach(() => {
- delete document.documentElement.dataset.theme;
- window.localStorage.clear();
- window.history.replaceState({}, '', import.meta.env.BASE_URL);
-});
+ delete document.documentElement.dataset.theme
+ window.localStorage.clear()
+ window.history.replaceState({}, '', import.meta.env.BASE_URL)
+})
describe('App', () => {
it('applies and toggles the selected theme on the document', () => {
- renderApp();
+ renderApp()
- expect(document.documentElement.getAttribute('data-theme')).toBe('light');
- expect(window.localStorage.getItem('theme')).toBe('light');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light')
+ expect(window.localStorage.getItem('theme')).toBe('light')
- const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i });
- fireEvent.click(themeToggle);
+ const themeToggle = screen.getByRole('switch', {
+ name: /toggle dark theme/i,
+ })
+ fireEvent.click(themeToggle)
- expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
- expect(window.localStorage.getItem('theme')).toBe('dark');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
+ expect(window.localStorage.getItem('theme')).toBe('dark')
- fireEvent.click(themeToggle);
+ fireEvent.click(themeToggle)
- expect(document.documentElement.getAttribute('data-theme')).toBe('light');
- expect(window.localStorage.getItem('theme')).toBe('light');
- });
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light')
+ expect(window.localStorage.getItem('theme')).toBe('light')
+ })
it('restores the previously saved theme on page load', () => {
- window.localStorage.setItem('theme', 'dark');
+ window.localStorage.setItem('theme', 'dark')
- renderApp();
+ renderApp()
- expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
expect(
screen
.getByRole('switch', { name: /toggle dark theme/i })
.getAttribute('aria-checked'),
- ).toBe('true');
- });
+ ).toBe('true')
+ })
it('generates navigation URLs beneath the configured base path', () => {
- renderApp();
+ renderApp()
- expect(screen.getByRole('link', { name: 'Calculator' }).getAttribute('href')).toBe(
- `${import.meta.env.BASE_URL}calculator`,
- );
- });
+ expect(
+ screen.getByRole('link', { name: 'Calculator' }).getAttribute('href'),
+ ).toBe(`${import.meta.env.BASE_URL}calculator`)
+ })
it('renders routed content inside a single main landmark', () => {
- renderApp();
+ renderApp()
- expect(screen.getAllByRole('main')).toHaveLength(1);
+ expect(screen.getAllByRole('main')).toHaveLength(1)
- fireEvent.click(screen.getByRole('link', { name: 'About' }));
+ fireEvent.click(screen.getByRole('link', { name: 'About' }))
- expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy();
- expect(screen.getAllByRole('main')).toHaveLength(1);
- });
+ expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy()
+ expect(screen.getAllByRole('main')).toHaveLength(1)
+ })
it('gives every page its own title, so the tab and the history say where you are', () => {
- renderApp();
- expect(document.title).toBe('Elements – WAG CoP Planner');
+ renderApp()
+ expect(document.title).toBe('Elements – WAG CoP Planner')
- fireEvent.click(screen.getByRole('link', { name: 'About' }));
- expect(document.title).toBe('About – WAG CoP Planner');
+ fireEvent.click(screen.getByRole('link', { name: 'About' }))
+ expect(document.title).toBe('About – WAG CoP Planner')
- fireEvent.click(screen.getByRole('link', { name: 'Calculator' }));
- expect(document.title).toBe('Calculator – WAG CoP Planner');
+ fireEvent.click(screen.getByRole('link', { name: 'Calculator' }))
+ expect(document.title).toBe('Calculator – WAG CoP Planner')
- window.history.pushState({}, '', `${import.meta.env.BASE_URL}no-such-page`);
- cleanup();
- renderApp();
- expect(document.title).toBe('Page not found – WAG CoP Planner');
- });
+ window.history.pushState({}, '', `${import.meta.env.BASE_URL}no-such-page`)
+ cleanup()
+ renderApp()
+ expect(document.title).toBe('Page not found – WAG CoP Planner')
+ })
it('opens the search page from the start address, the logo and the Search link', () => {
- renderApp();
- expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
- expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy();
+ renderApp()
+ expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`)
+ expect(
+ screen.getByRole('heading', { level: 1, name: 'Elements' }),
+ ).toBeTruthy()
for (const link of [
() => screen.getByRole('link', { name: /wag cop planner home/i }),
() => screen.getByRole('link', { name: 'Search' }),
]) {
- fireEvent.click(screen.getByRole('link', { name: 'About' }));
- expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy();
-
- fireEvent.click(link());
- expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
- expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy();
+ fireEvent.click(screen.getByRole('link', { name: 'About' }))
+ expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy()
+
+ fireEvent.click(link())
+ expect(window.location.pathname).toBe(
+ `${import.meta.env.BASE_URL}elements`,
+ )
+ expect(
+ screen.getByRole('heading', { level: 1, name: 'Elements' }),
+ ).toBeTruthy()
}
- });
+ })
it('sends a visitor who is not logged in from the Gymnasts route to the login page', async () => {
- window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`);
- renderApp(() => ok({ me: null }));
+ window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`)
+ renderApp(() => ok({ me: null }))
- expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy();
- expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`);
- expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull();
- expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy();
- });
+ expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy()
+ expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`)
+ expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull()
+ expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy()
+ })
it('keeps a logged-in coach on the Gymnasts route after a reload, and the menu agrees with the page', async () => {
- window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`);
+ window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`)
renderApp((operation) =>
ok({
me: {
@@ -142,27 +150,27 @@ describe('App', () => {
}),
},
}),
- );
+ )
expect(
await screen.findByRole('heading', { name: 'Your gymnasts' }),
- ).toBeTruthy();
- expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`);
+ ).toBeTruthy()
+ expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`)
// The menu and the page read the same answer.
- expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy();
- expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull();
- expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy();
- expect(await screen.findByText('Emma Berg')).toBeTruthy();
- });
+ expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy()
+ expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull()
+ expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy()
+ expect(await screen.findByText('Emma Berg')).toBeTruthy()
+ })
it('does not send a visitor away before the server has said who is logged in', async () => {
- window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`);
- renderApp(); // the server never answers
+ window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`)
+ renderApp() // the server never answers
- expect(await screen.findByText('Checking your login…')).toBeTruthy();
- expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`);
+ expect(await screen.findByText('Checking your login…')).toBeTruthy()
+ expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`)
// Neither "Log in" nor "Log out" is true yet.
- expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull();
- });
-});
+ expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull()
+ expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull()
+ })
+})
diff --git a/src/tests/components/gymnasts/DevLoginButton.test.tsx b/src/tests/components/gymnasts/DevLoginButton.test.tsx
index b17b341..6675057 100644
--- a/src/tests/components/gymnasts/DevLoginButton.test.tsx
+++ b/src/tests/components/gymnasts/DevLoginButton.test.tsx
@@ -1,134 +1,134 @@
// @vitest-environment jsdom
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { ApiError } from "@/lib/apollo/api";
-import { devLogin } from "@/lib/apollo/client";
-import { isDevLoginEnabled } from "@/lib/devLogin";
-import DevLoginButton from "@/components/gymnasts/DevLoginButton";
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ApiError } from '@/lib/apollo/api'
+import { devLogin } from '@/lib/apollo/client'
+import { isDevLoginEnabled } from '@/lib/devLogin'
+import DevLoginButton from '@/components/gymnasts/DevLoginButton'
-vi.mock("@/lib/apollo/client", () => ({ devLogin: vi.fn() }));
+vi.mock('@/lib/apollo/client', () => ({ devLogin: vi.fn() }))
-const logIn = vi.mocked(devLogin);
-const BUTTON = "Log in as test coach";
-const EMAIL = "emma@example.com";
-const coach = { id: "c1", name: "emma", email: EMAIL };
+const logIn = vi.mocked(devLogin)
+const BUTTON = 'Log in as test coach'
+const EMAIL = 'emma@example.com'
+const coach = { id: 'c1', name: 'emma', email: EMAIL }
afterEach(() => {
- cleanup();
- vi.resetAllMocks();
- vi.unstubAllEnvs();
-});
+ cleanup()
+ vi.resetAllMocks()
+ vi.unstubAllEnvs()
+})
function typeEmail(address: string) {
- fireEvent.change(screen.getByLabelText("Your e-mail address"), {
+ fireEvent.change(screen.getByLabelText('Your e-mail address'), {
target: { value: address },
- });
+ })
}
-describe("isDevLoginEnabled", () => {
- it("is on only when the build was made with VITE_DEV_LOGIN=true", () => {
+describe('isDevLoginEnabled', () => {
+ it('is on only when the build was made with VITE_DEV_LOGIN=true', () => {
for (const [value, expected] of [
- ["true", true],
- ["", false],
- ["false", false],
- ["1", false],
+ ['true', true],
+ ['', false],
+ ['false', false],
+ ['1', false],
] as const) {
- vi.stubEnv("VITE_DEV_LOGIN", value);
- expect(isDevLoginEnabled()).toBe(expected);
+ vi.stubEnv('VITE_DEV_LOGIN', value)
+ expect(isDevLoginEnabled()).toBe(expected)
}
- });
-});
+ })
+})
-describe("DevLoginButton", () => {
- it("warns that anyone can log in as anyone", () => {
- render( Promise.resolve()} />);
+describe('DevLoginButton', () => {
+ it('warns that anyone can log in as anyone', () => {
+ render( Promise.resolve()} />)
expect(
screen.getByText(
- "Test login: anyone can log in as anyone, so don’t enter real data.",
+ 'Test login: anyone can log in as anyone, so don’t enter real data.',
),
- ).toBeTruthy();
- });
+ ).toBeTruthy()
+ })
- it("logs in as the address that was typed, then loads what the page shows", async () => {
- logIn.mockResolvedValue(coach);
- const onLoggedIn = vi.fn().mockResolvedValue(undefined);
- render();
+ it('logs in as the address that was typed, then loads what the page shows', async () => {
+ logIn.mockResolvedValue(coach)
+ const onLoggedIn = vi.fn().mockResolvedValue(undefined)
+ render()
- typeEmail(EMAIL);
- fireEvent.click(screen.getByRole("button", { name: BUTTON }));
+ typeEmail(EMAIL)
+ fireEvent.click(screen.getByRole('button', { name: BUTTON }))
- await vi.waitFor(() => expect(onLoggedIn).toHaveBeenCalledTimes(1));
- expect(logIn).toHaveBeenCalledExactlyOnceWith(EMAIL);
- expect(screen.queryByRole("alert")).toBeNull();
- });
+ await vi.waitFor(() => expect(onLoggedIn).toHaveBeenCalledTimes(1))
+ expect(logIn).toHaveBeenCalledExactlyOnceWith(EMAIL)
+ expect(screen.queryByRole('alert')).toBeNull()
+ })
it("starts with no address, so nobody logs in as someone else's coach by default", () => {
- render( Promise.resolve()} />);
+ render( Promise.resolve()} />)
expect(
- screen.getByLabelText("Your e-mail address").value,
- ).toBe("");
- });
+ screen.getByLabelText('Your e-mail address').value,
+ ).toBe('')
+ })
- it("is disabled while it logs in, so a double click makes one login", async () => {
- let finish!: () => void;
+ it('is disabled while it logs in, so a double click makes one login', async () => {
+ let finish!: () => void
logIn.mockReturnValue(
new Promise((done) => {
- finish = () => done(coach);
+ finish = () => done(coach)
}),
- );
- render( Promise.resolve()} />);
-
- typeEmail(EMAIL);
- fireEvent.click(screen.getByRole("button", { name: BUTTON }));
- const busy = await screen.findByRole("button", {
- name: "Logging in…",
- });
- expect(busy.disabled).toBe(true);
- fireEvent.click(busy);
- finish();
-
- await screen.findByRole("button", { name: BUTTON });
- expect(logIn).toHaveBeenCalledTimes(1);
- });
+ )
+ render( Promise.resolve()} />)
+
+ typeEmail(EMAIL)
+ fireEvent.click(screen.getByRole('button', { name: BUTTON }))
+ const busy = await screen.findByRole('button', {
+ name: 'Logging in…',
+ })
+ expect(busy.disabled).toBe(true)
+ fireEvent.click(busy)
+ finish()
+
+ await screen.findByRole('button', { name: BUTTON })
+ expect(logIn).toHaveBeenCalledTimes(1)
+ })
it("shows the server's answer when it refuses the address, and keeps what was typed", async () => {
logIn.mockRejectedValue(
- new ApiError("BAD_USER_INPUT", "Enter a valid e-mail address."),
- );
- const onLoggedIn = vi.fn();
- render();
-
- typeEmail("emma@x");
- fireEvent.click(screen.getByRole("button", { name: BUTTON }));
-
- expect((await screen.findByRole("alert")).textContent).toBe(
- "Enter a valid e-mail address.",
- );
- expect(onLoggedIn).not.toHaveBeenCalled();
+ new ApiError('BAD_USER_INPUT', 'Enter a valid e-mail address.'),
+ )
+ const onLoggedIn = vi.fn()
+ render()
+
+ typeEmail('emma@x')
+ fireEvent.click(screen.getByRole('button', { name: BUTTON }))
+
+ expect((await screen.findByRole('alert')).textContent).toBe(
+ 'Enter a valid e-mail address.',
+ )
+ expect(onLoggedIn).not.toHaveBeenCalled()
expect(
- screen.getByLabelText("Your e-mail address").value,
- ).toBe("emma@x");
- });
+ screen.getByLabelText('Your e-mail address').value,
+ ).toBe('emma@x')
+ })
- it("says so when the server has the development login turned off", async () => {
+ it('says so when the server has the development login turned off', async () => {
logIn.mockRejectedValue(
- new ApiError("NOT_IMPLEMENTED", "devLogin is not available yet."),
- );
- const onLoggedIn = vi.fn();
- render();
-
- typeEmail(EMAIL);
- fireEvent.click(screen.getByRole("button", { name: BUTTON }));
-
- expect((await screen.findByRole("alert")).textContent).toBe(
- "This is not available yet.",
- );
- expect(onLoggedIn).not.toHaveBeenCalled();
+ new ApiError('NOT_IMPLEMENTED', 'devLogin is not available yet.'),
+ )
+ const onLoggedIn = vi.fn()
+ render()
+
+ typeEmail(EMAIL)
+ fireEvent.click(screen.getByRole('button', { name: BUTTON }))
+
+ expect((await screen.findByRole('alert')).textContent).toBe(
+ 'This is not available yet.',
+ )
+ expect(onLoggedIn).not.toHaveBeenCalled()
expect(
- screen.getByRole("button", { name: BUTTON }).disabled,
- ).toBe(false);
- });
-});
+ screen.getByRole('button', { name: BUTTON }).disabled,
+ ).toBe(false)
+ })
+})
diff --git a/src/tests/components/gymnasts/GymnastList.test.tsx b/src/tests/components/gymnasts/GymnastList.test.tsx
index c052d5d..1b59017 100644
--- a/src/tests/components/gymnasts/GymnastList.test.tsx
+++ b/src/tests/components/gymnasts/GymnastList.test.tsx
@@ -1,52 +1,58 @@
-import { ApolloProvider } from "@apollo/client/react";
-import { renderToString } from "react-dom/server";
-import { describe, expect, it } from "vitest";
-import { createTestClient } from "@/tests/apollo";
-import GymnastList from "@/components/gymnasts/GymnastList";
+import { ApolloProvider } from '@apollo/client/react'
+import { renderToString } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import { createTestClient } from '@/tests/apollo'
+import GymnastList from '@/components/gymnasts/GymnastList'
// Each row has a delete button, which uses the client for its mutation.
-const { client } = createTestClient(() => new Promise(() => {}));
+const { client } = createTestClient(() => new Promise(() => {}))
const renderList = (gymnasts: { id: string; name: string }[]) =>
renderToString(
- {}} onFailed={() => {}} />
+ {}}
+ onFailed={() => {}}
+ />
,
- );
+ )
-describe("GymnastList", () => {
- it("renders names with initials", () => {
+describe('GymnastList', () => {
+ it('renders names with initials', () => {
const markup = renderList([
- { id: "one", name: "Ingrid Haugen" },
- { id: "two", name: "Emma Berg" },
- ]);
-
- expect(markup).toContain("Ingrid Haugen");
- expect(markup).toContain("IH");
- expect(markup).toContain("Emma Berg");
- expect(markup).toContain("EB");
- });
-
- it("has a heading for the list", () => {
- const markup = renderList([]);
-
- expect(markup).toMatch(/]*>Gymnast list<\/h2>/);
- });
-
- it("announces the empty list state", () => {
- const markup = renderList([]);
-
- expect(markup).toContain('role="status"');
- expect(markup).toContain("You haven’t added any gymnasts yet.");
- });
-});
-
-describe("GymnastList: the cards show that they can be pressed", () => {
- it("has a tint and a deeper shadow on hover, a pointer, and no movement for a visitor who asks for less", () => {
- const markup = renderList([{ id: "one", name: "Ingrid Haugen" }]);
-
- expect(markup).toContain("cursor-pointer");
- expect(markup).toContain("hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)]");
- expect(markup).toContain("hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]");
- expect(markup).toContain("motion-reduce:transition-none");
- });
-});
+ { id: 'one', name: 'Ingrid Haugen' },
+ { id: 'two', name: 'Emma Berg' },
+ ])
+
+ expect(markup).toContain('Ingrid Haugen')
+ expect(markup).toContain('IH')
+ expect(markup).toContain('Emma Berg')
+ expect(markup).toContain('EB')
+ })
+
+ it('has a heading for the list', () => {
+ const markup = renderList([])
+
+ expect(markup).toMatch(/]*>Gymnast list<\/h2>/)
+ })
+
+ it('announces the empty list state', () => {
+ const markup = renderList([])
+
+ expect(markup).toContain('role="status"')
+ expect(markup).toContain('You haven’t added any gymnasts yet.')
+ })
+})
+
+describe('GymnastList: the cards show that they can be pressed', () => {
+ it('has a tint and a deeper shadow on hover, a pointer, and no movement for a visitor who asks for less', () => {
+ const markup = renderList([{ id: 'one', name: 'Ingrid Haugen' }])
+
+ expect(markup).toContain('cursor-pointer')
+ expect(markup).toContain(
+ 'hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)]',
+ )
+ expect(markup).toContain('hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]')
+ expect(markup).toContain('motion-reduce:transition-none')
+ })
+})
diff --git a/src/tests/components/navbar/Navbar.test.tsx b/src/tests/components/navbar/Navbar.test.tsx
index d5e9c1b..c111fcf 100644
--- a/src/tests/components/navbar/Navbar.test.tsx
+++ b/src/tests/components/navbar/Navbar.test.tsx
@@ -1,15 +1,15 @@
// @vitest-environment jsdom
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { BrowserRouter, useLocation } from 'react-router-dom';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { AuthContext, type AuthState } from '@/components/auth/useAuth';
-import { logout } from '@/lib/apollo/client';
-import { Navbar } from '@/components/navbar/Navbar';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { BrowserRouter, useLocation } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { AuthContext, type AuthState } from '@/components/auth/useAuth'
+import { logout } from '@/lib/apollo/client'
+import { Navbar } from '@/components/navbar/Navbar'
// The real functions would call the server. The Navbar only has to call logout.
vi.mock('@/lib/apollo/client', () => ({
logout: vi.fn(async () => undefined),
-}));
+}))
const states: Record = {
loading: { status: 'loading', coach: null },
@@ -18,198 +18,203 @@ const states: Record = {
status: 'authenticated',
coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' },
},
-};
+}
/** Shows the address, so a test can see where the navigation went. */
function Address() {
- const location = useLocation();
- return ;
+ const location = useLocation()
+ return
}
beforeEach(() => {
- window.history.replaceState({}, '', '/');
-});
+ window.history.replaceState({}, '', '/')
+})
afterEach(() => {
- window.history.replaceState({}, '', '/');
-});
+ window.history.replaceState({}, '', '/')
+})
const renderNavbar = ({
auth = 'anonymous',
isDarkMode = false,
onToggleDarkMode = vi.fn(),
}: {
- auth?: keyof typeof states;
- isDarkMode?: boolean;
- onToggleDarkMode?: () => void;
+ auth?: keyof typeof states
+ isDarkMode?: boolean
+ onToggleDarkMode?: () => void
} = {}) =>
render(
-
+
,
- );
+ )
describe('Navbar', () => {
it('shows the brand and public navigation links', () => {
- renderNavbar();
-
- const brand = screen.getByRole('link', { name: /wag cop planner home/i });
- expect(brand).toBeTruthy();
- expect(brand.className).toContain('justify-self-start');
- expect(brand.className).toContain('text-left');
- expect(brand.className).toContain('min-w-0');
- const header = screen.getByRole('banner');
- expect(header.className).toContain('px-[clamp(0.5rem,2vw,2.5rem)]');
+ renderNavbar()
+
+ const brand = screen.getByRole('link', { name: /wag cop planner home/i })
+ expect(brand).toBeTruthy()
+ expect(brand.className).toContain('justify-self-start')
+ expect(brand.className).toContain('text-left')
+ expect(brand.className).toContain('min-w-0')
+ const header = screen.getByRole('banner')
+ expect(header.className).toContain('px-[clamp(0.5rem,2vw,2.5rem)]')
expect(header.className).toContain(
'min-[720px]:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]',
- );
- expect(header.className).toContain('relative');
- expect(screen.getByRole('link', { name: 'Search' })).toBeTruthy();
- expect(screen.getByRole('link', { name: 'Calculator' })).toBeTruthy();
- expect(screen.getByRole('link', { name: 'About' })).toBeTruthy();
- expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull();
- });
+ )
+ expect(header.className).toContain('relative')
+ expect(screen.getByRole('link', { name: 'Search' })).toBeTruthy()
+ expect(screen.getByRole('link', { name: 'Calculator' })).toBeTruthy()
+ expect(screen.getByRole('link', { name: 'About' })).toBeTruthy()
+ expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull()
+ })
it('shows the Gymnasts link only when logged in', () => {
- renderNavbar({ auth: 'authenticated' });
+ renderNavbar({ auth: 'authenticated' })
- expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy();
- });
+ expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy()
+ })
it('opens and closes the mobile navigation and closes after a link is selected', () => {
- const { container } = renderNavbar();
-
- const menuButton = screen.getByRole('button', { name: 'Open menu' });
- expect(menuButton.getAttribute('aria-expanded')).toBe('false');
- expect(menuButton.getAttribute('aria-controls')).toBe('main-menu');
- expect(container.querySelector('#main-menu')?.className).toContain('hidden');
-
- fireEvent.click(menuButton);
- expect(screen.getByRole('button', { name: 'Close menu' }).getAttribute('aria-expanded')).toBe(
- 'true',
- );
- expect(screen.getByRole('navigation', { name: 'Primary navigation' }).className).toContain(
- 'flex',
- );
-
- fireEvent.click(screen.getByRole('link', { name: 'About' }));
- expect(screen.getByRole('button', { name: 'Open menu' }).getAttribute('aria-expanded')).toBe(
- 'false',
- );
- expect(container.querySelector('#main-menu')?.className).toContain('hidden');
- });
+ const { container } = renderNavbar()
+
+ const menuButton = screen.getByRole('button', { name: 'Open menu' })
+ expect(menuButton.getAttribute('aria-expanded')).toBe('false')
+ expect(menuButton.getAttribute('aria-controls')).toBe('main-menu')
+ expect(container.querySelector('#main-menu')?.className).toContain('hidden')
+
+ fireEvent.click(menuButton)
+ expect(
+ screen
+ .getByRole('button', { name: 'Close menu' })
+ .getAttribute('aria-expanded'),
+ ).toBe('true')
+ expect(
+ screen.getByRole('navigation', { name: 'Primary navigation' }).className,
+ ).toContain('flex')
+
+ fireEvent.click(screen.getByRole('link', { name: 'About' }))
+ expect(
+ screen
+ .getByRole('button', { name: 'Open menu' })
+ .getAttribute('aria-expanded'),
+ ).toBe('false')
+ expect(container.querySelector('#main-menu')?.className).toContain('hidden')
+ })
it('keeps the navigation visible at 720px and hides the menu button', () => {
- renderNavbar();
+ renderNavbar()
- expect(screen.getByRole('navigation', { hidden: true }).className).toContain(
- 'min-[720px]:flex',
- );
- expect(screen.getByRole('button', { name: 'Open menu' }).className).toContain(
- 'min-[720px]:hidden',
- );
- });
+ expect(
+ screen.getByRole('navigation', { hidden: true }).className,
+ ).toContain('min-[720px]:flex')
+ expect(
+ screen.getByRole('button', { name: 'Open menu' }).className,
+ ).toContain('min-[720px]:hidden')
+ })
it('provides hover underlines and visible keyboard focus styles for navigation links', () => {
- renderNavbar();
+ renderNavbar()
- const navLink = screen.getByRole('link', { name: 'About' });
- expect(navLink.className).toContain("after:content-['']");
- expect(navLink.className).toContain('after:bottom-0');
- expect(navLink.className).toContain('self-start');
- expect(navLink.className).toContain('hover:after:scale-x-100');
- expect(navLink.className).toContain('focus-visible:outline-2');
- expect(navLink.className).toContain('focus-visible:outline-(--text-color)');
- });
+ const navLink = screen.getByRole('link', { name: 'About' })
+ expect(navLink.className).toContain("after:content-['']")
+ expect(navLink.className).toContain('after:bottom-0')
+ expect(navLink.className).toContain('self-start')
+ expect(navLink.className).toContain('hover:after:scale-x-100')
+ expect(navLink.className).toContain('focus-visible:outline-2')
+ expect(navLink.className).toContain('focus-visible:outline-(--text-color)')
+ })
it('sends the logo and the Search link to the same page, the search page', () => {
- renderNavbar();
+ renderNavbar()
- const logo = screen.getByRole('link', { name: /wag cop planner home/i });
- const search = screen.getByRole('link', { name: 'Search' });
- expect(logo.getAttribute('href')).toBe('/elements');
- expect(search.getAttribute('href')).toBe('/elements');
- });
+ const logo = screen.getByRole('link', { name: /wag cop planner home/i })
+ const search = screen.getByRole('link', { name: 'Search' })
+ expect(logo.getAttribute('href')).toBe('/elements')
+ expect(search.getAttribute('href')).toBe('/elements')
+ })
it('keeps the underline visible on the current page link', () => {
- window.history.replaceState({}, '', '/elements');
- renderNavbar();
+ window.history.replaceState({}, '', '/elements')
+ renderNavbar()
- const currentPageLink = screen.getByRole('link', { name: 'Search' });
- expect(currentPageLink.className).toContain('after:scale-x-100');
- expect(screen.getByRole('navigation').className).not.toContain('pb-[6px]');
- });
+ const currentPageLink = screen.getByRole('link', { name: 'Search' })
+ expect(currentPageLink.className).toContain('after:scale-x-100')
+ expect(screen.getByRole('navigation').className).not.toContain('pb-[6px]')
+ })
it('reports theme toggle clicks to its owner', () => {
- const onToggleDarkMode = vi.fn();
- renderNavbar({ onToggleDarkMode });
+ const onToggleDarkMode = vi.fn()
+ renderNavbar({ onToggleDarkMode })
const themeToggle = screen.getByRole('switch', {
name: /toggle dark theme/i,
- });
- expect(themeToggle.getAttribute('aria-checked')).toBe('false');
+ })
+ expect(themeToggle.getAttribute('aria-checked')).toBe('false')
- fireEvent.click(themeToggle);
+ fireEvent.click(themeToggle)
- expect(onToggleDarkMode).toHaveBeenCalledOnce();
- });
+ expect(onToggleDarkMode).toHaveBeenCalledOnce()
+ })
it('reflects the controlled dark-mode state', () => {
- renderNavbar({ isDarkMode: true });
+ renderNavbar({ isDarkMode: true })
const themeToggle = screen.getByRole('switch', {
name: /toggle dark theme/i,
- });
- expect(themeToggle.getAttribute('aria-checked')).toBe('true');
- expect(themeToggle.querySelector('span')?.style.transform).toBe('translateX(22px)');
- });
+ })
+ expect(themeToggle.getAttribute('aria-checked')).toBe('true')
+ expect(themeToggle.querySelector('span')?.style.transform).toBe(
+ 'translateX(22px)',
+ )
+ })
it('keeps the theme toggle thumb at its starting point in light mode', () => {
- renderNavbar({ isDarkMode: false });
+ renderNavbar({ isDarkMode: false })
const themeToggle = screen.getByRole('switch', {
name: /toggle dark theme/i,
- });
- expect(themeToggle.querySelector('span')?.style.transform).toBe('translateX(0)');
- });
+ })
+ expect(themeToggle.querySelector('span')?.style.transform).toBe(
+ 'translateX(0)',
+ )
+ })
it('has a Log in link to the login page when nobody is logged in', () => {
- renderNavbar({ auth: 'anonymous' });
+ renderNavbar({ auth: 'anonymous' })
- expect(screen.getByRole('link', { name: 'Log in' }).getAttribute('href')).toBe(
- '/login',
- );
- expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull();
- });
+ expect(
+ screen.getByRole('link', { name: 'Log in' }).getAttribute('href'),
+ ).toBe('/login')
+ expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull()
+ })
it('has a Log out button, and no Log in, when a coach is logged in', () => {
- renderNavbar({ auth: 'authenticated' });
+ renderNavbar({ auth: 'authenticated' })
- expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy();
- expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull();
- });
+ expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy()
+ expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull()
+ })
it('says neither Log in nor Log out until the server has answered', () => {
- renderNavbar({ auth: 'loading' });
+ renderNavbar({ auth: 'loading' })
- expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull();
- expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull();
- });
+ expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull()
+ expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull()
+ expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull()
+ })
it('logs out and leaves the page that is for coaches', async () => {
- window.history.replaceState({}, '', '/gymnasts');
- renderNavbar({ auth: 'authenticated' });
+ window.history.replaceState({}, '', '/gymnasts')
+ renderNavbar({ auth: 'authenticated' })
- fireEvent.click(screen.getByRole('button', { name: 'Log out' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Log out' }))
- await waitFor(() => expect(logout).toHaveBeenCalledOnce());
- expect(screen.getByTestId('address').textContent).toBe('/elements');
- });
-});
+ await waitFor(() => expect(logout).toHaveBeenCalledOnce())
+ expect(screen.getByTestId('address').textContent).toBe('/elements')
+ })
+})
diff --git a/src/tests/components/skipLink/SkipLink.test.tsx b/src/tests/components/skipLink/SkipLink.test.tsx
index 9f52d96..7604ad3 100644
--- a/src/tests/components/skipLink/SkipLink.test.tsx
+++ b/src/tests/components/skipLink/SkipLink.test.tsx
@@ -1,33 +1,37 @@
// @vitest-environment jsdom
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
-import { SkipLink } from '@/components/skipLink/SkipLink';
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { SkipLink } from '@/components/skipLink/SkipLink'
describe('SkipLink', () => {
it('links to the main content using the default id', () => {
- render();
+ render()
- expect(screen.getByRole('link', { name: 'Skip to main content' }).getAttribute('href')).toBe(
- '#main-content',
- );
- });
+ expect(
+ screen
+ .getByRole('link', { name: 'Skip to main content' })
+ .getAttribute('href'),
+ ).toBe('#main-content')
+ })
it('supports a custom main content id', () => {
- render();
+ render()
- expect(screen.getByRole('link', { name: 'Skip to main content' }).getAttribute('href')).toBe(
- '#page-content',
- );
- });
+ expect(
+ screen
+ .getByRole('link', { name: 'Skip to main content' })
+ .getAttribute('href'),
+ ).toBe('#page-content')
+ })
it('uses Tailwind classes to hide the link until keyboard focus', () => {
- render();
+ render()
- const link = screen.getByRole('link', { name: 'Skip to main content' });
- expect(link.className).toContain('sr-only');
- expect(link.className).toContain('px-md');
- expect(link.className).toContain('focus:not-sr-only');
- expect(link.className).toContain('focus:top-0');
- expect(link.className).toContain('focus:left-0');
- });
-});
+ const link = screen.getByRole('link', { name: 'Skip to main content' })
+ expect(link.className).toContain('sr-only')
+ expect(link.className).toContain('px-md')
+ expect(link.className).toContain('focus:not-sr-only')
+ expect(link.className).toContain('focus:top-0')
+ expect(link.className).toContain('focus:left-0')
+ })
+})
diff --git a/src/tests/lib/gymnasts.test.ts b/src/tests/lib/gymnasts.test.ts
index f867d36..df44e5f 100644
--- a/src/tests/lib/gymnasts.test.ts
+++ b/src/tests/lib/gymnasts.test.ts
@@ -1,46 +1,46 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it } from 'vitest'
import {
getGymnastInitials,
getGymnastNameError,
limitGymnastNameInput,
normalizeGymnastName,
-} from "@/lib/gymnasts";
+} from '@/lib/gymnasts'
-describe("gymnast helpers", () => {
- it("creates initials from the first and last name", () => {
- expect(getGymnastInitials("Ingrid Haugen")).toBe("IH");
- });
+describe('gymnast helpers', () => {
+ it('creates initials from the first and last name', () => {
+ expect(getGymnastInitials('Ingrid Haugen')).toBe('IH')
+ })
- it("handles a single name and empty input", () => {
- expect(getGymnastInitials("Madonna")).toBe("MA");
- expect(getGymnastInitials(" ")).toBe("?");
- });
+ it('handles a single name and empty input', () => {
+ expect(getGymnastInitials('Madonna')).toBe('MA')
+ expect(getGymnastInitials(' ')).toBe('?')
+ })
- it("trims and collapses whitespace in names", () => {
- expect(normalizeGymnastName(" Emma Berg ")).toBe("Emma Berg");
- });
+ it('trims and collapses whitespace in names', () => {
+ expect(normalizeGymnastName(' Emma Berg ')).toBe('Emma Berg')
+ })
- it("rejects blank and overlong names", () => {
- expect(getGymnastNameError(" ")).toBe("Name is required");
- expect(getGymnastNameError("A".repeat(71))).toBe(
- "Names must be 70 characters or fewer.",
- );
- expect(getGymnastNameError("Sofia Hansen")).toBeUndefined();
- });
+ it('rejects blank and overlong names', () => {
+ expect(getGymnastNameError(' ')).toBe('Name is required')
+ expect(getGymnastNameError('A'.repeat(71))).toBe(
+ 'Names must be 70 characters or fewer.',
+ )
+ expect(getGymnastNameError('Sofia Hansen')).toBeUndefined()
+ })
- it("asks for a name when there is none, and takes any name of 1 to 70 characters, like the API", () => {
- expect(getGymnastNameError("")).toBe("Name is required");
- expect(getGymnastNameError(" \t ")).toBe("Name is required");
- expect(getGymnastNameError("A")).toBeUndefined();
- expect(getGymnastNameError("Al")).toBeUndefined();
- expect(getGymnastNameError("😀")).toBeUndefined();
- expect(getGymnastNameError("A".repeat(70))).toBeUndefined();
- });
+ it('asks for a name when there is none, and takes any name of 1 to 70 characters, like the API', () => {
+ expect(getGymnastNameError('')).toBe('Name is required')
+ expect(getGymnastNameError(' \t ')).toBe('Name is required')
+ expect(getGymnastNameError('A')).toBeUndefined()
+ expect(getGymnastNameError('Al')).toBeUndefined()
+ expect(getGymnastNameError('😀')).toBeUndefined()
+ expect(getGymnastNameError('A'.repeat(70))).toBeUndefined()
+ })
- it("cuts what is typed to 70 characters, and counts an emoji as one", () => {
- expect(limitGymnastNameInput("A".repeat(80))).toBe("A".repeat(70));
- expect(limitGymnastNameInput("A".repeat(70))).toBe("A".repeat(70));
- expect(limitGymnastNameInput("Emma")).toBe("Emma");
- expect(limitGymnastNameInput("😀".repeat(80))).toBe("😀".repeat(70));
- });
-});
\ No newline at end of file
+ it('cuts what is typed to 70 characters, and counts an emoji as one', () => {
+ expect(limitGymnastNameInput('A'.repeat(80))).toBe('A'.repeat(70))
+ expect(limitGymnastNameInput('A'.repeat(70))).toBe('A'.repeat(70))
+ expect(limitGymnastNameInput('Emma')).toBe('Emma')
+ expect(limitGymnastNameInput('😀'.repeat(80))).toBe('😀'.repeat(70))
+ })
+})
diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts
index d8935b0..17cdac2 100644
--- a/src/tests/setupTests.ts
+++ b/src/tests/setupTests.ts
@@ -1,7 +1,7 @@
-import '@testing-library/jest-dom';
-import { cleanup } from '@testing-library/react';
-import { afterEach } from 'vitest';
+import '@testing-library/jest-dom'
+import { cleanup } from '@testing-library/react'
+import { afterEach } from 'vitest'
afterEach(() => {
- cleanup();
-});
\ No newline at end of file
+ cleanup()
+})
diff --git a/tsconfig.json b/tsconfig.json
index 36109fc..57b1335 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,12 +12,9 @@
}
],
"compilerOptions": {
-
"paths": {
"@/*": ["./src/*"]
},
"types": ["jest", "@testing-library/jest-dom"]
-
-
}
}
diff --git a/vite.config.ts b/vite.config.ts
index 4510e4e..b9ab130 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -28,4 +28,4 @@ export default defineConfig({
setupFiles: ['./src/tests/setupTests.ts'],
fileParallelism: false,
},
-});
+})