1
1
mirror of https://github.com/pterodactyl/panel.git synced 2024-11-22 09:02:28 +01:00

Some code cleanup, add jest coverage and begin using it for utility functions

This commit is contained in:
DaneEveritt 2022-06-26 14:34:09 -04:00
parent ca39830333
commit 1eb3ea2ee4
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
29 changed files with 2044 additions and 134 deletions

View File

@ -25,6 +25,7 @@ extends:
- "standard"
- "plugin:react/recommended"
- "plugin:@typescript-eslint/recommended"
- "plugin:jest-dom/recommended"
rules:
quotes:
- warn

View File

@ -1,14 +1,6 @@
module.exports = {
presets: [
'@babel/typescript',
['@babel/env', {
modules: false,
useBuiltIns: 'entry',
corejs: 3,
}],
'@babel/react',
],
plugins: [
module.exports = function (api) {
let targets = {};
const plugins = [
'babel-plugin-macros',
'styled-components',
'react-hot-loader/babel',
@ -19,5 +11,24 @@ module.exports = {
'@babel/proposal-optional-chaining',
'@babel/proposal-nullish-coalescing-operator',
'@babel/syntax-dynamic-import',
],
];
if (api.env('test')) {
targets = { node: 'current' };
plugins.push('@babel/transform-modules-commonjs');
}
return {
plugins,
presets: [
'@babel/typescript',
['@babel/env', {
modules: false,
useBuiltIns: 'entry',
corejs: 3,
targets,
}],
'@babel/react',
]
};
};

28
jest.config.js Normal file
View File

@ -0,0 +1,28 @@
const { pathsToModuleNameMapper } = require('ts-jest');
const { compilerOptions } = require('./tsconfig');
/** @type {import('ts-jest').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
globals: {
'ts-jest': {
isolatedModules: true,
},
},
moduleFileExtensions: ['js', 'ts', 'tsx', 'd.ts', 'json', 'node'],
moduleNameMapper: {
'\\.(jpe?g|png|gif|svg)$': '<rootDir>/resources/scripts/__mocks__/file.ts',
'\\.(s?css|less)$': 'identity-obj-proxy',
...pathsToModuleNameMapper(compilerOptions.paths, {
prefix: '<rootDir>/',
}),
},
setupFilesAfterEnv: [
'<rootDir>/resources/scripts/setup-tests.ts',
],
transform: {
'.*\\.[t|j]sx$': 'babel-jest',
'.*\\.ts$': 'ts-jest',
},
testPathIgnorePatterns: ['/node_modules/'],
};

View File

@ -60,16 +60,22 @@
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-modules-commonjs": "^7.18.2",
"@babel/plugin-transform-react-jsx": "^7.12.1",
"@babel/plugin-transform-runtime": "^7.12.1",
"@babel/preset-env": "^7.12.1",
"@babel/preset-react": "^7.12.1",
"@babel/preset-typescript": "^7.12.1",
"@babel/runtime": "^7.12.1",
"@testing-library/dom": "^8.14.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "^14.2.1",
"@types/chart.js": "^2.8.5",
"@types/codemirror": "^0.0.98",
"@types/debounce": "^1.2.0",
"@types/events": "^3.0.0",
"@types/jest": "^28.1.3",
"@types/node": "^14.11.10",
"@types/qrcode.react": "^1.0.1",
"@types/query-string": "^6.3.0",
@ -88,6 +94,7 @@
"@typescript-eslint/eslint-plugin": "^4.25.0",
"@typescript-eslint/parser": "^4.25.0",
"autoprefixer": "^10.4.7",
"babel-jest": "^28.1.1",
"babel-loader": "^8.2.5",
"babel-plugin-styled-components": "^2.0.7",
"cross-env": "^7.0.2",
@ -95,11 +102,14 @@
"eslint": "^7.27.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-import": "^2.23.3",
"eslint-plugin-jest-dom": "^4.0.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.2.0",
"fork-ts-checker-webpack-plugin": "^6.2.10",
"identity-obj-proxy": "^3.0.0",
"jest": "^28.1.1",
"postcss": "^8.4.14",
"postcss-import": "^14.1.0",
"postcss-loader": "^4.0.0",
@ -111,6 +121,7 @@
"svg-url-loader": "^7.1.1",
"terser-webpack-plugin": "^4.2.3",
"ts-essentials": "^9.1.2",
"ts-jest": "^28.0.5",
"twin.macro": "^2.8.2",
"typescript": "^4.7.3",
"webpack": "^4.43.0",
@ -122,6 +133,7 @@
},
"scripts": {
"clean": "cd public/assets && find . \\( -name \"*.js\" -o -name \"*.map\" \\) -type f -delete",
"test": "jest",
"lint": "eslint ./resources/scripts/**/*.{ts,tsx} --ext .ts,.tsx",
"watch": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --watch --progress",
"build": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --progress",

View File

@ -0,0 +1 @@
module.exports = 'test-file-stub';

View File

@ -4,7 +4,7 @@ import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome
import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer';
import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToHuman, formatIp, megabytesToHuman } from '@/helpers';
import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
import Spinner from '@/components/elements/Spinner';
@ -74,8 +74,8 @@ export default ({ server, className }: { server: Server; className?: string }) =
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
}
const diskLimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited';
const memoryLimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited';
const diskLimit = server.limits.disk !== 0 ? bytesToString(mbToBytes(server.limits.disk)) : 'Unlimited';
const memoryLimit = server.limits.memory !== 0 ? bytesToString(mbToBytes(server.limits.memory)) : 'Unlimited';
const cpuLimit = server.limits.cpu !== 0 ? server.limits.cpu + ' %' : 'Unlimited';
return (
@ -98,7 +98,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
{
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<React.Fragment key={allocation.ip + allocation.port.toString()}>
{allocation.alias || formatIp(allocation.ip)}:{allocation.port}
{allocation.alias || ip(allocation.ip)}:{allocation.port}
</React.Fragment>
))
}
@ -146,7 +146,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
<div css={tw`flex justify-center`}>
<Icon icon={faMemory} $alarm={alarms.memory}/>
<IconDescription $alarm={alarms.memory}>
{bytesToHuman(stats.memoryUsageInBytes)}
{bytesToString(stats.memoryUsageInBytes)}
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memoryLimit}</p>
@ -155,7 +155,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
<div css={tw`flex justify-center`}>
<Icon icon={faHdd} $alarm={alarms.disk}/>
<IconDescription $alarm={alarms.disk}>
{bytesToHuman(stats.diskUsageInBytes)}
{bytesToString(stats.diskUsageInBytes)}
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p>

