From dbafc97e890fa496e0d50468b302cd08600e7b5b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 17:59:46 +0000 Subject: [PATCH] Drop unused login dialogs (dead code) + Vitest coverage on FriendlyTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated cleanups grouped because they're both small and safe. Dead code removal - src/components/login/components/RegisterDialog.tsx - src/components/login/components/ForgotDialog.tsx - src/components/login/components/shared.ts (only consumed by the two dialogs above) These were the older non-Form-Actions versions of the register and forgot-password dialogs. LoginView.tsx defines its own inline versions that use `useActionState` + `useFormStatus` (Phase 3 of the React 19 modernization), which are the ones actually rendered. The legacy files were already documented as dead in docs/ARCHITECTURE.md. NewsWindow.tsx and the `components/` directory itself stay — NewsWindow is still imported by LoginView at the bottom of the login flow. Vitest coverage on FriendlyTime (+12 cases) - 65 -> 77 passing tests, 5 -> 6 test files. - LocalizeText is mocked with a deterministic stub (`${ key }|${ amount }`) so each assertion can verify both the bucket chosen and the rounded amount. The mock also short-circuits the transitive renderer-SDK import, which keeps the test runner decoupled from the renderer install state. - Buckets covered: seconds / minutes / hours / days / months / years for both `format` and `shortFormat`. Plus: threshold override, key-suffix concatenation, half-hour rounding, the raw `getLocalization` helper. Verification - yarn test: 6 files / 77 cases / ~2s. - yarn eslint on the new test file: 0 errors / 0 warnings. - yarn tsc: clean on touched files. --- .../login/components/ForgotDialog.tsx | 72 -- .../login/components/RegisterDialog.tsx | 676 ------------------ src/components/login/components/shared.ts | 9 - tests/friendly-time.test.ts | 100 +++ 4 files changed, 100 insertions(+), 757 deletions(-) delete mode 100644 src/components/login/components/ForgotDialog.tsx delete mode 100644 src/components/login/components/RegisterDialog.tsx delete mode 100644 src/components/login/components/shared.ts create mode 100644 tests/friendly-time.test.ts diff --git a/src/components/login/components/ForgotDialog.tsx b/src/components/login/components/ForgotDialog.tsx deleted file mode 100644 index 65cffa8..0000000 --- a/src/components/login/components/ForgotDialog.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { FC, FormEvent, useCallback, useState } from 'react'; -import { TurnstileWidget } from '../TurnstileWidget'; -import { t } from '../utils/i18n'; -import { DialogSharedProps } from './shared'; - -export interface ForgotDialogProps extends DialogSharedProps -{ - onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; -} - -export const ForgotDialog: FC = props => -{ - const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; - const [ email, setEmail ] = useState(''); - const [ localError, setLocalError ] = useState(null); - const [ turnstileToken, setTurnstileToken ] = useState(''); - const [ resetSignal, setResetSignal ] = useState(0); - - const resetWidget = useCallback(() => - { - setTurnstileToken(''); - setResetSignal(prev => prev + 1); - }, []); - - const handle = (event: FormEvent) => - { - event.preventDefault(); - setLocalError(null); - - if(!email.trim()) - { - setLocalError(t('nitro.login.error.missing_email', 'Please enter your email address.')); - return; - } - - onSubmit({ email: email.trim(), turnstileToken }, resetWidget); - }; - - return ( -
-
-
-
- { t('nitro.login.forgot.title', 'Reset password') } - -
-
-
- - setEmail(e.target.value) } /> -
- { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- -
- -
-
-
- ); -}; diff --git a/src/components/login/components/RegisterDialog.tsx b/src/components/login/components/RegisterDialog.tsx deleted file mode 100644 index 42f0e72..0000000 --- a/src/components/login/components/RegisterDialog.tsx +++ /dev/null @@ -1,676 +0,0 @@ -import { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; -import { GetConfiguration } from '@nitrots/nitro-renderer'; -import { GetConfigurationValue } from '../../../api'; -import { TurnstileWidget } from '../TurnstileWidget'; -import { t } from '../utils/i18n'; -import { - buildFigureString, - buildImagingUrl, - buildPartPreviewUrl, - EMAIL_REGEX, - FALLBACK_DEFAULTS, - FALLBACK_HEX, - FigureData, - FigureSelection, - GenderKey, - PART_ROWS -} from '../utils/figure'; -import { DialogSharedProps } from './shared'; - -export interface RegisterDialogProps extends DialogSharedProps -{ - onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void; - onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; - onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; - onCheckServer: () => Promise; - imagingUrl: string; - roomTemplatesUrl: string; -} - -type RegisterStep = 'credentials' | 'avatar' | 'room'; - -interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; } - -export const RegisterDialog: FC = props => -{ - const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; - - const [ step, setStep ] = useState('credentials'); - const [ email, setEmail ] = useState(''); - const [ password, setPassword ] = useState(''); - const [ confirm, setConfirm ] = useState(''); - const [ username, setUsername ] = useState(''); - const [ gender, setGender ] = useState('F'); - const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); - const [ localError, setLocalError ] = useState(null); - const [ checking, setChecking ] = useState(false); - const [ turnstileToken, setTurnstileToken ] = useState(''); - const [ resetSignal, setResetSignal ] = useState(0); - const [ serverReachable, setServerReachable ] = useState(null); - const [ pingingServer, setPingingServer ] = useState(false); - - const pingServer = useCallback(async () => - { - setPingingServer(true); - try - { - const ok = await onCheckServer(); - setServerReachable(ok); - return ok; - } - finally - { - setPingingServer(false); - } - }, [ onCheckServer ]); - - useEffect(() => - { - let cancelled = false; - (async () => - { - const ok = await onCheckServer(); - if(!cancelled) setServerReachable(ok); - })(); - return () => - { - cancelled = true; - }; - }, [ onCheckServer ]); - - const resetWidget = useCallback(() => - { - setTurnstileToken(''); - setResetSignal(prev => prev + 1); - }, []); - - useEffect(() => - { - setLocalError(null); - }, [ step ]); - - const [ roomTemplates, setRoomTemplates ] = useState(null); - const [ roomTemplatesError, setRoomTemplatesError ] = useState(null); - const [ selectedTemplateId, setSelectedTemplateId ] = useState(null); - - const [ figureData, setFigureData ] = useState(null); - const figureDataUrlRaw = GetConfigurationValue('avatar.figuredata.url', ''); - const figureDataUrl = useMemo(() => - { - if(!figureDataUrlRaw) return ''; - try - { - return GetConfiguration().interpolate(figureDataUrlRaw); - } - catch - { - return figureDataUrlRaw; - } - }, [ figureDataUrlRaw ]); - - useEffect(() => - { - if(step !== 'avatar' || figureData || !figureDataUrl) return; - let cancelled = false; - fetch(figureDataUrl, { credentials: 'omit' }) - .then(r => r.ok ? r.json() : null) - .then(json => - { - if(!cancelled && json) setFigureData(json as FigureData); - }) - .catch(() => - { }); - return () => - { - cancelled = true; - }; - }, [ step, figureData, figureDataUrl ]); - - useEffect(() => - { - if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return; - let cancelled = false; - setRoomTemplatesError(null); - fetch(roomTemplatesUrl, { credentials: 'include' }) - .then(async r => - { - if(!r.ok) throw new Error(`status ${ r.status }`); - return r.json(); - }) - .then(json => - { - if(cancelled) return; - const list = Array.isArray((json as { templates?: unknown })?.templates) - ? (json as { templates: RoomTemplate[] }).templates - : []; - setRoomTemplates(list); - }) - .catch(() => - { - if(cancelled) return; - setRoomTemplates([]); - setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.')); - }); - return () => - { - cancelled = true; - }; - }, [ step, roomTemplates, roomTemplatesUrl ]); - - const partOptions = useMemo(() => - { - const result: Record> = {}; - if(!figureData) return result; - for(const st of figureData.setTypes) - { - if(!PART_ROWS.includes(st.type)) continue; - const forGender = (g: GenderKey) => st.sets - .filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U')) - .map(s => s.id); - result[st.type] = { M: forGender('M'), F: forGender('F') }; - } - return result; - }, [ figureData ]); - - const paletteOptions = useMemo(() => - { - const result: Record = {}; - if(!figureData) return result; - for(const st of figureData.setTypes) - { - if(!PART_ROWS.includes(st.type)) continue; - const palette = figureData.palettes.find(p => p.id === st.paletteId); - if(!palette) - { - result[st.type] = []; continue; - } - result[st.type] = palette.colors - .filter(c => c.selectable && c.club === 0) - .map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() })); - } - return result; - }, [ figureData ]); - - const hexFor = useCallback((setType: string, colorId: number): string => - { - const list = paletteOptions[setType]; - if(list) - { - const found = list.find(c => c.id === colorId); - if(found) return found.hex; - } - return FALLBACK_HEX[colorId] || '#c9c9c9'; - }, [ paletteOptions ]); - - const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]); - const [ hotLookIndex, setHotLookIndex ] = useState(-1); - - useEffect(() => - { - if(step !== 'avatar' || hotLooks.length) return; - let cancelled = false; - fetch('hotlooks.json', { credentials: 'omit' }) - .then(r => r.ok ? r.json() : null) - .then((json: unknown) => - { - if(cancelled || !Array.isArray(json)) return; - const parsed: { gender: GenderKey; figure: string }[] = []; - for(const entry of json as Record[]) - { - const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : ''; - const figure = typeof entry._figure === 'string' ? entry._figure : ''; - if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue; - parsed.push({ gender: rawGender, figure }); - } - if(parsed.length) setHotLooks(parsed); - }) - .catch(() => - { }); - return () => - { - cancelled = true; - }; - }, [ step, hotLooks.length ]); - - const applyLook = useCallback((figure: string, lookGender: GenderKey) => - { - const next: FigureSelection = {}; - for(const setPart of figure.split('.')) - { - const bits = setPart.split('-'); - if(bits.length < 2) continue; - const setType = bits[0]; - const partId = parseInt(bits[1], 10); - if(!setType || Number.isNaN(partId)) continue; - const colors: number[] = []; - for(let i = 2; i < bits.length; i++) - { - const c = parseInt(bits[i], 10); - if(!Number.isNaN(c)) colors.push(c); - } - next[setType] = { partId, colors }; - } - - for(const setType of PART_ROWS) - { - if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] }; - } - setGender(lookGender); - setSelection(next); - }, []); - - const cycleHotLook = useCallback(() => - { - if(!hotLooks.length) return; - const nextIdx = (hotLookIndex + 1) % hotLooks.length; - setHotLookIndex(nextIdx); - const look = hotLooks[nextIdx]; - applyLook(look.figure, look.gender); - }, [ hotLooks, hotLookIndex, applyLook ]); - - const credentialsValid = - EMAIL_REGEX.test(email.trim()) && - password.length >= 8 && - password === confirm; - - const handleCredentialsNext = async (event: FormEvent) => - { - event.preventDefault(); - setLocalError(null); - - if(!email.trim() || !password || !confirm) - { - setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.')); - return; - } - if(!EMAIL_REGEX.test(email.trim())) - { - setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.')); - return; - } - if(password.length < 8) - { - setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.')); - return; - } - if(password !== confirm) - { - setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.')); - return; - } - - setChecking(true); - try - { - const serverOk = await pingServer(); - if(!serverOk) - { - setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); - return; - } - const result = await onCheckEmail(email.trim()); - if(!result.available) - { - setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.')); - return; - } - setStep('avatar'); - } - finally - { - setChecking(false); - } - }; - - const applyGender = (newGender: GenderKey) => - { - setGender(newGender); - setSelection({ ...FALLBACK_DEFAULTS[newGender] }); - setHotLookIndex(-1); - }; - - const getPartList = useCallback((setType: string): number[] => - { - const loaded = partOptions[setType]?.[gender]; - if(loaded && loaded.length) return loaded; - const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId; - return fallback !== undefined ? [ fallback ] : []; - }, [ partOptions, gender ]); - - const getColorList = useCallback((setType: string): number[] => - { - const loaded = paletteOptions[setType]; - if(loaded && loaded.length) return loaded.map(c => c.id); - const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0]; - return fallback !== undefined ? [ fallback ] : []; - }, [ paletteOptions, gender ]); - - const cyclePart = (setType: string, direction: 1 | -1) => - { - const options = getPartList(setType); - if(!options.length) return; - const current = selection[setType]?.partId ?? options[0]; - const idx = options.indexOf(current); - const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length; - const colors = getColorList(setType); - setSelection(prev => ({ - ...prev, - [setType]: { - partId: options[nextIdx], - colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ] - } - })); - }; - - const cycleColor = (setType: string, direction: 1 | -1) => - { - const colors = getColorList(setType); - if(!colors.length) return; - const currentColor = selection[setType]?.colors?.[0] ?? colors[0]; - const idx = colors.indexOf(currentColor); - const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length; - const parts = getPartList(setType); - setSelection(prev => ({ - ...prev, - [setType]: { - partId: prev[setType]?.partId ?? parts[0], - colors: [ colors[nextIdx] ] - } - })); - }; - - const figure = buildFigureString(selection); - const previewSrc = buildImagingUrl(imagingUrl, figure, gender); - - const handleAvatarSubmit = async (event: FormEvent) => - { - event.preventDefault(); - setLocalError(null); - - const trimmed = username.trim(); - if(!trimmed) - { - setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.')); - return; - } - if(trimmed.length < 3 || trimmed.length > 16) - { - setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 3–16 characters.')); - return; - } - - if(turnstileEnabled && !turnstileToken) - { - setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.')); - return; - } - - setChecking(true); - try - { - const serverOk = await pingServer(); - if(!serverOk) - { - setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); - return; - } - const result = await onCheckUsername(trimmed); - if(!result.available) - { - setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.')); - return; - } - } - finally - { - setChecking(false); - } - - setStep('room'); - }; - - const submitRegistration = (templateId: number | null) => - { - onSubmit({ - username: username.trim(), - email: email.trim(), - password, - figure, - gender, - turnstileToken, - templateId - }, resetWidget); - }; - - const handleRoomSubmit = (event: FormEvent) => - { - event.preventDefault(); - setLocalError(null); - submitRegistration(selectedTemplateId); - }; - - const busy = submitting || checking || pingingServer; - const serverOffline = serverReachable === false; - - return ( -
-
-
-
- { t('nitro.login.register.title', 'Habbo Details') } - -
- - { step === 'credentials' && -
-
- { t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') } -
- { serverOffline && -
- { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } - -
- } -
- - setEmail(e.target.value) } /> -
-
- - setPassword(e.target.value) } /> -
-
- - setConfirm(e.target.value) } /> -
- { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- 1/3 - -
-
- } - - { step === 'avatar' && -
-
- { t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') } -
- { serverOffline && -
- { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } - -
- } -
- setUsername(e.target.value) } /> -
- -
- - -
- -
-
- { PART_ROWS.map(setType => - { - const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); - return ( -
- -
- { - { - (e.currentTarget).style.visibility = 'hidden'; - } } /> -
- -
- ); - }) } -
- -
- Habbo preview - { - (e.currentTarget).style.visibility = 'hidden'; - } } /> -
- -
- { PART_ROWS.map(setType => - { - const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0; - const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor; - const swatchHex = hexFor(setType, currentColor); - return ( -
- -
- -
- ); - }) } -
-
- -
- -
- - { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} - -
- - 2/3 - -
- - } - - { step === 'room' && -
-
- { t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') } -
- { serverOffline && -
- { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } - -
- } - -
- - - { roomTemplates === null &&
{ t('nitro.login.register.room.loading', 'Loading rooms…') }
} - - { roomTemplates !== null && roomTemplates.map(template => ( - - )) } -
- - { roomTemplatesError &&
{ roomTemplatesError }
} - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} - -
- - 3/3 - -
-
- } -
-
-
- ); -}; diff --git a/src/components/login/components/shared.ts b/src/components/login/components/shared.ts deleted file mode 100644 index 53e30ed..0000000 --- a/src/components/login/components/shared.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface DialogSharedProps -{ - onCancel: () => void; - submitting: boolean; - error: string | null; - info: string | null; - turnstileEnabled: boolean; - turnstileSiteKey: string; -} diff --git a/tests/friendly-time.test.ts b/tests/friendly-time.test.ts new file mode 100644 index 0000000..3753278 --- /dev/null +++ b/tests/friendly-time.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; + +/** + * Mock LocalizeText (which transitively imports @nitrots/nitro-renderer) + * with a deterministic stub. The stub returns `key|amount` so each test + * can assert both the bucket FriendlyTime chose AND the value it computed. + */ +vi.mock('../src/api/utils/LocalizeText', () => ({ + LocalizeText: (key: string, _params?: string[], replacements?: string[]) => + `${ key }|${ replacements?.[0] ?? '' }` +})); + +import { FriendlyTime } from '../src/api/utils/FriendlyTime'; + +const MINUTE = 60; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const MONTH = 30 * DAY; +const YEAR = 365 * DAY; + +describe('FriendlyTime.format', () => +{ + it('uses the seconds bucket for small values', () => + { + expect(FriendlyTime.format(5)).toBe('friendlytime.seconds|5'); + expect(FriendlyTime.format(0)).toBe('friendlytime.seconds|0'); + }); + + it('uses the minutes bucket once we cross 3 * 60s (default threshold)', () => + { + expect(FriendlyTime.format(4 * MINUTE)).toBe('friendlytime.minutes|4'); + expect(FriendlyTime.format(10 * MINUTE)).toBe('friendlytime.minutes|10'); + }); + + it('uses the hours bucket above 3 * HOUR', () => + { + expect(FriendlyTime.format(4 * HOUR)).toBe('friendlytime.hours|4'); + }); + + it('uses the days bucket above 3 * DAY', () => + { + expect(FriendlyTime.format(5 * DAY)).toBe('friendlytime.days|5'); + }); + + it('uses the months bucket above 3 * MONTH', () => + { + expect(FriendlyTime.format(4 * MONTH)).toBe('friendlytime.months|4'); + }); + + it('uses the years bucket above 3 * YEAR', () => + { + expect(FriendlyTime.format(4 * YEAR)).toBe('friendlytime.years|4'); + }); + + it('rounds half-hours correctly inside the hours bucket', () => + { + // 4.5 hours -> rounds to 5 + expect(FriendlyTime.format((4 * HOUR) + (30 * MINUTE))).toBe('friendlytime.hours|5'); + }); + + it('threshold=1 lets the larger bucket win sooner', () => + { + // With default threshold=3, 90s would stay in "seconds"; with threshold=1 + // it crosses into "minutes" (90s > 1*60s). + expect(FriendlyTime.format(90, '', 1)).toBe('friendlytime.minutes|2'); + }); + + it('key suffix is appended to the bucket key', () => + { + // Useful for plurals / variants ('s' for singular fallback, etc.) + expect(FriendlyTime.format(5, '.foo')).toBe('friendlytime.seconds.foo|5'); + expect(FriendlyTime.format(4 * HOUR, '.foo')).toBe('friendlytime.hours.foo|4'); + }); +}); + +describe('FriendlyTime.shortFormat', () => +{ + it('uses the .short variant of each bucket', () => + { + expect(FriendlyTime.shortFormat(5)).toBe('friendlytime.seconds.short|5'); + expect(FriendlyTime.shortFormat(4 * MINUTE)).toBe('friendlytime.minutes.short|4'); + expect(FriendlyTime.shortFormat(4 * HOUR)).toBe('friendlytime.hours.short|4'); + expect(FriendlyTime.shortFormat(5 * DAY)).toBe('friendlytime.days.short|5'); + expect(FriendlyTime.shortFormat(4 * MONTH)).toBe('friendlytime.months.short|4'); + expect(FriendlyTime.shortFormat(4 * YEAR)).toBe('friendlytime.years.short|4'); + }); + + it('respects the optional key suffix and threshold', () => + { + expect(FriendlyTime.shortFormat(2 * MINUTE, '.bar', 1)).toBe('friendlytime.minutes.short.bar|2'); + }); +}); + +describe('FriendlyTime.getLocalization', () => +{ + it('formats an arbitrary key and amount with the (amount, AMOUNT) replacements', () => + { + expect(FriendlyTime.getLocalization('whatever', 42)).toBe('whatever|42'); + }); +});