diff --git a/README.md b/README.md index ef8acf04..1188e32a 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ get into your bundle. Direct hook imports should be considered otherwise. - [**`useNetworkState`**](./src/useNetworkState/index.ts) — Tracks the state of the browser's network connection. - [**`useVibrate`**](./src/useVibrate/index.ts) — Provides vibration feedback using the Vibration API. - [**`usePermission`**](./src/usePermission/index.ts) — Tracks the state of a permission. + - [**`useBattery`**](./src/useBattery/index.ts) — Tracks the state of the device's battery. - #### Miscellaneous - [**`useSyncedRef`**](./src/useSyncedRef/index.ts) — Like `useRef`, but it returns an immutable ref that contains the diff --git a/src/index.ts b/src/index.ts index ffcac791..cc23a164 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ export * from './useThrottledState/index.js'; export * from './useValidator/index.js'; // Navigator +export * from './useBattery/index.js'; export * from './useNetworkState/index.js'; export * from './usePermission/index.js'; export * from './useVibrate/index.js'; diff --git a/src/useBattery/index.dom.test.ts b/src/useBattery/index.dom.test.ts new file mode 100644 index 00000000..04132adb --- /dev/null +++ b/src/useBattery/index.dom.test.ts @@ -0,0 +1,144 @@ +import {act, renderHook} from '@ver0/react-hooks-testing'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {useBattery} from '../index.js'; +import type {BatteryManagerMock} from '../util/testing/setup/battery.test.js'; +import {getBatteryMock, mockBattery, resetBatteryMock} from '../util/testing/setup/battery.test.js'; +import {expectCallArgs, expectResultValue} from '../util/testing/test-helpers.js'; + +/** Flushes the microtask queue so the hook's pending getBattery() settles. */ +const flushBattery = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +describe('useBattery', () => { + beforeEach(() => { + resetBatteryMock(); + }); + + it('should be defined', () => { + expect(useBattery).toBeDefined(); + }); + + it('should render', async () => { + const {result} = await renderHook(() => useBattery()); + expectResultValue(result); + }); + + it('should return an object of certain structure', async () => { + const {result} = await renderHook(() => useBattery()); + const value = expectResultValue(result); + + expect(Object.keys(value).toSorted()).toEqual([ + 'charging', + 'chargingTime', + 'dischargingTime', + 'fetched', + 'isSupported', + 'level', + ]); + }); + + it('should return isSupported: true when API is available', async () => { + const {result} = await renderHook(() => useBattery()); + const value = expectResultValue(result); + expect(value.isSupported).toBe(true); + }); + + it('should fetch battery state when API is supported', async () => { + const {result} = await renderHook(() => useBattery()); + + await flushBattery(); + + const value = expectResultValue(result); + expect(value.fetched).toBe(true); + expect(value.charging).toBe(true); + expect(value.chargingTime).toBe(3600); + expect(value.dischargingTime).toBe(Infinity); + expect(value.level).toBe(0.75); + }); + + it('should subscribe to battery events', async () => { + await renderHook(() => useBattery()); + + await flushBattery(); + + expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function)); + expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingtimechange', expect.any(Function)); + expect(mockBattery.addEventListener).toHaveBeenCalledWith('dischargingtimechange', expect.any(Function)); + expect(mockBattery.addEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function)); + }); + + it('should unsubscribe the very handlers it registered on unmount', async () => { + const {unmount} = await renderHook(() => useBattery()); + + await flushBattery(); + + // Compared by reference, so removing a different function than the one + // registered -- a listener leak -- fails here. + const registered = [...mockBattery.addEventListener.mock.calls]; + expect(registered).toHaveLength(4); + + await unmount(); + + expect(mockBattery.removeEventListener.mock.calls).toEqual(registered); + }); + + it('should update state when battery events fire', async () => { + const {result} = await renderHook(() => useBattery()); + + await flushBattery(); + + let value = expectResultValue(result); + expect(value.level).toBe(0.75); + + // Simulate battery level change + mockBattery.level = 0.5; + + const [, levelChangeHandler] = expectCallArgs(mockBattery.addEventListener, 3); + + await act(async () => { + levelChangeHandler(); + }); + + value = expectResultValue(result); + expect(value.level).toBe(0.5); + }); + + it('should not subscribe when unmounted before getBattery() resolves', async () => { + let resolveBattery: (battery: BatteryManagerMock) => void = () => undefined; + + getBatteryMock.mockImplementation( + async () => + new Promise((resolve) => { + resolveBattery = resolve; + }), + ); + + const {unmount} = await renderHook(() => useBattery()); + await unmount(); + + resolveBattery(mockBattery); + await flushBattery(); + + expect(mockBattery.addEventListener).not.toHaveBeenCalled(); + }); + + it('should report unfetched state when getBattery() rejects', async () => { + getBatteryMock.mockImplementation(async () => { + throw new Error('Battery API blocked by permissions policy'); + }); + + const {result} = await renderHook(() => useBattery()); + + await flushBattery(); + + const value = expectResultValue(result); + expect(value.fetched).toBe(false); + expect(value.isSupported).toBe(true); + expect(value.charging).toBeUndefined(); + expect(value.level).toBeUndefined(); + expect(mockBattery.addEventListener).not.toHaveBeenCalled(); + }); +}); diff --git a/src/useBattery/index.ssr.test.ts b/src/useBattery/index.ssr.test.ts new file mode 100644 index 00000000..05d063ed --- /dev/null +++ b/src/useBattery/index.ssr.test.ts @@ -0,0 +1,35 @@ +import {renderHookServer as renderHook} from '@ver0/react-hooks-testing'; +import {describe, expect, it} from 'vitest'; +import {useBattery} from '../index.js'; +import {expectResultValue} from '../util/testing/test-helpers.js'; + +describe('useBattery', () => { + it('should be defined', () => { + expect(useBattery).toBeDefined(); + }); + + it('should render', async () => { + const {result} = await renderHook(() => useBattery()); + expectResultValue(result); + }); + + it('should return isSupported as false in SSR', async () => { + const {result} = await renderHook(() => useBattery()); + expect(expectResultValue(result).isSupported).toBe(false); + }); + + it('should return fetched as false in SSR', async () => { + const {result} = await renderHook(() => useBattery()); + expect(expectResultValue(result).fetched).toBe(false); + }); + + it('should return undefined values in SSR', async () => { + const {result} = await renderHook(() => useBattery()); + const value = expectResultValue(result); + + expect(value.charging).toBeUndefined(); + expect(value.chargingTime).toBeUndefined(); + expect(value.dischargingTime).toBeUndefined(); + expect(value.level).toBeUndefined(); + }); +}); diff --git a/src/useBattery/index.ts b/src/useBattery/index.ts new file mode 100644 index 00000000..a0ccdc14 --- /dev/null +++ b/src/useBattery/index.ts @@ -0,0 +1,156 @@ +import {useEffect, useState} from 'react'; +import {isBrowser} from '../util/const.js'; +import {off, on} from '../util/misc.js'; + +export type UseBatteryState = { + /** + * Whether the Battery Status API is supported by the browser. + */ + isSupported: boolean; + /** + * Whether the battery state has been fetched. + */ + fetched: boolean; + /** + * Whether the battery is currently being charged. + */ + charging: boolean | undefined; + /** + * Time in seconds until the battery is fully charged, or Infinity if not charging. + */ + chargingTime: number | undefined; + /** + * Time in seconds until the battery is fully discharged, or Infinity if charging. + */ + dischargingTime: number | undefined; + /** + * Battery charge level between 0 and 1. + */ + level: number | undefined; +}; + +type BatteryManager = { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; +} & EventTarget; + +type NavigatorWithBattery = Navigator & { + getBattery?: () => Promise; +}; + +const BATTERY_EVENTS = ['chargingchange', 'chargingtimechange', 'dischargingtimechange', 'levelchange'] as const; + +const nav = isBrowser ? (globalThis.navigator as NavigatorWithBattery) : undefined; +const isSupported = Boolean(nav?.getBattery); + +function getBatteryState(battery: BatteryManager | null): UseBatteryState { + if (!battery) { + return { + isSupported, + fetched: false, + charging: undefined, + chargingTime: undefined, + dischargingTime: undefined, + level: undefined, + }; + } + + return { + isSupported, + fetched: true, + charging: battery.charging, + chargingTime: battery.chargingTime, + dischargingTime: battery.dischargingTime, + level: battery.level, + }; +} + +/** + * Tracks the state of device's battery. + * + * @returns An object containing the battery state and whether the API is supported. + * + * @example + * const { isSupported, level, charging } = useBattery(); + * + * if (!isSupported) { + * return