View File

@ -13,7 +13,8 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Input from '@/components/elements/Input';
import { formatIp } from '@/helpers';
import { ip } from '@/lib/formatters';
type Props = RequiredModalProps;
interface Values {
@ -109,7 +110,7 @@ export default ({ ...props }: Props) => {
<p css={tw`mt-1 text-xs text-neutral-400`}>
{
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || formatIp(allocation.ip)}:{allocation.port}</span>
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || ip(allocation.ip)}:{allocation.port}</span>
))
}
</p>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { FormikErrors, FormikTouched } from 'formik';
import tw from 'twin.macro';
import { capitalize } from '@/helpers';
import { capitalize } from '@/lib/strings';
interface Props {
errors: FormikErrors<any>;

View File

@ -8,7 +8,7 @@ import ActivityLogMetaButton from '@/components/elements/activity/ActivityLogMet
import { TerminalIcon } from '@heroicons/react/solid';
import classNames from 'classnames';
import style from './style.module.css';
import { isObject } from '@/helpers';
import { isObject } from '@/lib/objects';
import Avatar from '@/components/Avatar';
import useLocationHash from '@/plugins/useLocationHash';

View File

@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive, faEllipsisH, faLock } from '@fortawesome/free-solid-svg-icons';
import { format, formatDistanceToNow } from 'date-fns';
import Spinner from '@/components/elements/Spinner';
import { bytesToHuman } from '@/helpers';
import { bytesToString } from '@/lib/formatters';
import Can from '@/components/elements/Can';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
@ -64,7 +64,7 @@ export default ({ backup, className }: Props) => {
{backup.name}
</p>
{(backup.completedAt !== null && backup.isSuccessful) &&
<span css={tw`ml-3 text-neutral-300 text-xs font-extralight hidden sm:inline`}>{bytesToHuman(backup.bytes)}</span>
<span css={tw`ml-3 text-neutral-300 text-xs font-extralight hidden sm:inline`}>{bytesToString(backup.bytes)}</span>
}
</div>
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>

View File

@ -8,7 +8,7 @@ import {
faMicrochip,
faWifi,
} from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, formatIp, megabytesToHuman } from '@/helpers';
import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
import { ServerContext } from '@/state/server';
import { SocketEvent, SocketRequest } from '@/components/server/events';
import UptimeDuration from '@/components/server/UptimeDuration';
@ -41,7 +41,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
const allocation = ServerContext.useStoreState(state => {
const match = state.server.data!.allocations.find(allocation => allocation.isDefault);
return !match ? 'n/a' : `${match.alias || formatIp(match.ip)}:${match.port}`;
return !match ? 'n/a' : `${match.alias || ip(match.ip)}:${match.port}`;
});
useEffect(() => {
@ -106,14 +106,14 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
title={'Memory'}
color={getBackgroundColor(stats.memory / 1024, limits.memory * 1024)}
description={limits.memory
? `This server is allowed to use up to ${megabytesToHuman(limits.memory)} of memory.`
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.memory))} of memory.`
: 'No memory limit has been configured for this server.'
}
>
{status === 'offline' ?
<span className={'text-gray-400'}>Offline</span>
:
bytesToHuman(stats.memory)
bytesToString(stats.memory)
}
</StatBlock>
<StatBlock
@ -121,11 +121,11 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
title={'Disk'}
color={getBackgroundColor(stats.disk / 1024, limits.disk * 1024)}
description={limits.disk
? `This server is allowed to use up to ${megabytesToHuman(limits.disk)} of disk space.`
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.disk))} of disk space.`
: 'No disk space limit has been configured for this server.'
}
>
{bytesToHuman(stats.disk)}
{bytesToString(stats.disk)}
</StatBlock>
<StatBlock
icon={faCloudDownloadAlt}
@ -135,7 +135,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
{status === 'offline' ?
<span className={'text-gray-400'}>Offline</span>
:
bytesToHuman(stats.tx)
bytesToString(stats.tx)
}
</StatBlock>
<StatBlock
@ -146,7 +146,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
{status === 'offline' ?
<span className={'text-gray-400'}>Offline</span>
:
bytesToHuman(stats.rx)
bytesToString(stats.rx)
}
</StatBlock>
</div>

