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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
133 changes: 133 additions & 0 deletions src/useBattery/index.dom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {act, renderHook} from '@ver0/react-hooks-testing';
import type {vi} from 'vitest';
import {describe, expect, it, beforeEach} from 'vitest';
import {useBattery} from '../index.js';
import {expectResultValue} from '../util/testing/test-helpers.js';

type MockFn = ReturnType<typeof vi.fn>;

type BatteryManager = {
charging: boolean;
chargingTime: number;
dischargingTime: number;
level: number;
addEventListener: MockFn;
removeEventListener: MockFn;
};

describe('useBattery', () => {
// Use the global getBattery mock that's already set up in the test environment
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const getBatteryMock = globalThis.navigator.getBattery as MockFn;

// Access the mock battery object from the getBattery mock
let mockBattery: BatteryManager;

beforeEach(async () => {
getBatteryMock.mockClear();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mockBattery = await getBatteryMock();
mockBattery.addEventListener.mockClear();
mockBattery.removeEventListener.mockClear();
});

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(typeof value).toBe('object');
expect(Object.keys(value)).toEqual([
'isSupported',
'fetched',
'charging',
'chargingTime',
'dischargingTime',
'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 act(async () => {
await Promise.resolve();
});

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 act(async () => {
await Promise.resolve();
});

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 from battery events on unmount', async () => {
const {unmount} = await renderHook(() => useBattery());

await act(async () => {
await Promise.resolve();
});

await unmount();

expect(mockBattery.removeEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function));
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('chargingtimechange', expect.any(Function));
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('dischargingtimechange', expect.any(Function));
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function));
});

it('should update state when battery events fire', async () => {
const {result} = await renderHook(() => useBattery());

await act(async () => {
await Promise.resolve();
});

let value = expectResultValue(result);
expect(value.level).toBe(0.75);

// Simulate battery level change
mockBattery.level = 0.5;

// Get the handler that was registered for levelchange
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const levelChangeHandler = mockBattery.addEventListener.mock.calls.find(
(call) => call[0] === 'levelchange',
)?.[1] as () => void;

await act(async () => {
levelChangeHandler();
});

value = expectResultValue(result);
expect(value.level).toBe(0.5);
});
});
32 changes: 32 additions & 0 deletions src/useBattery/index.ssr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {renderHookServer as renderHook} from '@ver0/react-hooks-testing';
import {describe, expect, it} from 'vitest';
import {useBattery} from '../index.js';

describe('useBattery', () => {
it('should be defined', () => {
expect(useBattery).toBeDefined();
});

it('should render', async () => {
const {result} = await renderHook(() => useBattery());
expect(result.error).toBeUndefined();
});

it('should return isSupported as false in SSR', async () => {
const {result} = await renderHook(() => useBattery());
expect(result.value?.isSupported).toBe(false);
});

it('should return fetched as false in SSR', async () => {
const {result} = await renderHook(() => useBattery());
expect(result.value?.fetched).toBe(false);
});

it('should return undefined values in SSR', async () => {
const {result} = await renderHook(() => useBattery());
expect(result.value?.charging).toBeUndefined();
expect(result.value?.chargingTime).toBeUndefined();
expect(result.value?.dischargingTime).toBeUndefined();
expect(result.value?.level).toBeUndefined();
});
});
146 changes: 146 additions & 0 deletions src/useBattery/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {useEffect, useState} from 'react';
import {isBrowser} from '../util/const.js';
import {off, on} from '../util/misc.js';

export type BatteryState = {
/**
* 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;
};

export type UseBatteryState = BatteryState & {
/**
* Whether the Battery Status API is supported by the browser.
*/
isSupported: boolean;
/**
* Whether the battery state has been fetched.
*/
fetched: boolean;
};

type BatteryManager = {
charging: boolean;
chargingTime: number;
dischargingTime: number;
level: number;
} & EventTarget;

type NavigatorWithBattery = Navigator & {
getBattery?: () => Promise<BatteryManager>;
};

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 <p>Battery API not supported</p>;
* }
*
* return (
* <p>
* Battery level: {level ? `${Math.round(level * 100)}%` : 'Unknown'}
* {charging && ' (Charging)'}
* </p>
* );
*/
export function useBattery(): UseBatteryState {
const [state, setState] = useState<UseBatteryState>(() => getBatteryState(null));

useEffect(() => {
if (!isSupported || !nav?.getBattery) {
return;
}

let battery: BatteryManager | null = null;
let mounted = true;

const handleChange = () => {
if (battery && mounted) {
setState(getBatteryState(battery));
}
};

// eslint-disable-next-line @typescript-eslint/no-floating-promises,promise/prefer-await-to-then,promise/always-return
nav
.getBattery()
.then((b) => {
if (!mounted) {
return;
}

battery = b;
setState(getBatteryState(battery));

on(battery, 'chargingchange', handleChange);
on(battery, 'chargingtimechange', handleChange);
on(battery, 'dischargingtimechange', handleChange);
on(battery, 'levelchange', handleChange);
})
.catch((error) => {
// Gracefully handle getBattery() rejections to avoid unhandled promise rejections
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.error('Failed to get battery status:', error);
}
if (mounted) {
setState(getBatteryState(null));
}
});

return () => {
mounted = false;
if (battery) {
off(battery, 'chargingchange', handleChange);
off(battery, 'chargingtimechange', handleChange);
off(battery, 'dischargingtimechange', handleChange);
off(battery, 'levelchange', handleChange);
}
};
}, []);

return state;
}
27 changes: 27 additions & 0 deletions src/util/testing/setup/battery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {vi} from 'vitest';

type BatteryManager = {
charging: boolean;
chargingTime: number;
dischargingTime: number;
level: number;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
};

const mockBattery: BatteryManager = {
charging: true,
chargingTime: 3600,
dischargingTime: Infinity,
level: 0.75,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};

const getBatteryMock = vi.fn<() => Promise<BatteryManager>>(async () => mockBattery);

Object.defineProperty(globalThis.navigator, 'getBattery', {
value: getBatteryMock,
writable: true,
configurable: true,
});
6 changes: 5 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import {defineConfig} from 'vitest/config';
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,
projects: [
{
Expand Down