Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe("exercise accessibility labels", () => {
});

it("reads the countdown of the interlude", () => {
expect(getInterludeAccessibilityLabel(3)).toBe("Starting session in 3");
expect(getInterludeAccessibilityLabel(3)).toBe("Starting session in 3 seconds");
expect(getInterludeAccessibilityLabel(1)).toBe("Starting session in 1 second");
});
});
86 changes: 86 additions & 0 deletions src/screens/exercise-screen/__tests__/interlude.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { act, render, screen } from "@testing-library/react-native";
import React from "react";
import { ExerciseInterlude, getInterludeInitialStep } from "../interlude";

jest.mock("@breathly/utils/animate", () => ({
animate: (_value: unknown, { duration = 0 }: { duration?: number }) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
return {
start: (callback?: (result: { finished: boolean }) => void) => {
timeout = setTimeout(() => callback?.({ finished: true }), duration);
},
stop: () => {
if (timeout != null) clearTimeout(timeout);
},
};
},
}));

describe("exercise interlude", () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it("uses the selected preparation time as the initial countdown step", () => {
expect(getInterludeInitialStep(3_000)).toBe(3);
expect(getInterludeInitialStep(30_000)).toBe(30);
});

it("waits for every selected second after the subtitle appears", async () => {
const onComplete = jest.fn();

await render(React.createElement(ExerciseInterlude, { preparationTime: 3_000, onComplete }));

await act(async () => {
jest.advanceTimersByTime(600);
});
expect(onComplete).not.toHaveBeenCalled();

await act(async () => {
jest.advanceTimersByTime(400);
});
expect(screen.getByText("Starting session in \n3")).not.toBeNull();

await act(async () => {
jest.advanceTimersByTime(1_000);
});
expect(screen.getByText("Starting session in \n2")).not.toBeNull();

await act(async () => {
jest.advanceTimersByTime(1_000);
});
expect(screen.getByText("Starting session in \n1")).not.toBeNull();
expect(onComplete).not.toHaveBeenCalled();

await act(async () => {
jest.advanceTimersByTime(1_000);
});
expect(onComplete).not.toHaveBeenCalled();

await act(async () => {
jest.advanceTimersByTime(400);
});
expect(onComplete).toHaveBeenCalledTimes(1);
});

it("does not start an exercise after the countdown unmounts", async () => {
const onComplete = jest.fn();
const { unmount } = await render(
React.createElement(ExerciseInterlude, { preparationTime: 3_000, onComplete }),
);

await act(async () => {
jest.advanceTimersByTime(1_000);
});
await unmount();

await act(async () => {
jest.advanceTimersByTime(3_000);
});
expect(onComplete).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion src/screens/exercise-screen/accessibility-announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const getStepAccessibilityLabel = (label: string, durationMs: number) =>
`${label}, ${formatStepDuration(durationMs)}`;

export const getInterludeAccessibilityLabel = (secondsLeft: number) =>
`Starting session in ${secondsLeft}`;
`Starting session in ${secondsLeft} ${secondsLeft === 1 ? "second" : "seconds"}`;

export const sessionCompleteAnnouncement = "Session complete";

Expand Down
6 changes: 4 additions & 2 deletions src/screens/exercise-screen/exercise-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const screenReaderFallbackVoice: GuidedBreathingMode = "paul";
export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exercise">> = ({
navigation,
}) => {
const { guidedBreathingVoice } = useSettingsStore();
const { guidedBreathingVoice, preparationTime } = useSettingsStore();
const screenReaderEnabled = useScreenReaderEnabled();
// A user of a screen reader who disabled the voice has no channel that works
// without sight, because the visuals carry the whole exercise. The voice
Expand Down Expand Up @@ -135,7 +135,9 @@ export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exer
},
]}
>
{session.status === "interlude" && <ExerciseInterlude onComplete={handleInterludeComplete} />}
{session.status === "interlude" && (
<ExerciseInterlude preparationTime={preparationTime} onComplete={handleInterludeComplete} />
)}
{session.status === "running" && (
<>
{colorScheme === "dark" && (
Expand Down
26 changes: 15 additions & 11 deletions src/screens/exercise-screen/interlude.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,26 @@ import { useReduceMotion } from "@breathly/utils/use-accessibility-preferences";
import { useOnMount } from "@breathly/utils/use-on-mount";

interface Props {
preparationTime: number;
onComplete: () => void;
}

const interludeInitialDelay = 600;
const interludeAnimDuration = 400;
const interludeInitialStep = 3;
const secondMs = 1_000;

export const ExerciseInterlude: FC<Props> = ({ onComplete }) => {
export const getInterludeInitialStep = (preparationTime: number) =>
Math.max(1, Math.round(preparationTime / secondMs));

export const ExerciseInterlude: FC<Props> = ({ preparationTime, onComplete }) => {
const isDarkMode = useColorScheme() === "dark";
const theme = useThemeColors();
const reduceMotionEnabled = useReduceMotion();
const isMountedRef = useRef(true);
const containerAnimVal = useRef(new Animated.Value(1)).current;
const subtitleAnimVal = useRef(new Animated.Value(0)).current;
const [step, setStep] = useState(interludeInitialStep);
const initialStep = getInterludeInitialStep(preparationTime);
const [step, setStep] = useState(initialStep);

const goToStep = (nextStep: number) => {
setStep(nextStep);
Expand All @@ -46,13 +51,12 @@ export const ExerciseInterlude: FC<Props> = ({ onComplete }) => {
});

const countDownAndHide = async () => {
await delay(1000);
if (!isMountedRef.current) return;
goToStep(2);
await delay(1000);
if (!isMountedRef.current) return;
goToStep(1);
await delay(1000);
for (let nextStep = initialStep - 1; nextStep >= 1; nextStep--) {
await delay(secondMs);
if (!isMountedRef.current) return;
goToStep(nextStep);
}
await delay(secondMs);
if (!isMountedRef.current) return;
hideContainerAnimation.start((done) => done && onComplete());
};
Expand All @@ -61,7 +65,7 @@ export const ExerciseInterlude: FC<Props> = ({ onComplete }) => {
await delay(interludeInitialDelay);
showSubtitleAnimation.start(({ finished }) => {
if (!finished) return;
announceLiveRegionUpdate(getInterludeAccessibilityLabel(interludeInitialStep));
announceLiveRegionUpdate(getInterludeAccessibilityLabel(initialStep));
void countDownAndHide();
});
};
Expand Down
36 changes: 36 additions & 0 deletions src/screens/settings-screen/__tests__/settings-screen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react-native";
import React from "react";
import { useSettingsStore } from "@breathly/stores/settings";
import { defaultSettingsState } from "@breathly/stores/settings-state";
import { SettingsRootScreen } from "../settings-screen";

const navigation = {
goBack: jest.fn(),
navigate: jest.fn(),
setOptions: jest.fn(),
};

describe("settings screen", () => {
beforeEach(() => {
useSettingsStore.setState(defaultSettingsState);
});

it("updates the preparation time from its stepper", async () => {
await render(
<SettingsRootScreen
{...({ navigation, route: {} } as unknown as React.ComponentProps<
typeof SettingsRootScreen
>)}
/>,
);

expect(screen.getByTestId("settings.preparation-time.value").props.children).toBe(3);

await fireEvent.press(screen.getByTestId("settings.preparation-time.increase"));

await waitFor(() => {
expect(screen.getByTestId("settings.preparation-time.value").props.children).toBe(4);
expect(useSettingsStore.getState().preparationTime).toBe(4_000);
});
});
});
17 changes: 17 additions & 0 deletions src/screens/settings-screen/settings-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
import {
customPatternDurationLimits,
customPatternStepSizeMs,
maximumPreparationTimeMs,
maximumTimeLimitMs,
minimumPreparationTimeMs,
type Theme,
} from "@breathly/stores/settings-state";
import { GuidedBreathingMode } from "@breathly/types/guided-breathing-mode";
Expand All @@ -29,6 +31,9 @@ export const SettingsRootScreen: FC<
const timeLimit = useSettingsStore((state) => state.timeLimit);
const increaseTimeLimit = useSettingsStore((state) => state.increaseTimeLimit);
const decreaseTimeLimit = useSettingsStore((state) => state.decreaseTimeLimit);
const preparationTime = useSettingsStore((state) => state.preparationTime);
const increasePreparationTime = useSettingsStore((state) => state.increasePreparationTime);
const decreasePreparationTime = useSettingsStore((state) => state.decreasePreparationTime);
const shouldFollowSystemDarkMode = useSettingsStore((state) => state.shouldFollowSystemDarkMode);
const setShouldFollowSystemDarkMode = useSettingsStore(
(state) => state.setShouldFollowSystemDarkMode,
Expand Down Expand Up @@ -135,6 +140,18 @@ export const SettingsRootScreen: FC<
/>
</SettingsUI.Section>
<SettingsUI.Section label="Timer" hideBottomBorderWeb>
<SettingsUI.StepperItem
label="Preparation time"
secondaryLabel="Time before the exercise starts, in seconds"
value={preparationTime / ms("1 sec")}
iconName="hourglass"
iconBackgroundColor="#fdba74"
onIncrease={increasePreparationTime}
onDecrease={decreasePreparationTime}
decreaseDisabled={preparationTime <= minimumPreparationTimeMs}
increaseDisabled={preparationTime >= maximumPreparationTimeMs}
testID="settings.preparation-time"
/>
<SettingsUI.StepperItem
label="Exercise timer"
secondaryLabel="Time limit in minutes"
Expand Down
26 changes: 26 additions & 0 deletions src/stores/__tests__/settings-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import {
adjustPreparationTime,
adjustTimeLimit,
customPatternDurationLimits,
defaultSettingsState,
maximumPreparationTimeMs,
maximumTimeLimitMs,
mergePersistedSettingsState,
minimumPreparationTimeMs,
normalizePersistedSettingsState,
setCustomPatternStepValue,
} from "../settings-state";
Expand All @@ -22,6 +25,7 @@ describe("settings state", () => {
...defaultSettingsState,
customPatternEnabled: true,
customPatternSteps: [1_500, 0, 8_000, 3_500] as [number, number, number, number],
preparationTime: 30_000,
guidedBreathingVoice: "bell" as const,
timeLimit: 0,
shouldFollowSystemDarkMode: false,
Expand All @@ -37,6 +41,7 @@ describe("settings state", () => {
customPatternEnabled: "yes",
customPatternSteps: [-1, Number.NaN, 200_000, 3_000],
selectedPatternPresetId: "missing-preset",
preparationTime: Number.POSITIVE_INFINITY,
guidedBreathingVoice: "missing-voice",
timeLimit: Number.POSITIVE_INFINITY,
shouldFollowSystemDarkMode: null,
Expand Down Expand Up @@ -73,6 +78,27 @@ describe("settings state", () => {
expect(adjustTimeLimit(maximumTimeLimitMs, 60_000)).toBe(maximumTimeLimitMs);
});

it("clamps every preparation-time adjustment inside the supported range", () => {
expect(adjustPreparationTime(minimumPreparationTimeMs, -1_000)).toBe(minimumPreparationTimeMs);
expect(adjustPreparationTime(maximumPreparationTimeMs, 1_000)).toBe(maximumPreparationTimeMs);
});

it("rounds persisted preparation time to the displayed whole seconds", () => {
expect(normalizePersistedSettingsState({ preparationTime: 3_500 }).preparationTime).toBe(4_000);
});

it.each([
[null, defaultSettingsState.preparationTime],
["30 seconds", defaultSettingsState.preparationTime],
[Number.NaN, defaultSettingsState.preparationTime],
[minimumPreparationTimeMs - 1, minimumPreparationTimeMs],
[maximumPreparationTimeMs + 1, maximumPreparationTimeMs],
])("normalizes persisted preparation time %p", (preparationTime, expectedPreparationTime) => {
expect(normalizePersistedSettingsState({ preparationTime }).preparationTime).toBe(
expectedPreparationTime,
);
});

it("clamps custom steps and ignores invalid indexes", () => {
const steps = defaultSettingsState.customPatternSteps;
expect(setCustomPatternStepValue(steps, 0, 0)[0]).toBe(customPatternDurationLimits[0]![0]);
Expand Down
16 changes: 15 additions & 1 deletion src/stores/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,30 @@ describe("settings persistence", () => {
});

it("restores stored settings and keeps the store actions", async () => {
mockGetItem.mockResolvedValue(storedSettings({ theme: "dark", vibrationEnabled: false }));
mockGetItem.mockResolvedValue(
storedSettings({ theme: "dark", vibrationEnabled: false, preparationTime: 30_000 }),
);

const useSettingsStore = await loadSettingsStore();

expect(useSettingsStore.persist.hasHydrated()).toBe(true);
expect(useSettingsStore.getState().theme).toBe("dark");
expect(useSettingsStore.getState().vibrationEnabled).toBe(false);
expect(useSettingsStore.getState().preparationTime).toBe(30_000);
expect(typeof useSettingsStore.getState().setTheme).toBe("function");
});

it("adjusts the preparation time through the store actions", async () => {
mockGetItem.mockResolvedValue(null);
const useSettingsStore = await loadSettingsStore();

useSettingsStore.getState().increasePreparationTime();
expect(useSettingsStore.getState().preparationTime).toBe(4_000);

useSettingsStore.getState().decreasePreparationTime();
expect(useSettingsStore.getState().preparationTime).toBe(3_000);
});

it("retries a read that failed before it falls back to the defaults", async () => {
mockGetItem.mockRejectedValue(new Error("database is locked"));

Expand Down
Loading