View File

@ -4,7 +4,8 @@ import { SocketEvent } from '@/components/server/events';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { Line } from 'react-chartjs-2';
import { useChart, useChartTickLabel } from '@/components/server/console/chart';
import { bytesToHuman, toRGBA } from '@/helpers';
import { hexToRgba } from '@/lib/helpers';
import { bytesToString } from '@/lib/formatters';
import { CloudDownloadIcon, CloudUploadIcon } from '@heroicons/react/solid';
import { theme } from 'twin.macro';
import ChartBlock from '@/components/server/console/ChartBlock';
@ -24,7 +25,7 @@ export default () => {
y: {
ticks: {
callback (value) {
return bytesToHuman(typeof value === 'string' ? parseInt(value, 10) : value);
return bytesToString(typeof value === 'string' ? parseInt(value, 10) : value);
},
},
},
@ -35,7 +36,7 @@ export default () => {
...opts,
label: !index ? 'Network In' : 'Network Out',
borderColor: !index ? theme('colors.cyan.400') : theme('colors.yellow.400'),
backgroundColor: toRGBA(!index ? theme('colors.cyan.700') : theme('colors.yellow.700'), 0.5),
backgroundColor: hexToRgba(!index ? theme('colors.cyan.700') : theme('colors.yellow.700'), 0.5),
};
},
});

View File

@ -12,7 +12,7 @@ import { DeepPartial } from 'ts-essentials';
import { useState } from 'react';
import { deepmerge, deepmergeCustom } from 'deepmerge-ts';
import { theme } from 'twin.macro';
import { toRGBA } from '@/helpers';
import { hexToRgba } from '@/lib/helpers';
ChartJS.register(LineElement, PointElement, Filler, LinearScale);
@ -86,7 +86,7 @@ function getEmptyData (label: string, sets = 1, callback?: ChartDatasetCallback
label,
data: Array(20).fill(0),
borderColor: theme('colors.cyan.400'),
backgroundColor: toRGBA(theme('colors.cyan.700'), 0.5),
backgroundColor: hexToRgba(theme('colors.cyan.700'), 0.5),
}, index)),
};
}

View File

