[READ-ONLY] a fast, modern browser for the npm registry

feat(i18n): format numbers with `Intl.NumberFormat` (#1149)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Denys
Daniel Roe
and committed by
GitHub
5ad013bc 33a9df02

+170 -150
+3 -2
app/components/Code/DirectoryListing.vue
··· 2 2 import type { PackageFileTree } from '#shared/types' 3 3 import type { RouteLocationRaw } from 'vue-router' 4 4 import { getFileIcon } from '~/utils/file-icons' 5 - import { formatBytes } from '~/utils/formatters' 6 5 7 6 const props = defineProps<{ 8 7 tree: PackageFileTree[] ··· 51 50 params: { path: pathSegments as [string, ...string[]] }, 52 51 } 53 52 } 53 + 54 + const bytesFormatter = useBytesFormatter() 54 55 </script> 55 56 56 57 <template> ··· 107 108 v-if="node.type === 'file' && node.size" 108 109 class="text-end font-mono text-xs text-fg-subtle" 109 110 > 110 - {{ formatBytes(node.size) }} 111 + {{ bytesFormatter.format(node.size) }} 111 112 </span> 112 113 </NuxtLink> 113 114 </td>
+4 -2
app/components/Compare/PackageSelector.vue
··· 53 53 .filter(r => !packages.value.includes(r.name)) 54 54 }) 55 55 56 + const numberFormatter = useNumberFormatter() 57 + 56 58 function addPackage(name: string) { 57 59 if (packages.value.length >= maxPackages.value) return 58 60 if (packages.value.includes(name)) return ··· 209 211 <p class="text-xs text-fg-subtle"> 210 212 {{ 211 213 $t('compare.selector.packages_selected', { 212 - count: packages.length, 213 - max: maxPackages, 214 + count: numberFormatter.format(packages.length), 215 + max: numberFormatter.format(maxPackages), 214 216 }) 215 217 }} 216 218 <span v-if="packages.length < 2">{{ $t('compare.selector.add_hint') }}</span>
+3 -1
app/components/Package/Card.vue
··· 34 34 plain: true, 35 35 packageName: props.result.package.name, 36 36 })) 37 + 38 + const numberFormatter = useNumberFormatter() 37 39 </script> 38 40 39 41 <template> ··· 180 182 class="text-fg-subtle text-xs pointer-events-auto" 181 183 :title="result.package.keywords.slice(5).join(', ')" 182 184 > 183 - +{{ result.package.keywords.length - 5 }} 185 + +{{ numberFormatter.format(result.package.keywords.length - 5) }} 184 186 </span> 185 187 </div> 186 188 </BaseCard>
+40 -14
app/components/Package/Dependencies.vue
··· 65 65 if (!props.optionalDependencies) return [] 66 66 return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b)) 67 67 }) 68 + 69 + const numberFormatter = useNumberFormatter() 68 70 </script> 69 71 70 72 <template> ··· 73 75 <CollapsibleSection 74 76 v-if="sortedDependencies.length > 0" 75 77 id="dependencies" 76 - :title="$t('package.dependencies.title', { count: sortedDependencies.length })" 78 + :title=" 79 + $t( 80 + 'package.dependencies.title', 81 + { 82 + count: numberFormatter.format(sortedDependencies.length), 83 + }, 84 + sortedDependencies.length, 85 + ) 86 + " 77 87 > 78 88 <ul class="space-y-1 list-none m-0" :aria-label="$t('package.dependencies.list_label')"> 79 89 <li ··· 137 147 @click="depsExpanded = true" 138 148 > 139 149 {{ 140 - $t('package.dependencies.show_all', { 141 - count: sortedDependencies.length, 142 - }) 150 + $t( 151 + 'package.dependencies.show_all', 152 + { 153 + count: numberFormatter.format(sortedDependencies.length), 154 + }, 155 + sortedDependencies.length, 156 + ) 143 157 }} 144 158 </button> 145 159 </CollapsibleSection> ··· 150 164 id="peer-dependencies" 151 165 :title=" 152 166 $t('package.peer_dependencies.title', { 153 - count: sortedPeerDependencies.length, 167 + count: numberFormatter.format(sortedPeerDependencies.length), 154 168 }) 155 169 " 156 170 > ··· 185 199 @click="peerDepsExpanded = true" 186 200 > 187 201 {{ 188 - $t('package.peer_dependencies.show_all', { 189 - count: sortedPeerDependencies.length, 190 - }) 202 + $t( 203 + 'package.peer_dependencies.show_all', 204 + { 205 + count: numberFormatter.format(sortedPeerDependencies.length), 206 + }, 207 + sortedPeerDependencies.length, 208 + ) 191 209 }} 192 210 </button> 193 211 </CollapsibleSection> ··· 197 215 v-if="sortedOptionalDependencies.length > 0" 198 216 id="optional-dependencies" 199 217 :title=" 200 - $t('package.optional_dependencies.title', { 201 - count: sortedOptionalDependencies.length, 202 - }) 218 + $t( 219 + 'package.optional_dependencies.title', 220 + { 221 + count: numberFormatter.format(sortedOptionalDependencies.length), 222 + }, 223 + sortedOptionalDependencies.length, 224 + ) 203 225 " 204 226 > 205 227 <ul ··· 229 251 @click="optionalDepsExpanded = true" 230 252 > 231 253 {{ 232 - $t('package.optional_dependencies.show_all', { 233 - count: sortedOptionalDependencies.length, 234 - }) 254 + $t( 255 + 'package.optional_dependencies.show_all', 256 + { 257 + count: numberFormatter.format(sortedOptionalDependencies.length), 258 + }, 259 + sortedOptionalDependencies.length, 260 + ) 235 261 }} 236 262 </button> 237 263 </CollapsibleSection>
+4 -4
app/components/Package/DownloadAnalytics.vue
··· 750 750 return { dataset, dates } 751 751 }) 752 752 753 - const formatter = ({ value }: { value: number }) => formatCompactNumber(value, { decimals: 1 }) 754 - 755 753 const loadFile = (link: string, filename: string) => { 756 754 const a = document.createElement('a') 757 755 a.href = link ··· 799 797 function getGranularityLabel(granularity: ChartTimeGranularity) { 800 798 return granularityLabels.value[granularity] 801 799 } 800 + 801 + const compactNumberFormatter = useCompactNumberFormatter() 802 802 803 803 // VueUiXy chart component configuration 804 804 const chartConfig = computed(() => { ··· 867 867 }, 868 868 }, 869 869 yAxis: { 870 - formatter, 870 + formatter: compactNumberFormatter.value.format, 871 871 useNiceScale: true, 872 872 gap: 24, // vertical gap between individual series in stacked mode 873 873 }, ··· 899 899 .map((d: any) => { 900 900 const label = String(d?.name ?? '').trim() 901 901 const raw = Number(d?.value ?? 0) 902 - const v = formatter({ value: Number.isFinite(raw) ? raw : 0 }) 902 + const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0) 903 903 904 904 if (!hasMultipleItems) { 905 905 // We don't need the name of the package in this case, since it is shown in the xAxis label
+21 -3
app/components/Settings/TranslationHelper.vue
··· 29 29 // Copy missing keys as JSON template to clipboard 30 30 const { copy, copied } = useClipboard() 31 31 32 + const numberFormatter = useNumberFormatter() 33 + const percentageFormatter = useNumberFormatter({ style: 'percent' }) 34 + 32 35 function copyMissingKeysTemplate() { 33 36 // Create a template showing what needs to be added 34 37 const template = props.status.missingKeys.map(key => ` "${key}": ""`).join(',\n') ··· 49 52 <div class="flex items-center justify-between text-xs text-fg-muted"> 50 53 <span>{{ $t('settings.translation_progress') }}</span> 51 54 <span class="tabular-nums" 52 - >{{ status.completedKeys }}/{{ status.totalKeys }} ({{ status.percentComplete }}%)</span 55 + >{{ numberFormatter.format(status.completedKeys) }}/{{ 56 + numberFormatter.format(status.totalKeys) 57 + }} 58 + ({{ percentageFormatter.format(status.percentComplete / 100) }})</span 53 59 > 54 60 </div> 55 61 <div class="h-1.5 bg-bg rounded-full overflow-hidden"> ··· 64 70 <div v-if="status.missingKeys.length > 0" class="space-y-2"> 65 71 <div class="flex items-center justify-between"> 66 72 <h4 class="text-xs text-fg-muted font-medium"> 67 - {{ $t('i18n.missing_keys', { count: status.missingKeys.length }) }} 73 + {{ 74 + $t( 75 + 'i18n.missing_keys', 76 + { count: numberFormatter.format(status.missingKeys.length) }, 77 + status.missingKeys.length, 78 + ) 79 + }} 68 80 </h4> 69 81 <button 70 82 type="button" ··· 87 99 class="text-xs text-fg-muted hover:text-fg rounded focus-visible:outline-accent/70" 88 100 @click="showAll = true" 89 101 > 90 - {{ $t('i18n.show_more_keys', { count: remainingCount }) }} 102 + {{ 103 + $t( 104 + 'i18n.show_more_keys', 105 + { count: numberFormatter.format(remainingCount) }, 106 + remainingCount, 107 + ) 108 + }} 91 109 </button> 92 110 </div> 93 111
+35
app/composables/useNumberFormatter.ts
··· 1 + export function useNumberFormatter(options?: Intl.NumberFormatOptions) { 2 + const { locale } = useI18n() 3 + 4 + return computed(() => new Intl.NumberFormat(locale.value, options)) 5 + } 6 + 7 + export const useCompactNumberFormatter = () => 8 + useNumberFormatter({ 9 + notation: 'compact', 10 + compactDisplay: 'short', 11 + maximumFractionDigits: 1, 12 + }) 13 + 14 + export const useBytesFormatter = () => { 15 + const { t } = useI18n() 16 + const decimalNumberFormatter = useNumberFormatter({ 17 + maximumFractionDigits: 1, 18 + }) 19 + 20 + return { 21 + format: (bytes: number) => { 22 + if (bytes < 1024) 23 + return t('package.size.b', { 24 + size: decimalNumberFormatter.value.format(bytes), 25 + }) 26 + if (bytes < 1024 * 1024) 27 + return t('package.size.kb', { 28 + size: decimalNumberFormatter.value.format(bytes / 1024), 29 + }) 30 + return t('package.size.mb', { 31 + size: decimalNumberFormatter.value.format(bytes / (1024 * 1024)), 32 + }) 33 + }, 34 + } 35 + }
+16 -4
app/composables/usePackageComparison.ts
··· 9 9 import { encodePackageName } from '#shared/utils/npm' 10 10 import type { PackageAnalysisResponse } from './usePackageAnalysis' 11 11 import { isBinaryOnlyPackage } from '#shared/utils/binary-detection' 12 - import { formatBytes } from '~/utils/formatters' 13 12 import { getDependencyCount } from '~/utils/npm/dependency-count' 14 13 15 14 /** Special identifier for the "What Would James Do?" comparison column */ ··· 71 70 */ 72 71 export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) { 73 72 const { t } = useI18n() 73 + const numberFormatter = useNumberFormatter() 74 + const compactNumberFormatter = useCompactNumberFormatter() 75 + const bytesFormatter = useBytesFormatter() 74 76 const packages = computed(() => toValue(packageNames)) 75 77 76 78 // Cache of fetched data by package name (source of truth) ··· 260 262 261 263 return packagesData.value.map(pkg => { 262 264 if (!pkg) return null 263 - return computeFacetValue(facet, pkg, t) 265 + return computeFacetValue( 266 + facet, 267 + pkg, 268 + numberFormatter.value.format, 269 + compactNumberFormatter.value.format, 270 + bytesFormatter.format, 271 + t, 272 + ) 264 273 }) 265 274 } 266 275 ··· 342 351 function computeFacetValue( 343 352 facet: ComparisonFacet, 344 353 data: PackageComparisonData, 354 + formatNumber: (num: number) => string, 355 + formatCompactNumber: (num: number) => string, 356 + formatBytes: (num: number) => string, 345 357 t: (key: string, params?: Record<string, unknown>) => string, 346 358 ): FacetValue | null { 347 359 const { isNoDependency } = data ··· 513 525 if (depCount == null) return null 514 526 return { 515 527 raw: depCount, 516 - display: String(depCount), 528 + display: formatNumber(depCount), 517 529 status: depCount > 10 ? 'warning' : 'neutral', 518 530 } 519 531 } ··· 532 544 const totalDepCount = data.installSize.dependencyCount 533 545 return { 534 546 raw: totalDepCount, 535 - display: String(totalDepCount), 547 + display: formatNumber(totalDepCount), 536 548 status: totalDepCount > 50 ? 'warning' : 'neutral', 537 549 } 538 550 }
+6 -3
app/pages/package-code/[...path].vue
··· 4 4 PackageFileTreeResponse, 5 5 PackageFileContentResponse, 6 6 } from '#shared/types' 7 - import { formatBytes } from '~/utils/formatters' 8 7 9 8 definePageMeta({ 10 9 name: 'code', ··· 269 268 270 269 const markdownViewMode = shallowRef<(typeof markdownViewModes)[number]['key']>('preview') 271 270 271 + const bytesFormatter = useBytesFormatter() 272 + 272 273 useHead({ 273 274 link: [{ rel: 'canonical', href: canonicalUrl }], 274 275 }) ··· 443 444 $t('code.lines', { count: fileContent.lines }) 444 445 }}</span> 445 446 <span v-if="currentNode?.size" class="text-fg-subtle">{{ 446 - formatBytes(currentNode.size) 447 + bytesFormatter.format(currentNode.size) 447 448 }}</span> 448 449 </div> 449 450 </div> ··· 489 490 <div class="i-carbon:document w-12 h-12 mx-auto text-fg-subtle mb-4" /> 490 491 <p class="text-fg-muted mb-2">{{ $t('code.file_too_large') }}</p> 491 492 <p class="text-fg-subtle text-sm mb-4"> 492 - {{ $t('code.file_size_warning', { size: formatBytes(currentNode?.size ?? 0) }) }} 493 + {{ 494 + $t('code.file_size_warning', { size: bytesFormatter.format(currentNode?.size ?? 0) }) 495 + }} 493 496 </p> 494 497 <LinkBase 495 498 variant="button-secondary"
+22 -13
app/pages/package/[[org]]/[name].vue
··· 11 11 import { joinURL } from 'ufo' 12 12 import { areUrlsEquivalent } from '#shared/utils/url' 13 13 import { isEditableElement } from '~/utils/input' 14 - import { formatBytes } from '~/utils/formatters' 15 14 import { getDependencyCount } from '~/utils/npm/dependency-count' 16 15 import { useModal } from '~/composables/useModal' 17 16 import { useAtproto } from '~/composables/atproto/useAtproto' ··· 231 230 displayVersion.value && 232 231 displayVersion.value.dist.unpackedSize && 233 232 $t('package.stats.size_tooltip.unpacked', { 234 - size: formatBytes(displayVersion.value.dist.unpackedSize), 233 + size: bytesFormatter.format(displayVersion.value.dist.unpackedSize), 235 234 }), 236 235 installSize.value && 237 236 installSize.value.dependencyCount && 238 237 $t('package.stats.size_tooltip.total', { 239 - size: formatBytes(installSize.value.totalSize), 238 + size: bytesFormatter.format(installSize.value.totalSize), 240 239 count: installSize.value.dependencyCount, 241 240 }), 242 241 ] ··· 263 262 // Subtract 1 to exclude the root package itself 264 263 const totalDepsCount = computed(() => { 265 264 if (vulnTree.value) { 266 - return vulnTree.value.totalPackages - 1 265 + return vulnTree.value.totalPackages ? vulnTree.value.totalPackages - 1 : 0 267 266 } 268 267 if (installSize.value) { 269 268 return installSize.value.dependencyCount ··· 462 461 } 463 462 } 464 463 464 + const numberFormatter = useNumberFormatter() 465 + const compactNumberFormatter = useCompactNumberFormatter() 466 + const bytesFormatter = useBytesFormatter() 467 + 465 468 useHead({ 466 469 link: [{ rel: 'canonical', href: canonicalUrl }], 467 470 }) ··· 647 650 : 'i-lucide-heart-plus' 648 651 " 649 652 > 650 - {{ formatCompactNumber(likesData?.totalLikes ?? 0, { decimals: 1 }) }} 653 + {{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }} 651 654 </ButtonBase> 652 655 </TooltipApp> 653 656 <template #fallback> ··· 726 729 </li> 727 730 <li v-if="repositoryUrl && repoMeta && starsLink"> 728 731 <LinkBase :to="starsLink" classicon="i-carbon:star"> 729 - {{ formatCompactNumber(stars, { decimals: 1 }) }} 732 + {{ compactNumberFormatter.format(stars) }} 730 733 </LinkBase> 731 734 </li> 732 735 <li v-if="forks && forksLink"> 733 736 <LinkBase :to="forksLink" classicon="i-carbon:fork"> 734 - {{ formatCompactNumber(forks, { decimals: 1 }) }} 737 + {{ compactNumberFormatter.format(forks) }} 735 738 </LinkBase> 736 739 </li> 737 740 <li v-if="homepageUrl"> ··· 832 835 <dd class="font-mono text-sm text-fg flex items-center justify-start gap-2"> 833 836 <span class="flex items-center gap-1"> 834 837 <!-- Direct deps (muted) --> 835 - <span class="text-fg-muted">{{ getDependencyCount(displayVersion) }}</span> 838 + <span class="text-fg-muted">{{ 839 + numberFormatter.format(getDependencyCount(displayVersion)) 840 + }}</span> 836 841 837 842 <!-- Separator and total transitive deps --> 838 843 <template v-if="getDependencyCount(displayVersion) !== totalDepsCount"> ··· 851 856 aria-hidden="true" 852 857 /> 853 858 </span> 854 - <span v-else-if="totalDepsCount !== null">{{ totalDepsCount }}</span> 859 + <span v-else-if="totalDepsCount !== null">{{ 860 + numberFormatter.format(totalDepsCount) 861 + }}</span> 855 862 <span v-else class="text-fg-subtle">-</span> 856 863 <template #fallback> 857 864 <span class="text-fg-subtle">-</span> ··· 899 906 <!-- Package size (greyed out) --> 900 907 <span class="text-fg-muted" dir="ltr"> 901 908 <span v-if="displayVersion?.dist?.unpackedSize"> 902 - {{ formatBytes(displayVersion.dist.unpackedSize) }} 909 + {{ bytesFormatter.format(displayVersion.dist.unpackedSize) }} 903 910 </span> 904 911 <span v-else>-</span> 905 912 </span> ··· 918 925 /> 919 926 </span> 920 927 <span v-else-if="installSize?.totalSize" dir="ltr"> 921 - {{ formatBytes(installSize.totalSize) }} 928 + {{ bytesFormatter.format(installSize.totalSize) }} 922 929 </span> 923 930 <span v-else class="text-fg-subtle">-</span> 924 931 </template> ··· 942 949 /> 943 950 </span> 944 951 <span v-else-if="vulnTreeStatus === 'success'"> 945 - <span v-if="hasVulnerabilities" class="text-amber-500">{{ vulnCount }}</span> 952 + <span v-if="hasVulnerabilities" class="text-amber-500"> 953 + {{ numberFormatter.format(vulnCount) }} 954 + </span> 946 955 <span v-else class="inline-flex items-center gap-1 text-fg-muted"> 947 956 <span class="i-carbon:checkmark w-3 h-3" aria-hidden="true" /> 948 - 0 957 + {{ numberFormatter.format(0) }} 949 958 </span> 950 959 </span> 951 960 <span v-else class="text-fg-subtle">-</span>
-34
app/utils/formatters.ts
··· 18 18 export function decodeHtmlEntities(text: string): string { 19 19 return text.replace(/&(?:amp|lt|gt|quot|apos|nbsp|#39);/g, match => htmlEntities[match] || match) 20 20 } 21 - 22 - export function formatCompactNumber( 23 - value: number, 24 - options?: { decimals?: number; space?: boolean }, 25 - ): string { 26 - const decimals = options?.decimals ?? 0 27 - const space = options?.space ?? false 28 - 29 - const sign = value < 0 ? '-' : '' 30 - const abs = Math.abs(value) 31 - 32 - const fmt = (n: number) => { 33 - if (decimals <= 0) return Math.round(n).toString() 34 - const fixed = n.toFixed(decimals) 35 - // Remove trailing zeros after decimal point 36 - return fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed 37 - } 38 - 39 - const join = (suffix: string, n: number) => `${sign}${fmt(n)}${space ? ' ' : ''}${suffix}` 40 - 41 - if (abs >= 1e12) return join('T', abs / 1e12) 42 - if (abs >= 1e9) return join('B', abs / 1e9) 43 - if (abs >= 1e6) return join('M', abs / 1e6) 44 - if (abs >= 1e3) return join('k', abs / 1e3) 45 - 46 - return `${sign}${Math.round(abs)}` 47 - } 48 - 49 - // Format file size 50 - export function formatBytes(bytes: number): string { 51 - if (bytes < 1024) return `${bytes} B` 52 - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB` 53 - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 54 - }
+5
i18n/locales/en.json
··· 413 413 "published": "Recently published", 414 414 "name_asc": "Name (A-Z)", 415 415 "name_desc": "Name (Z-A)" 416 + }, 417 + "size": { 418 + "b": "{size} B", 419 + "kb": "{size} kB", 420 + "mb": "{size} MB" 416 421 } 417 422 }, 418 423 "connector": {
+5
lunaria/files/en-GB.json
··· 413 413 "published": "Recently published", 414 414 "name_asc": "Name (A-Z)", 415 415 "name_desc": "Name (Z-A)" 416 + }, 417 + "size": { 418 + "b": "{size} B", 419 + "kb": "{size} kB", 420 + "mb": "{size} MB" 416 421 } 417 422 }, 418 423 "connector": {
+5
lunaria/files/en-US.json
··· 413 413 "published": "Recently published", 414 414 "name_asc": "Name (A-Z)", 415 415 "name_desc": "Name (Z-A)" 416 + }, 417 + "size": { 418 + "b": "{size} B", 419 + "kb": "{size} kB", 420 + "mb": "{size} MB" 416 421 } 417 422 }, 418 423 "connector": {
+1 -70
test/unit/app/utils/formatters.spec.ts
··· 1 1 import { describe, expect, it } from 'vitest' 2 - import { 3 - decodeHtmlEntities, 4 - formatBytes, 5 - formatCompactNumber, 6 - toIsoDateString, 7 - } from '../../../../app/utils/formatters' 2 + import { decodeHtmlEntities, toIsoDateString } from '../../../../app/utils/formatters' 8 3 9 4 describe('toIsoDateString', () => { 10 5 it('formats a date as YYYY-MM-DD', () => { ··· 41 36 expect(decodeHtmlEntities('&unknown;')).toBe('&unknown;') 42 37 }) 43 38 }) 44 - 45 - describe('formatCompactNumber', () => { 46 - describe('without options', () => { 47 - it.each([ 48 - [0, '0'], 49 - [1, '1'], 50 - [999, '999'], 51 - [1000, '1k'], 52 - [1500, '2k'], 53 - [10000, '10k'], 54 - [1000000, '1M'], 55 - [2500000, '3M'], 56 - [1000000000, '1B'], 57 - [1000000000000, '1T'], 58 - ] as const)('%d → %s', (input, expected) => { 59 - expect(formatCompactNumber(input)).toBe(expected) 60 - }) 61 - }) 62 - 63 - describe('with decimals', () => { 64 - it.each([ 65 - [1500, 1, '1.5k'], 66 - [1234567, 2, '1.23M'], 67 - [1200000, 1, '1.2M'], 68 - [1000, 2, '1k'], 69 - ] as const)('%d with %d decimals', (input, decimals, expected) => { 70 - expect(formatCompactNumber(input, { decimals })).toBe(expected) 71 - }) 72 - }) 73 - 74 - describe('with space', () => { 75 - it('adds space before suffix', () => { 76 - expect(formatCompactNumber(1500, { space: true })).toBe('2 k') 77 - }) 78 - }) 79 - 80 - describe('negative values', () => { 81 - it.each([ 82 - [-1000, '-1k'], 83 - [-2500000, '-3M'], 84 - [-42, '-42'], 85 - ] as const)('%d → %s', (input, expected) => { 86 - expect(formatCompactNumber(input)).toBe(expected) 87 - }) 88 - }) 89 - 90 - it('handles values below 1000', () => { 91 - expect(formatCompactNumber(500)).toBe('500') 92 - }) 93 - }) 94 - 95 - describe('formatBytes', () => { 96 - it.each([ 97 - [0, '0 B'], 98 - [512, '512 B'], 99 - [1023, '1023 B'], 100 - [1024, '1.0 kB'], 101 - [1536, '1.5 kB'], 102 - [1048576, '1.0 MB'], 103 - [1572864, '1.5 MB'], 104 - ] as const)('%d → %s', (input, expected) => { 105 - expect(formatBytes(input)).toBe(expected) 106 - }) 107 - })