Battery API not supported

; + * } + * + * return ( + *

+ * Battery level: {level === undefined ? 'Unknown' : `${Math.round(level * 100)}%`} + * {charging && ' (Charging)'} + *

+ * ); + */ +export function useBattery(): UseBatteryState { + const [state, setState] = useState(() => getBatteryState(null)); + + useEffect(() => { + // Not covered by the DOM suite: `nav` is resolved once at module scope (as in + // useNetworkState), so the mocked `getBattery` is always present by the time a + // test runs. The unsupported path is exercised by the SSR suite instead. + if (!nav?.getBattery) { + return undefined; + } + + const {getBattery} = nav; + + let battery: BatteryManager | null = null; + let mounted = true; + + const handleChange = () => { + if (battery && mounted) { + setState(getBatteryState(battery)); + } + }; + + const subscribe = async (): Promise => { + try { + const current = await getBattery.call(nav); + + // The effect may have been cleaned up while getBattery() was pending; + // subscribing then would leak listeners nothing will ever remove. + if (!mounted) { + return; + } + + battery = current; + setState(getBatteryState(current)); + + for (const event of BATTERY_EVENTS) { + on(current, event, handleChange); + } + } catch (error: unknown) { + // Some browsers reject when the API is disabled by policy; report the + // state as unfetched rather than leaving the rejection unhandled. + // The warning itself stays uncovered: NODE_ENV is 'test' under vitest. + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.error('Failed to get battery status:', error); + } + + if (mounted) { + setState(getBatteryState(null)); + } + } + }; + + void subscribe(); + + return () => { + mounted = false; + + if (battery) { + for (const event of BATTERY_EVENTS) { + off(battery, event, handleChange); + } + } + }; + }, []); + + return state; +} diff --git a/src/util/testing/setup/battery.test.ts b/src/util/testing/setup/battery.test.ts new file mode 100644 index 00000000..c703a46e --- /dev/null +++ b/src/util/testing/setup/battery.test.ts @@ -0,0 +1,49 @@ +import {vi} from 'vitest'; + +type ListenerMock = ReturnType void) => void>>; + +export type BatteryManagerMock = { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; + addEventListener: ListenerMock; + removeEventListener: ListenerMock; +}; + +const initialReadings = { + charging: true, + chargingTime: 3600, + dischargingTime: Infinity, + level: 0.75, +}; + +export const mockBattery: BatteryManagerMock = { + ...initialReadings, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}; + +export const getBatteryMock: ReturnType Promise>> = vi.fn( + async () => mockBattery, +); + +/** + * Restores the mock battery readings and clears every recorded call. + * + * The battery manager is a single object shared by all `getBattery()` calls, so + * a test that mutates a reading would otherwise leak it into later tests. + */ +export function resetBatteryMock(): void { + Object.assign(mockBattery, initialReadings); + mockBattery.addEventListener.mockClear(); + mockBattery.removeEventListener.mockClear(); + getBatteryMock.mockClear(); + getBatteryMock.mockImplementation(async () => mockBattery); +} + +Object.defineProperty(globalThis.navigator, 'getBattery', { + value: getBatteryMock, + writable: true, + configurable: true, +}); diff --git a/vite.config.ts b/vite.config.ts index 09bc0db4..e9e93fbf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,11 @@ const repoOverrides: OxlintConfig = { export default defineConfig({ test: { dir: './src', - setupFiles: ['./src/util/testing/setup/react-hooks.test.ts', './src/util/testing/setup/vibrate.test.ts'], + setupFiles: [ + './src/util/testing/setup/react-hooks.test.ts', + './src/util/testing/setup/vibrate.test.ts', + './src/util/testing/setup/battery.test.ts', + ], passWithNoTests: true, // Node >= 25 ships Web Storage globals; without --localstorage-file // they are non-functional stubs that shadow jsdom's storage in workers.