@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import features from './index';
import { getObjectKeys } from '@/helpers';
import { getObjectKeys } from '@/lib/objects';
type ListItems = [ string, React.ComponentType ][];

View File

@ -1,6 +1,6 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileAlt, faFileArchive, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, encodePathSegments } from '@/helpers';
import { encodePathSegments } from '@/helpers';
import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
import React, { memo } from 'react';
import { FileObject } from '@/api/server/files/loadDirectory';
@ -13,6 +13,7 @@ import styled from 'styled-components/macro';
import SelectFileCheckbox from '@/components/server/files/SelectFileCheckbox';
import { usePermissions } from '@/plugins/usePermissions';
import { join } from 'path';
import { bytesToString } from '@/lib/formatters';
const Row = styled.div`
${tw`flex bg-neutral-700 rounded-sm mb-px text-sm hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600`};
@ -61,7 +62,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => (
</div>
{file.isFile &&
<div css={tw`w-1/6 text-right mr-4 hidden sm:block`}>
{bytesToHuman(file.size)}
{bytesToString(file.size)}
</div>
}
<div

View File

@ -18,7 +18,7 @@ import CopyOnClick from '@/components/elements/CopyOnClick';
import DeleteAllocationButton from '@/components/server/network/DeleteAllocationButton';
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
import getServerAllocations from '@/api/swr/getServerAllocations';
import { formatIp } from '@/helpers';
import { ip } from '@/lib/formatters';
import Code from '@/components/elements/Code';
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
@ -67,7 +67,7 @@ const AllocationRow = ({ allocation }: Props) => {
<div className={'mr-4 flex-1 md:w-40'}>
{allocation.alias ?
<CopyOnClick text={allocation.alias}><Code dark className={'w-40 truncate'}>{allocation.alias}</Code></CopyOnClick> :
<CopyOnClick text={formatIp(allocation.ip)}><Code dark>{formatIp(allocation.ip)}</Code></CopyOnClick>}
<CopyOnClick text={ip(allocation.ip)}><Code dark>{ip(allocation.ip)}</Code></CopyOnClick>}
<Label>{allocation.alias ? 'Hostname' : 'IP Address'}</Label>
</div>
<div className={'w-16 md:w-24 overflow-hidden'}>

View File

@ -12,7 +12,7 @@ import Label from '@/components/elements/Label';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import isEqual from 'react-fast-compare';
import CopyOnClick from '@/components/elements/CopyOnClick';
import { formatIp } from '@/helpers';
import { ip } from '@/lib/formatters';
import { Button } from '@/components/elements/button/index';
export default () => {
@ -31,10 +31,10 @@ export default () => {
<TitledGreyBox title={'SFTP Details'} css={tw`mb-6 md:mb-10`}>
<div>
<Label>Server Address</Label>
<CopyOnClick text={`sftp://${formatIp(sftp.ip)}:${sftp.port}`}>
<CopyOnClick text={`sftp://${ip(sftp.ip)}:${sftp.port}`}>
<Input
type={'text'}
value={`sftp://${formatIp(sftp.ip)}:${sftp.port}`}
value={`sftp://${ip(sftp.ip)}:${sftp.port}`}
readOnly
/>
</CopyOnClick>
@ -58,7 +58,7 @@ export default () => {
</div>
</div>
<div css={tw`ml-4`}>
<a href={`sftp://${username}.${id}@${formatIp(sftp.ip)}:${sftp.port}`}>
<a href={`sftp://${username}.${id}@${ip(sftp.ip)}:${sftp.port}`}>
<Button.Text variant={Button.Variants.Secondary}>Launch SFTP</Button.Text>
</a>
</div>

View File

@ -1,24 +1,7 @@
export const megabytesToBytes = (mb: number) => Math.floor(mb * 1024 * 1024);
export function bytesToHuman (bytes: number): string {
if (bytes < 1) {
return '0 Bytes';
}
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Number((bytes / Math.pow(1024, i)).toFixed(2))} ${[ 'Bytes', 'kB', 'MB', 'GB', 'TB' ][i]}`;
}
export function megabytesToHuman (mb: number): string {
return bytesToHuman(megabytesToBytes(mb));
}
export const randomInt = (low: number, high: number) => Math.floor(Math.random() * (high - low) + low);
export const cleanDirectoryPath = (path: string) => path.replace(/(\/(\/*))|(^$)/g, '/');
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
export function fileBitsToString (mode: string, directory: boolean): string {
const m = parseInt(mode, 8);
@ -61,23 +44,3 @@ export function encodePathSegments (path: string): string {
export function hashToPath (hash: string): string {
return hash.length > 0 ? decodeURIComponent(hash.substr(1)) : '/';
}
export function formatIp (ip: string): string {
return /([a-f0-9:]+:+)+[a-f0-9]+/.test(ip) ? `[${ip}]` : ip;
}
// eslint-disable-next-line @typescript-eslint/ban-types
export const isObject = (o: unknown): o is {} => typeof o === 'object' && o !== null;
// eslint-disable-next-line @typescript-eslint/ban-types
export const isEmptyObject = (o: {}): boolean =>
Object.keys(o).length === 0 && Object.getPrototypeOf(o) === Object.prototype;
// eslint-disable-next-line @typescript-eslint/ban-types
export const getObjectKeys = <T extends {}> (o: T): (keyof T)[] => Object.keys(o) as (keyof typeof o)[];
export const toRGBA = (hex: string, alpha = 1): string => {
const [ r, g, b ] = hex.match(/\w\w/g)!.map(v => parseInt(v, 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};

View File

@ -0,0 +1,56 @@
import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
describe('@/lib/formatters.ts', function () {
describe('mbToBytes()', function () {
it('should convert from MB to Bytes', function () {
expect(mbToBytes(1)).toBe(1_000_000);
expect(mbToBytes(0)).toBe(0);
expect(mbToBytes(0.1)).toBe(100_000);
expect(mbToBytes(0.001)).toBe(1000);
expect(mbToBytes(1024)).toBe(1_024_000_000);
});
});
describe('bytesToString()', function () {
it.each([
[ 0, '0 Bytes' ],
[ 0.5, '0 Bytes' ],
[ 0.9, '0 Bytes' ],
[ 100, '100 Bytes' ],
[ 100.25, '100.25 Bytes' ],
[ 100.998, '101 Bytes' ],
[ 512, '512 Bytes' ],
[ 1000, '1 KB' ],
[ 1024, '1.02 KB' ],
[ 5068, '5.07 KB' ],
[ 10_000, '10 KB' ],
[ 11_864, '11.86 KB' ],
[ 1_000_000, '1 MB' ],
[ 1_356_000, '1.36 MB' ],
[ 1_024_000, '1.02 MB' ],
[ 1_000_000_000, '1 GB' ],
[ 1_024_000_000, '1.02 GB' ],
[ 1_678_342_000, '1.68 GB' ],
[ 1_000_000_000_000, '1 TB' ],
])('should format %d bytes as "%s"', function (input, output) {
expect(bytesToString(input)).toBe(output);
});
});
describe('ip()', function () {
it('should format an IPv4 address', function () {
expect(ip('127.0.0.1')).toBe('127.0.0.1');
});
it('should format an IPv6 address', function () {
expect(ip(':::1')).toBe('[:::1]');
expect(ip('2001:db8::')).toBe('[2001:db8::]');
});
it('should handle random inputs', function () {
expect(ip('1')).toBe('1');
expect(ip('foobar')).toBe('foobar');
expect(ip('127.0.0.1:25565')).toBe('[127.0.0.1:25565]');
});
});
});

View File

@ -0,0 +1,35 @@
const _CONVERSION_UNIT = 1000;
/**
* Given a value in megabytes converts it back down into bytes.
*/
function mbToBytes (megabytes: number): number {
return Math.floor(megabytes * _CONVERSION_UNIT * _CONVERSION_UNIT);
}
/**
* Given an amount of bytes, converts them into a human readable string format
* using "1000" as the divisor.
*/
function bytesToString (bytes: number): string {
if (bytes < 1) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(_CONVERSION_UNIT));
const value = Number((bytes / Math.pow(_CONVERSION_UNIT, i)).toFixed(2));
return `${value} ${[ 'Bytes', 'KB', 'MB', 'GB', 'TB' ][i]}`;
}
/**
* Formats an IPv4 or IPv6 address.
*/
function ip (value: string): string {
// noinspection RegExpSimplifiable
return /([a-f0-9:]+:+)+[a-f0-9]+/.test(value) ? `[${value}]` : value;
}
export {
ip,
mbToBytes,
bytesToString,
};

View File

@ -0,0 +1,29 @@
import { hexToRgba } from '@/lib/helpers';
describe('@/lib/helpers.ts', function () {
describe('hexToRgba()', function () {
it('should return the expected rgba', function () {
expect(hexToRgba('#ffffff')).toBe('rgba(255, 255, 255, 1)');
expect(hexToRgba('#00aabb')).toBe('rgba(0, 170, 187, 1)');
expect(hexToRgba('#efefef')).toBe('rgba(239, 239, 239, 1)');
});
it('should ignore case', function () {
expect(hexToRgba('#FF00A3')).toBe('rgba(255, 0, 163, 1)');
});
it('should allow alpha channel changes', function () {
expect(hexToRgba('#ece5a8', 0.5)).toBe('rgba(236, 229, 168, 0.5)');
expect(hexToRgba('#ece5a8', 0.1)).toBe('rgba(236, 229, 168, 0.1)');
expect(hexToRgba('#000000', 0)).toBe('rgba(0, 0, 0, 0)');
});
it('should handle invalid strings', function () {
expect(hexToRgba('')).toBe('');
expect(hexToRgba('foobar')).toBe('foobar');
expect(hexToRgba('#fff')).toBe('#fff');
expect(hexToRgba('#')).toBe('#');
expect(hexToRgba('#fffffy')).toBe('#fffffy');
});
});
});

View File

@ -0,0 +1,17 @@
/**
* Given a valid six character HEX color code, converts it into its associated
* RGBA value with a user controllable alpha channel.
*/
function hexToRgba (hex: string, alpha = 1): string {
// noinspection RegExpSimplifiable
if (!/#?([a-fA-F0-9]{2}){3}/.test(hex)) {
return hex;
}
// noinspection RegExpSimplifiable
const [ r, g, b ] = hex.match(/[a-fA-F0-9]{2}/g)!.map(v => parseInt(v, 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export { hexToRgba };

View File

@ -0,0 +1,30 @@
import { isObject } from '@/lib/objects';
describe('@/lib/objects.ts', function () {
describe('isObject()', function () {
it('should return true for objects', function () {
expect(isObject({})).toBe(true);
expect(isObject({ foo: 123 })).toBe(true);
expect(isObject(Object.freeze({}))).toBe(true);
});
it('should return false for null', function () {
expect(isObject(null)).toBe(false);
});
it.each([
undefined,
123,
'foobar',
() => ({}),
Function,
String(123),
isObject,
() => null,
[],
[ 1, 2, 3 ],
])('should return false for %p', function (value) {
expect(isObject(value)).toBe(false);
});
});
});

View File

@ -0,0 +1,29 @@
/**
* Determines if the value provided to the function is an object type that
* is not null.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
function isObject (val: unknown): val is {} {
return typeof val === 'object' && val !== null && !Array.isArray(val);
}
/**
* Determines if an object is truly empty by looking at the keys present
* and the prototype value.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
function isEmptyObject (val: {}): boolean {
return Object.keys(val).length === 0 && Object.getPrototypeOf(val) === Object.prototype;
}
/**
* A helper function for use in TypeScript that returns all of the keys
* for an object, but in a typed manner to make working with them a little
* easier.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
function getObjectKeys<T extends {}> (o: T): (keyof T)[] {
return Object.keys(o) as (keyof typeof o)[];
}
export { isObject, isEmptyObject, getObjectKeys };

View File

@ -0,0 +1,14 @@
import { capitalize } from '@/lib/strings';
describe('@/lib/strings.ts', function () {
describe('capitalize()', function () {
it('should capitalize a string', function () {
expect(capitalize('foo bar')).toBe('Foo bar');
expect(capitalize('FOOBAR')).toBe('Foobar');
});
it('should handle empty strings', function () {
expect(capitalize('')).toBe('');
});
});
});

View File

@ -0,0 +1,5 @@
function capitalize (value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
export { capitalize };

View File

@ -3,7 +3,7 @@
* undefined, or empty string key values. This allows the parameters to be used for
* caching without having to account for all of the different data combinations.
*/
import { isEmptyObject, isObject } from '@/helpers';
import { isEmptyObject, isObject } from '@/lib/objects';
// eslint-disable-next-line @typescript-eslint/ban-types
export default <T extends {}>(data: T): T => {

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

1775
yarn.lock

File diff suppressed because it is too large Load Diff