mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
Merge branch 'Dev' into merge-duckie-main-2026-05-06
This commit is contained in:
@@ -17,13 +17,20 @@ export const setAccessToken = (token: string | null | undefined, expiresAt?: num
|
||||
window.localStorage.removeItem(EXPIRES_KEY);
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
export const getAccessToken = (): string =>
|
||||
{
|
||||
try { return window.localStorage.getItem(STORAGE_KEY) ?? ''; }
|
||||
catch { return ''; }
|
||||
try
|
||||
{
|
||||
return window.localStorage.getItem(STORAGE_KEY) ?? '';
|
||||
}
|
||||
catch
|
||||
{
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getAccessTokenExpiresAt = (): number =>
|
||||
@@ -35,7 +42,10 @@ export const getAccessTokenExpiresAt = (): number =>
|
||||
const value = parseInt(raw, 10);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
catch { return 0; }
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAccessToken = (): void =>
|
||||
|
||||
@@ -87,7 +87,7 @@ export class AvatarEditorThumbnailsHelper
|
||||
AvatarFigurePartType.PET,
|
||||
'ptl',
|
||||
'ptr',
|
||||
AvatarFigurePartType.MISC,
|
||||
AvatarFigurePartType.MISC,
|
||||
'mcl',
|
||||
'mcr',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dedupeBadges } from './dedupeBadges';
|
||||
|
||||
describe('dedupeBadges', () =>
|
||||
{
|
||||
it('returns an empty array for an empty input', () =>
|
||||
{
|
||||
expect(dedupeBadges([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves unique badges in slot order', () =>
|
||||
{
|
||||
expect(dedupeBadges([ 'a', 'b', 'c' ])).toEqual([ 'a', 'b', 'c' ]);
|
||||
});
|
||||
|
||||
it('replaces duplicate slots with empty strings to preserve slot indices', () =>
|
||||
{
|
||||
expect(dedupeBadges([ 'a', 'b', 'a', 'c' ])).toEqual([ 'a', 'b', '', 'c' ]);
|
||||
});
|
||||
|
||||
it('normalizes falsy entries (null, undefined, "") to empty string', () =>
|
||||
{
|
||||
// server sometimes returns null/undefined for unused slots
|
||||
const input = [ 'a', null as unknown as string, '', undefined as unknown as string, 'b' ];
|
||||
|
||||
expect(dedupeBadges(input)).toEqual([ 'a', '', '', '', 'b' ]);
|
||||
});
|
||||
|
||||
it('only keeps the FIRST occurrence of each unique code', () =>
|
||||
{
|
||||
expect(dedupeBadges([ 'a', 'a', 'a' ])).toEqual([ 'a', '', '' ]);
|
||||
});
|
||||
|
||||
it('is order-sensitive: identical multisets but different orderings yield different outputs', () =>
|
||||
{
|
||||
expect(dedupeBadges([ 'a', 'b', 'a' ])).toEqual([ 'a', 'b', '' ]);
|
||||
expect(dedupeBadges([ 'b', 'a', 'a' ])).toEqual([ 'b', 'a', '' ]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Strips duplicate badge codes from a server-supplied badge array,
|
||||
* preserving slot indices: a duplicate is replaced by an empty string
|
||||
* rather than shifted out, so badge[i] still corresponds to slot i.
|
||||
*
|
||||
* Empty / falsy entries are normalized to '' (some servers emit null
|
||||
* inside the array for unused slots).
|
||||
*/
|
||||
export const dedupeBadges = (badges: ReadonlyArray<string>): string[] =>
|
||||
{
|
||||
const seen = new Set<string>();
|
||||
|
||||
return badges.map(code =>
|
||||
{
|
||||
if(!code || seen.has(code)) return '';
|
||||
|
||||
seen.add(code);
|
||||
|
||||
return code;
|
||||
});
|
||||
};
|
||||
@@ -3,5 +3,6 @@ export * from './AvatarEditorColorSorter';
|
||||
export * from './AvatarEditorPartSorter';
|
||||
export * from './AvatarEditorThumbnailsHelper';
|
||||
export * from './BuildPurchasableClothingFigure';
|
||||
export * from './dedupeBadges';
|
||||
export * from './IAvatarEditorCategory';
|
||||
export * from './IAvatarEditorCategoryPartItem';
|
||||
|
||||
@@ -31,8 +31,14 @@ export interface CustomBadgeError
|
||||
|
||||
const interpolate = (value: string): string =>
|
||||
{
|
||||
try { return GetConfiguration().interpolate(value); }
|
||||
catch { return value; }
|
||||
try
|
||||
{
|
||||
return GetConfiguration().interpolate(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfigUrl = (key: string, fallback: string): string =>
|
||||
@@ -61,8 +67,14 @@ const parseJson = async <T>(response: Response): Promise<T> =>
|
||||
{
|
||||
const text = await response.text();
|
||||
if(!text) return {} as T;
|
||||
try { return JSON.parse(text) as T; }
|
||||
catch { throw new Error('Invalid response from server.'); }
|
||||
try
|
||||
{
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new Error('Invalid response from server.');
|
||||
}
|
||||
};
|
||||
|
||||
const throwOnError = async (response: Response): Promise<void> =>
|
||||
@@ -129,8 +141,14 @@ const injectTextsIntoLocalization = (texts: Record<string, string> | null | unde
|
||||
{
|
||||
if(!texts) return;
|
||||
let manager: ReturnType<typeof GetLocalizationManager> | null = null;
|
||||
try { manager = GetLocalizationManager(); }
|
||||
catch { return; }
|
||||
try
|
||||
{
|
||||
manager = GetLocalizationManager();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!manager || typeof manager.setValue !== 'function') return;
|
||||
for(const key of Object.keys(texts))
|
||||
{
|
||||
@@ -152,7 +170,8 @@ export const ensureCustomBadgeTexts = (): Promise<void> =>
|
||||
const payload = await parseJson<{ texts: Record<string, string> }>(response);
|
||||
injectTextsIntoLocalization(payload.texts);
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
})();
|
||||
return customBadgeTextsLoadPromise;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export class CatalogNode implements ICatalogNode
|
||||
private _depth: number = 0;
|
||||
private _localization: string = '';
|
||||
private _pageId: number = -1;
|
||||
private _parentId: number = -1;
|
||||
private _pageName: string = '';
|
||||
private _iconId: number = 0;
|
||||
private _children: ICatalogNode[];
|
||||
@@ -21,6 +22,7 @@ export class CatalogNode implements ICatalogNode
|
||||
this._parent = parent;
|
||||
this._localization = node.localization;
|
||||
this._pageId = node.pageId;
|
||||
this._parentId = node.parentId;
|
||||
this._pageName = node.pageName;
|
||||
this._iconId = node.icon;
|
||||
this._children = [];
|
||||
@@ -82,6 +84,11 @@ export class CatalogNode implements ICatalogNode
|
||||
return this._pageId;
|
||||
}
|
||||
|
||||
public get parentId(): number
|
||||
{
|
||||
return this._parentId;
|
||||
}
|
||||
|
||||
public get pageName(): string
|
||||
{
|
||||
return this._pageName;
|
||||
|
||||
@@ -15,7 +15,7 @@ export class FurnitureOffer implements IPurchasableOffer
|
||||
constructor(furniData: IFurnitureData)
|
||||
{
|
||||
this._furniData = furniData;
|
||||
this._product = (new Product(this._furniData.type, this._furniData.id, this._furniData.customParams, 1, GetProductDataForLocalization(this._furniData.className), this._furniData) as IProduct);
|
||||
this._product = (new Product(this._furniData.type, this._furniData.id, this._furniData.customParams, 1, GetProductDataForLocalization(this._furniData.className), this._furniData));
|
||||
}
|
||||
|
||||
public activate(): void
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ICatalogNode
|
||||
readonly isLeaf: boolean;
|
||||
readonly localization: string;
|
||||
readonly pageId: number;
|
||||
readonly parentId: number;
|
||||
readonly pageName: string;
|
||||
readonly iconId: number;
|
||||
readonly children: ICatalogNode[];
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ClubGiftInfoParser, ClubOfferData, HabboGroupEntryData, MarketplaceConfigurationMessageParser } from '@nitrots/nitro-renderer';
|
||||
import { CatalogPetPalette } from './CatalogPetPalette';
|
||||
import { GiftWrappingConfiguration } from './GiftWrappingConfiguration';
|
||||
|
||||
export interface ICatalogOptions
|
||||
{
|
||||
groups?: HabboGroupEntryData[];
|
||||
petPalettes?: CatalogPetPalette[];
|
||||
clubOffers?: ClubOfferData[];
|
||||
clubOffersByWindowId?: Record<number, ClubOfferData[]>;
|
||||
clubGifts?: ClubGiftInfoParser;
|
||||
giftConfiguration?: GiftWrappingConfiguration;
|
||||
marketplaceConfiguration?: MarketplaceConfigurationMessageParser;
|
||||
}
|
||||
@@ -24,4 +24,5 @@ export interface IPurchasableOffer
|
||||
products: IProduct[];
|
||||
itemIds: string;
|
||||
haveOffer: boolean;
|
||||
clone?(): IPurchasableOffer;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export * from './FurnitureOffer';
|
||||
export * from './GetImageIconUrlForProduct';
|
||||
export * from './GiftWrappingConfiguration';
|
||||
export * from './ICatalogNode';
|
||||
export * from './ICatalogOptions';
|
||||
export * from './ICatalogPage';
|
||||
export * from './IMarketplaceSearchOptions';
|
||||
export * from './IPageLocalization';
|
||||
|
||||
@@ -9,5 +9,5 @@ export const GetGroupChatData = (extraData: string) =>
|
||||
const figure = splitData[1];
|
||||
const userId = parseInt(splitData[2]);
|
||||
|
||||
return ({ username: username, figure: figure, userId: userId } as IGroupChatData);
|
||||
return ({ username: username, figure: figure, userId: userId });
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export * from './purse';
|
||||
export * from './room';
|
||||
export * from './room/events';
|
||||
export * from './room/widgets';
|
||||
export * from './ui-settings';
|
||||
export * from './user';
|
||||
export * from './utils';
|
||||
export * from './wired';
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { GetCommunication, IMessageComposer, IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
|
||||
import { SendMessageComposer } from '../nitro/SendMessageComposer';
|
||||
|
||||
export interface NitroQueryConfig<TParser extends IMessageEvent, TData>
|
||||
{
|
||||
/**
|
||||
* Stable key for caching/deduping. Convention:
|
||||
* `['nitro', '<domain>', '<request>', ...args]`.
|
||||
*/
|
||||
key: QueryKey;
|
||||
/**
|
||||
* Factory for the request composer. Called once per query execution.
|
||||
* `null` skips sending (useful when the server pushes the event
|
||||
* unprompted — you only want subscription, not a request).
|
||||
*/
|
||||
request: (() => IMessageComposer<unknown[]>) | null;
|
||||
/**
|
||||
* The parser class to listen for as the response.
|
||||
*/
|
||||
parser: typeof MessageEvent;
|
||||
/**
|
||||
* Maps the parser event to the data the component cares about.
|
||||
*/
|
||||
select?: (event: TParser) => TData;
|
||||
/**
|
||||
* Optional predicate to ignore parser events that don't match this
|
||||
* query (typically used as a correlation-key filter on a globally
|
||||
* shared event stream — e.g. `e => e.getParser()?.roomId === roomId`).
|
||||
* When the predicate returns false, the listener stays registered
|
||||
* and keeps waiting; the timeout still applies.
|
||||
*/
|
||||
accept?: (event: TParser) => boolean;
|
||||
/**
|
||||
* Max time to wait for the response before rejecting (default 15s).
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Forwarded to TanStack Query.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
refetchOnMount?: boolean | 'always';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Nitro composer/parser request-response pair as a TanStack Query
|
||||
* `useQuery` call. The returned object is the standard TanStack result —
|
||||
* `{ data, isLoading, isError, error, refetch, ... }`.
|
||||
*
|
||||
* Behavior:
|
||||
* - On the first subscribe, registers the parser, sends the composer,
|
||||
* resolves the Promise with the selected payload when the parser fires.
|
||||
* - Default `staleTime` is the QueryClient default (30s).
|
||||
* - Subsequent mounts within `staleTime` get the cached value immediately;
|
||||
* the request is NOT re-sent.
|
||||
* - Identical concurrent calls (same `key`) are deduped.
|
||||
*/
|
||||
export const useNitroQuery = <TParser extends IMessageEvent, TData = TParser>(
|
||||
config: NitroQueryConfig<TParser, TData>
|
||||
): UseQueryResult<TData> =>
|
||||
{
|
||||
const { key, request, parser, select, accept, timeoutMs = 15_000, enabled, staleTime, refetchOnMount } = config;
|
||||
|
||||
const options: UseQueryOptions<TData, Error, TData> = {
|
||||
queryKey: key,
|
||||
queryFn: () => awaitNitroResponse<TParser, TData>({ request, parser, select, accept, timeoutMs }),
|
||||
enabled,
|
||||
staleTime,
|
||||
refetchOnMount
|
||||
};
|
||||
|
||||
return useQuery(options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lower-level helper: send a composer (if any) and resolve with the next
|
||||
* matching parser event. Exposed so `queryClient.fetchQuery({...})` callers
|
||||
* can use the same plumbing imperatively.
|
||||
*/
|
||||
export const awaitNitroResponse = <TParser extends IMessageEvent, TData>(
|
||||
config: Pick<NitroQueryConfig<TParser, TData>, 'request' | 'parser' | 'select' | 'accept' | 'timeoutMs'>
|
||||
): Promise<TData> =>
|
||||
new Promise<TData>((resolve, reject) =>
|
||||
{
|
||||
const { request, parser: ParserCtor, select, accept, timeoutMs = 15_000 } = config;
|
||||
|
||||
let settled = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
let listener: IMessageEvent | undefined = undefined;
|
||||
|
||||
const cleanup = () =>
|
||||
{
|
||||
if(timeoutHandle !== null) clearTimeout(timeoutHandle);
|
||||
if(listener) GetCommunication().removeMessageEvent(listener);
|
||||
};
|
||||
|
||||
listener = new (ParserCtor as any)((event: TParser) =>
|
||||
{
|
||||
if(settled) return;
|
||||
if(accept && !accept(event)) return;
|
||||
settled = true;
|
||||
|
||||
cleanup();
|
||||
|
||||
try
|
||||
{
|
||||
resolve(select ? select(event) : (event as unknown as TData));
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
|
||||
GetCommunication().registerMessageEvent(listener);
|
||||
|
||||
timeoutHandle = setTimeout(() =>
|
||||
{
|
||||
if(settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`NitroQuery timed out after ${ timeoutMs }ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
if(request) SendMessageComposer(request());
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './createNitroQuery';
|
||||
export * from './useNitroEventInvalidator';
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMessageEvent } from '../../hooks/events/useMessageEvent';
|
||||
|
||||
/**
|
||||
* Invalidate a TanStack query slot every time the renderer pushes the
|
||||
* matching parser event. Companion to useNitroQuery for the case where
|
||||
* the server can push fresh data unprompted (e.g. ClubGiftInfoEvent
|
||||
* fires both as the response to GetClubGiftInfo and again after the
|
||||
* user claims a gift via SelectClubGiftComposer).
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const { data: clubGifts } = useNitroQuery({
|
||||
* key: ['nitro', 'catalog', 'clubGifts'],
|
||||
* request: () => new GetClubGiftInfo(),
|
||||
* parser: ClubGiftInfoEvent,
|
||||
* select: e => e.getParser(),
|
||||
* });
|
||||
*
|
||||
* // re-fetch on every server push:
|
||||
* useNitroEventInvalidator(ClubGiftInfoEvent, ['nitro', 'catalog', 'clubGifts']);
|
||||
*
|
||||
* Optional `accept` predicate filters out events that don't belong to
|
||||
* this query slot — useful when the same parser is multiplexed across
|
||||
* multiple correlated queries (mirrors useNitroQuery.accept).
|
||||
*
|
||||
* Implementation: the renderer push triggers `queryClient.invalidateQueries`,
|
||||
* which marks the slot stale; the next subscriber render triggers a
|
||||
* fresh fetch via useNitroQuery's queryFn. If nobody is currently
|
||||
* subscribed, the invalidation is a no-op (TanStack drops stale entries
|
||||
* with no active observers per its garbage-collection policy).
|
||||
*/
|
||||
export const useNitroEventInvalidator = <T extends IMessageEvent>(
|
||||
eventType: typeof MessageEvent,
|
||||
queryKey: QueryKey,
|
||||
accept?: (event: T) => boolean
|
||||
) =>
|
||||
{
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useMessageEvent<T>(eventType, event =>
|
||||
{
|
||||
if(accept && !accept(event)) return;
|
||||
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
});
|
||||
};
|
||||
@@ -20,10 +20,11 @@ export class AvatarInfoUser implements IAvatarInfo
|
||||
public prefixFont: string = '';
|
||||
public displayOrder: string = 'icon-prefix-name';
|
||||
public achievementScore: number = 0;
|
||||
public backgroundId: number = 0;
|
||||
public backgroundId: number = 0;
|
||||
public standId: number = 0;
|
||||
public overlayId: number = 0;
|
||||
public cardBackgroundId: number = 0;
|
||||
public borderId: number = 0;
|
||||
public webID: number = 0;
|
||||
public xp: number = 0;
|
||||
public userType: number = -1;
|
||||
|
||||
@@ -190,10 +190,11 @@ export class AvatarInfoUtilities
|
||||
userInfo.prefixEffect = userData.prefixEffect;
|
||||
userInfo.prefixFont = userData.prefixFont;
|
||||
userInfo.displayOrder = userData.displayOrder;
|
||||
userInfo.backgroundId = userData.background;
|
||||
userInfo.backgroundId = userData.background;
|
||||
userInfo.standId = userData.stand;
|
||||
userInfo.overlayId = userData.overlay;
|
||||
userInfo.cardBackgroundId = userData.cardBackground ?? 0;
|
||||
userInfo.borderId = (userData as any).borderId ?? 0;
|
||||
userInfo.achievementScore = userData.activityPoints;
|
||||
userInfo.webID = userData.webID;
|
||||
userInfo.roomIndex = userData.roomIndex;
|
||||
|
||||
@@ -9,9 +9,11 @@ export class chooserSelectionVisualizer
|
||||
{
|
||||
if (this.animationFrameId !== null) return;
|
||||
|
||||
const animate = (time: number) => {
|
||||
const animate = (time: number) =>
|
||||
{
|
||||
const elapsed = time / 1000; // Convert to seconds
|
||||
this.activeFilters.forEach(filter => {
|
||||
this.activeFilters.forEach(filter =>
|
||||
{
|
||||
filter.time = elapsed; // Update time uniform
|
||||
});
|
||||
this.animationFrameId = requestAnimationFrame(animate);
|
||||
@@ -22,7 +24,8 @@ export class chooserSelectionVisualizer
|
||||
|
||||
private static stopAnimation(): void
|
||||
{
|
||||
if (this.animationFrameId !== null) {
|
||||
if (this.animationFrameId !== null)
|
||||
{
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
@@ -45,7 +48,7 @@ export class chooserSelectionVisualizer
|
||||
|
||||
for (const sprite of visualization.sprites)
|
||||
{
|
||||
if (sprite.blendMode === 1) continue;
|
||||
if (sprite.blendMode === 'add') continue;
|
||||
const existing = (sprite.filters || []).filter(f => !(f instanceof ChooserSelectionFilter));
|
||||
sprite.filters = [...existing, filter];
|
||||
}
|
||||
@@ -69,7 +72,8 @@ export class chooserSelectionVisualizer
|
||||
sprite.filters = (sprite.filters || []).filter(f => !(f instanceof ChooserSelectionFilter));
|
||||
}
|
||||
|
||||
if (this.activeFilters.size === 0) {
|
||||
if (this.activeFilters.size === 0)
|
||||
{
|
||||
this.stopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AvatarFigurePartType, GetAvatarRenderManager, IAvatarFigureContainer }
|
||||
|
||||
export class MannequinUtilities
|
||||
{
|
||||
public static MANNEQUIN_FIGURE = [ 'hd', 99999, [ 99998 ] ];
|
||||
public static MANNEQUIN_FIGURE: [ string, number, number[] ] = [ 'hd', 99999, [ 99998 ] ];
|
||||
public static MANNEQUIN_CLOTHING_PART_TYPES = [
|
||||
AvatarFigurePartType.CHEST_ACCESSORY,
|
||||
AvatarFigurePartType.COAT_CHEST,
|
||||
@@ -33,6 +33,6 @@ export class MannequinUtilities
|
||||
figureContainer.removePart(part);
|
||||
}
|
||||
|
||||
figureContainer.updatePart((this.MANNEQUIN_FIGURE[0] as string), (this.MANNEQUIN_FIGURE[1] as number), (this.MANNEQUIN_FIGURE[2] as number[]));
|
||||
figureContainer.updatePart((this.MANNEQUIN_FIGURE[0]), (this.MANNEQUIN_FIGURE[1]), (this.MANNEQUIN_FIGURE[2]));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { GetCommunication, UiSettingsDataEvent, UiSettingsLoadComposer, UiSettingsSaveComposer } from '@nitrots/nitro-renderer';
|
||||
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { DEFAULT_UI_SETTINGS, IUiSettings } from './IUiSettings';
|
||||
|
||||
/**
|
||||
* UI settings currently persist to localStorage only. The cross-device
|
||||
* server-side sync (UiSettingsLoadComposer / UiSettingsSaveComposer /
|
||||
* UiSettingsDataEvent) is a planned addition that requires both the
|
||||
* renderer composer classes and the Arcturus packet handlers — none of
|
||||
* which exist yet. Until those land, settings stay per-browser.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'nitro.ui.settings';
|
||||
|
||||
interface IUiSettingsContext
|
||||
@@ -18,8 +25,10 @@ interface IUiSettingsContext
|
||||
const UiSettingsContext = createContext<IUiSettingsContext>({
|
||||
settings: DEFAULT_UI_SETTINGS,
|
||||
isCustomActive: false,
|
||||
updateSettings: () => {},
|
||||
resetSettings: () => {},
|
||||
updateSettings: () =>
|
||||
{},
|
||||
resetSettings: () =>
|
||||
{},
|
||||
getHeaderStyle: () => ({}),
|
||||
getTabsStyle: () => ({}),
|
||||
getAccentColor: () => DEFAULT_UI_SETTINGS.headerColor
|
||||
@@ -42,7 +51,8 @@ const loadSettings = (): IUiSettings =>
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if(stored) return { ...DEFAULT_UI_SETTINGS, ...JSON.parse(stored) };
|
||||
}
|
||||
catch(e) {}
|
||||
catch(e)
|
||||
{}
|
||||
|
||||
return { ...DEFAULT_UI_SETTINGS };
|
||||
};
|
||||
@@ -53,61 +63,20 @@ const saveSettings = (settings: IUiSettings): void =>
|
||||
{
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
catch(e) {}
|
||||
catch(e)
|
||||
{}
|
||||
};
|
||||
|
||||
const sendComposer = (composer: any): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
GetCommunication()?.connection?.send(composer);
|
||||
}
|
||||
catch(e) {}
|
||||
};
|
||||
const ALL_CSS_VARS = [
|
||||
'--ui-accent-color', '--ui-accent-dark',
|
||||
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
|
||||
'--ui-btn-primary-bg', '--ui-btn-primary-border',
|
||||
'--ui-dark-bg', '--ui-dark-border'
|
||||
];
|
||||
|
||||
export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
|
||||
{
|
||||
const [ settings, setSettings ] = useState<IUiSettings>(loadSettings);
|
||||
const serverSaveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
// Carica dal server al mount e ascolta risposta
|
||||
useEffect(() =>
|
||||
{
|
||||
sendComposer(new UiSettingsLoadComposer());
|
||||
|
||||
const connection = GetCommunication()?.connection;
|
||||
|
||||
if(!connection) return;
|
||||
|
||||
const handler = (event: any) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const json = parser?.settingsJson;
|
||||
|
||||
if(json && json !== '{}')
|
||||
{
|
||||
const serverSettings = { ...DEFAULT_UI_SETTINGS, ...JSON.parse(json) };
|
||||
setSettings(serverSettings);
|
||||
saveSettings(serverSettings);
|
||||
}
|
||||
}
|
||||
catch(e) {}
|
||||
};
|
||||
|
||||
connection.addMessageEvent(new UiSettingsDataEvent(handler));
|
||||
}, []);
|
||||
|
||||
const syncToServer = useCallback((settingsToSave: IUiSettings) =>
|
||||
{
|
||||
if(serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
|
||||
|
||||
serverSaveTimerRef.current = setTimeout(() =>
|
||||
{
|
||||
sendComposer(new UiSettingsSaveComposer(JSON.stringify(settingsToSave)));
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const updateSettings = useCallback((partial: Partial<IUiSettings>) =>
|
||||
{
|
||||
@@ -115,18 +84,16 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
|
||||
{
|
||||
const updated = { ...prev, ...partial };
|
||||
saveSettings(updated);
|
||||
syncToServer(updated);
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [ syncToServer ]);
|
||||
}, []);
|
||||
|
||||
const resetSettings = useCallback(() =>
|
||||
{
|
||||
setSettings({ ...DEFAULT_UI_SETTINGS });
|
||||
saveSettings(DEFAULT_UI_SETTINGS);
|
||||
syncToServer(DEFAULT_UI_SETTINGS);
|
||||
}, [ syncToServer ]);
|
||||
}, []);
|
||||
|
||||
const getHeaderStyle = useCallback((): React.CSSProperties =>
|
||||
{
|
||||
@@ -183,13 +150,6 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
|
||||
|
||||
const isCustomActive = settings.colorMode !== 'default';
|
||||
|
||||
const ALL_CSS_VARS = [
|
||||
'--ui-accent-color', '--ui-accent-dark',
|
||||
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
|
||||
'--ui-btn-primary-bg', '--ui-btn-primary-border',
|
||||
'--ui-dark-bg', '--ui-dark-border'
|
||||
];
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const root = document.documentElement;
|
||||
@@ -215,9 +175,9 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
|
||||
}, [ settings ]);
|
||||
|
||||
return (
|
||||
<UiSettingsContext.Provider value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
|
||||
<UiSettingsContext value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
|
||||
{ children }
|
||||
</UiSettingsContext.Provider>
|
||||
</UiSettingsContext>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ProductImageUtility
|
||||
imageUrl = GetRoomEngine().getFurnitureFloorIconUrl(furniClassId);
|
||||
break;
|
||||
case FurnitureType.WALL:
|
||||
const productCategory = this.getProductCategory(CatalogPageMessageProductData.I, furniClassId);
|
||||
const productCategory = this.getProductCategory(productType, furniClassId);
|
||||
|
||||
if(productCategory === 1)
|
||||
{
|
||||
@@ -32,7 +32,7 @@ export class ProductImageUtility
|
||||
}
|
||||
}
|
||||
break;
|
||||
case FurnitureType.EFFECT:
|
||||
case FurnitureType.EFFECT:
|
||||
// fx_icon_furniClassId_png
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,12 @@ export const SetRememberLogin = (data: RememberLoginData): void =>
|
||||
{
|
||||
if(!data?.token?.length && !data?.ssoTicket?.length) return;
|
||||
|
||||
try { window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data)); }
|
||||
catch {}
|
||||
try
|
||||
{
|
||||
window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data));
|
||||
}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
export const ClearRememberLogin = (): void =>
|
||||
@@ -64,7 +68,8 @@ export const ClearRememberLogin = (): void =>
|
||||
window.localStorage.removeItem(REMEMBER_LOGIN_KEY);
|
||||
window.localStorage.removeItem(LEGACY_REMEMBER_LOGIN_KEY);
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
export const StoreRememberLoginFromPayload = (payload: Record<string, unknown>, username?: string, ssoTicket?: string): void =>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ColorUtils } from './ColorUtils';
|
||||
import { FixedSizeStack } from './FixedSizeStack';
|
||||
import { LocalizeFormattedNumber } from './LocalizeFormattedNumber';
|
||||
|
||||
describe('LocalizeFormattedNumber', () =>
|
||||
{
|
||||
it('returns "0" for zero / NaN / null / undefined', () =>
|
||||
{
|
||||
expect(LocalizeFormattedNumber(0)).toBe('0');
|
||||
expect(LocalizeFormattedNumber(NaN)).toBe('0');
|
||||
expect(LocalizeFormattedNumber(null)).toBe('0');
|
||||
expect(LocalizeFormattedNumber(undefined as unknown as number)).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps numbers under 1000 unchanged', () =>
|
||||
{
|
||||
expect(LocalizeFormattedNumber(42)).toBe('42');
|
||||
expect(LocalizeFormattedNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('inserts a thin space every 3 digits for >=1000', () =>
|
||||
{
|
||||
expect(LocalizeFormattedNumber(1000)).toBe('1 000');
|
||||
expect(LocalizeFormattedNumber(1_234_567)).toBe('1 234 567');
|
||||
expect(LocalizeFormattedNumber(10_000_000)).toBe('10 000 000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColorUtils', () =>
|
||||
{
|
||||
describe('makeColorHex', () =>
|
||||
{
|
||||
it('prepends "#" to the given color string', () =>
|
||||
{
|
||||
expect(ColorUtils.makeColorHex('ff0000')).toBe('#ff0000');
|
||||
expect(ColorUtils.makeColorHex('abc')).toBe('#abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeColorNumberHex', () =>
|
||||
{
|
||||
it('pads to 6 hex chars and prepends "#"', () =>
|
||||
{
|
||||
expect(ColorUtils.makeColorNumberHex(0xff0000)).toBe('#ff0000');
|
||||
expect(ColorUtils.makeColorNumberHex(0x00ff00)).toBe('#00ff00');
|
||||
expect(ColorUtils.makeColorNumberHex(0)).toBe('#000000');
|
||||
});
|
||||
|
||||
it('pads short hex values with leading zeros', () =>
|
||||
{
|
||||
expect(ColorUtils.makeColorNumberHex(0xff)).toBe('#0000ff');
|
||||
expect(ColorUtils.makeColorNumberHex(1)).toBe('#000001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFromHex', () =>
|
||||
{
|
||||
it('parses a "#"-prefixed hex string to a number', () =>
|
||||
{
|
||||
expect(ColorUtils.convertFromHex('#ff0000')).toBe(0xff0000);
|
||||
expect(ColorUtils.convertFromHex('#000000')).toBe(0);
|
||||
expect(ColorUtils.convertFromHex('#ffffff')).toBe(0xffffff);
|
||||
});
|
||||
|
||||
it('also handles strings without the leading "#"', () =>
|
||||
{
|
||||
expect(ColorUtils.convertFromHex('00ff00')).toBe(0x00ff00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('int_to_8BitVals / eight_bitVals_to_int', () =>
|
||||
{
|
||||
it('roundtrips: int -> [a,r,g,b] -> int', () =>
|
||||
{
|
||||
const original = 0x12345678;
|
||||
const [ a, b, c, d ] = ColorUtils.int_to_8BitVals(original);
|
||||
expect(a).toBe(0x12);
|
||||
expect(b).toBe(0x34);
|
||||
expect(c).toBe(0x56);
|
||||
expect(d).toBe(0x78);
|
||||
expect(ColorUtils.eight_bitVals_to_int(a, b, c, d)).toBe(original);
|
||||
});
|
||||
|
||||
it('roundtrips zero', () =>
|
||||
{
|
||||
const parts = ColorUtils.int_to_8BitVals(0);
|
||||
expect(parts).toEqual([ 0, 0, 0, 0 ]);
|
||||
expect(ColorUtils.eight_bitVals_to_int(0, 0, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('int2rgb', () =>
|
||||
{
|
||||
it('produces rgba(r,g,b,1) for an RGB integer', () =>
|
||||
{
|
||||
expect(ColorUtils.int2rgb(0xff0000)).toBe('rgba(255,0,0,1)');
|
||||
expect(ColorUtils.int2rgb(0x00ff00)).toBe('rgba(0,255,0,1)');
|
||||
expect(ColorUtils.int2rgb(0x0000ff)).toBe('rgba(0,0,255,1)');
|
||||
});
|
||||
|
||||
it('returns black for 0', () =>
|
||||
{
|
||||
expect(ColorUtils.int2rgb(0)).toBe('rgba(0,0,0,1)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FixedSizeStack', () =>
|
||||
{
|
||||
it('grows up to maxSize then overwrites the oldest entry', () =>
|
||||
{
|
||||
const stack = new FixedSizeStack(3);
|
||||
|
||||
stack.addValue(10);
|
||||
stack.addValue(20);
|
||||
stack.addValue(30);
|
||||
|
||||
expect(stack.getMax()).toBe(30);
|
||||
expect(stack.getMin()).toBe(10);
|
||||
|
||||
// Capacity hit — 40 overwrites 10
|
||||
stack.addValue(40);
|
||||
expect(stack.getMin()).toBe(20);
|
||||
expect(stack.getMax()).toBe(40);
|
||||
|
||||
// 50 overwrites 20
|
||||
stack.addValue(50);
|
||||
expect(stack.getMin()).toBe(30);
|
||||
expect(stack.getMax()).toBe(50);
|
||||
});
|
||||
|
||||
it('reset clears all values', () =>
|
||||
{
|
||||
const stack = new FixedSizeStack(2);
|
||||
|
||||
stack.addValue(100);
|
||||
stack.addValue(200);
|
||||
|
||||
expect(stack.getMax()).toBe(200);
|
||||
|
||||
stack.reset();
|
||||
|
||||
stack.addValue(7);
|
||||
expect(stack.getMax()).toBe(7);
|
||||
expect(stack.getMin()).toBe(7);
|
||||
});
|
||||
|
||||
it('getMax with maxSize > inserted entries returns the inserted value', () =>
|
||||
{
|
||||
// FixedSizeStack iterates the whole maxSize window but the
|
||||
// unfilled slots are `undefined` which fail `> currentMax`, so
|
||||
// the inserted value wins.
|
||||
const stack = new FixedSizeStack(5);
|
||||
stack.addValue(42);
|
||||
|
||||
expect(stack.getMax()).toBe(42);
|
||||
});
|
||||
|
||||
it('getMax on an empty stack returns Number.MIN_VALUE', () =>
|
||||
{
|
||||
const stack = new FixedSizeStack(3);
|
||||
expect(stack.getMax()).toBe(Number.MIN_VALUE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CloneObject } from './CloneObject';
|
||||
import { ConvertSeconds } from './ConvertSeconds';
|
||||
import { LocalizeShortNumber } from './LocalizeShortNumber';
|
||||
import { GetWiredTimeLocale } from '../wired/GetWiredTimeLocale';
|
||||
import { WiredDateToString } from '../wired/WiredDateToString';
|
||||
import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from './PrefixUtils';
|
||||
|
||||
describe('ConvertSeconds', () =>
|
||||
{
|
||||
it('formats zero seconds as the dd:hh:mm:ss zero string', () =>
|
||||
{
|
||||
expect(ConvertSeconds(0)).toBe('00:00:00:00');
|
||||
});
|
||||
|
||||
it('formats one minute correctly', () =>
|
||||
{
|
||||
expect(ConvertSeconds(60)).toBe('00:00:01:00');
|
||||
});
|
||||
|
||||
it('formats one hour correctly', () =>
|
||||
{
|
||||
expect(ConvertSeconds(3600)).toBe('00:01:00:00');
|
||||
});
|
||||
|
||||
it('formats one day correctly', () =>
|
||||
{
|
||||
expect(ConvertSeconds(86400)).toBe('01:00:00:00');
|
||||
});
|
||||
|
||||
it('formats a mixed value (1d 2h 3m 4s)', () =>
|
||||
{
|
||||
expect(ConvertSeconds(86400 + 2 * 3600 + 3 * 60 + 4)).toBe('01:02:03:04');
|
||||
});
|
||||
|
||||
it('pads single-digit components with a leading zero', () =>
|
||||
{
|
||||
expect(ConvertSeconds(9)).toBe('00:00:00:09');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalizeShortNumber', () =>
|
||||
{
|
||||
it('returns "0" for zero, null, undefined, and NaN', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(0)).toBe('0');
|
||||
expect(LocalizeShortNumber(NaN)).toBe('0');
|
||||
expect(LocalizeShortNumber(null)).toBe('0');
|
||||
expect(LocalizeShortNumber(undefined as unknown as number)).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps numbers safely under 1000 unchanged (returns as-is)', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(42)).toBe('42');
|
||||
// Anything that rounds to >= 1.0K (i.e. >= 950) crosses into the K bucket
|
||||
expect(LocalizeShortNumber(949)).toBe('949');
|
||||
});
|
||||
|
||||
it('rounds 950..999 up into the K bucket (documented quirk)', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(950)).toBe('1K');
|
||||
expect(LocalizeShortNumber(999)).toBe('1K');
|
||||
});
|
||||
|
||||
it('uses K for thousands', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(1500)).toBe('1.5K');
|
||||
expect(LocalizeShortNumber(12_345)).toBe('12.3K');
|
||||
});
|
||||
|
||||
it('uses M for millions', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(2_500_000)).toBe('2.5M');
|
||||
});
|
||||
|
||||
it('uses B for billions', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(3_700_000_000)).toBe('3.7B');
|
||||
});
|
||||
|
||||
it('preserves the sign for negative values', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(-1500)).toBe('-1.5K');
|
||||
expect(LocalizeShortNumber(-2_500_000)).toBe('-2.5M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloneObject', () =>
|
||||
{
|
||||
it('returns primitives unchanged', () =>
|
||||
{
|
||||
expect(CloneObject(42)).toBe(42);
|
||||
expect(CloneObject('hello')).toBe('hello');
|
||||
expect(CloneObject(null)).toBe(null);
|
||||
expect(CloneObject(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns a new object instance for object inputs', () =>
|
||||
{
|
||||
const original = { a: 1, b: 'two' };
|
||||
const copy = CloneObject(original);
|
||||
|
||||
expect(copy).not.toBe(original);
|
||||
expect(copy).toEqual(original);
|
||||
});
|
||||
|
||||
it('preserves enumerable own keys', () =>
|
||||
{
|
||||
const original = { x: 1, y: 2, z: 3 };
|
||||
const copy = CloneObject(original);
|
||||
|
||||
expect(copy.x).toBe(1);
|
||||
expect(copy.y).toBe(2);
|
||||
expect(copy.z).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetWiredTimeLocale', () =>
|
||||
{
|
||||
// The renderer encodes time as `value = seconds * 2` so even values
|
||||
// are whole seconds, odd values are half-seconds.
|
||||
|
||||
it('returns "0" for value 0', () =>
|
||||
{
|
||||
expect(GetWiredTimeLocale(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('returns whole seconds for even values', () =>
|
||||
{
|
||||
expect(GetWiredTimeLocale(2)).toBe('1');
|
||||
expect(GetWiredTimeLocale(10)).toBe('5');
|
||||
expect(GetWiredTimeLocale(60)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns half-second formatting for odd values', () =>
|
||||
{
|
||||
expect(GetWiredTimeLocale(1)).toBe('0.5');
|
||||
expect(GetWiredTimeLocale(3)).toBe('1.5');
|
||||
expect(GetWiredTimeLocale(11)).toBe('5.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WiredDateToString', () =>
|
||||
{
|
||||
it('zero-pads single-digit month / day / hour / minute', () =>
|
||||
{
|
||||
const d = new Date(2024, 0, 5, 7, 9); // Jan 5, 2024, 07:09
|
||||
expect(WiredDateToString(d)).toBe('2024/01/05 07:09');
|
||||
});
|
||||
|
||||
it('formats two-digit values without extra padding', () =>
|
||||
{
|
||||
const d = new Date(2024, 11, 31, 23, 59); // Dec 31, 2024, 23:59
|
||||
expect(WiredDateToString(d)).toBe('2024/12/31 23:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrefixUtils.parsePrefixColors', () =>
|
||||
{
|
||||
it('returns an empty array when text or colors are empty', () =>
|
||||
{
|
||||
expect(parsePrefixColors('', '#fff')).toEqual([]);
|
||||
expect(parsePrefixColors('abc', '')).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps each text character to the nth color', () =>
|
||||
{
|
||||
expect(parsePrefixColors('ab', '#f00,#0f0')).toEqual([ '#f00', '#0f0' ]);
|
||||
});
|
||||
|
||||
it('reuses the last color when the text is longer than the color list', () =>
|
||||
{
|
||||
expect(parsePrefixColors('abcd', '#f00,#0f0')).toEqual([ '#f00', '#0f0', '#0f0', '#0f0' ]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrefixUtils.getPrefixFontStyle', () =>
|
||||
{
|
||||
it('returns an empty object for the default (empty) font id', () =>
|
||||
{
|
||||
expect(getPrefixFontStyle('')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns a fontFamily for a known preset', () =>
|
||||
{
|
||||
const out = getPrefixFontStyle('pixel');
|
||||
expect(out.fontFamily).toBe(PRESET_PREFIX_FONTS.find(p => p.id === 'pixel')?.family);
|
||||
});
|
||||
|
||||
it('returns an empty object for an unknown font id', () =>
|
||||
{
|
||||
expect(getPrefixFontStyle('does-not-exist')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -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('./LocalizeText', () => ({
|
||||
LocalizeText: (key: string, _params?: string[], replacements?: string[]) =>
|
||||
`${ key }|${ replacements?.[0] ?? '' }`
|
||||
}));
|
||||
|
||||
import { FriendlyTime } from './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');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
let _youtubeEnabled = false;
|
||||
|
||||
export const getYoutubeRoomEnabled = () => _youtubeEnabled;
|
||||
export const setYoutubeRoomEnabled = (enabled: boolean) => { _youtubeEnabled = enabled; };
|
||||
export const setYoutubeRoomEnabled = (enabled: boolean) =>
|
||||
{
|
||||
_youtubeEnabled = enabled;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user