Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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();
});
});
128 changes: 128 additions & 0 deletions src/useBattery/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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 is currently being fetched.
Comment thread
samithahansaka marked this conversation as resolved.
Outdated
*/
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;

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

// eslint-disable-next-line @typescript-eslint/no-floating-promises,promise/catch-or-return,promise/prefer-await-to-then,promise/always-return
nav.getBattery().then((b) => {
battery = b;
setState(getBatteryState(battery));

on(battery, 'chargingchange', handleChange);
on(battery, 'chargingtimechange', handleChange);
on(battery, 'dischargingtimechange', handleChange);
on(battery, 'levelchange', handleChange);
});
Comment thread
samithahansaka marked this conversation as resolved.
Outdated
Comment thread
samithahansaka marked this conversation as resolved.
Outdated

return () => {
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
Loading