mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
tests: co-locate every Vitest suite next to its subject under src/
Eliminate the parallel `tests/` tree. Each `*.test.ts` / `*.test.tsx` now sits in the same directory as the module it covers, mirroring its filename (`Foo.ts` ↔ `Foo.test.ts`). The renderer-SDK mock used by component / hook tests moves to `src/__mocks__/nitro-renderer.ts` and the Vitest setup file becomes `src/test-setup.ts` — both still wired through `vitest.config.mts` exactly as before, only the paths changed. All 13 suites + 178/178 cases still pass. The production build is unaffected: rollup only follows imports from `src/index.tsx` and never crosses into `.test.ts` files, so test code is naturally tree-shaken out of the bundle. `yarn build` output is byte-for-byte the same on the user-facing chunks. tsconfig drops the now-redundant `tests` include entry. CLAUDE.md 'Layout convention' replaces the old `tests/` row with three rows documenting the new co-located convention, the `__mocks__/` directory and the `test-setup.ts` entry; ARCHITECTURE.md picks up the same update. The 'DO NOT CHANGE' qualifier on the layout is preserved — this rewrite IS the change, decided deliberately to make tests a first-class part of the source tree rather than a sibling project.
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Stub of `@nitrots/nitro-renderer` for Vitest.
|
||||
*
|
||||
* The real package eagerly loads Pixi v8 + a few hundred Habbo message
|
||||
* parsers/composers at module import time, which jsdom cannot host:
|
||||
* any `tests/**` file that transitively pulls a renderer symbol throws
|
||||
* before a single assertion runs.
|
||||
*
|
||||
* This module replaces it via `resolve.alias` in `vitest.config.mts`.
|
||||
* We provide explicit named exports for the symbols a test currently
|
||||
* needs (logger, event dispatcher, doorbell event class); everything
|
||||
* else mentioned in the comments below is a generic stub kept just to
|
||||
* keep the side-effectful imports happy as `src/api/index.ts` and
|
||||
* friends are pulled in transitively by the barrel cascade.
|
||||
*
|
||||
* Grow this file as new tests require additional symbols. Prefer adding
|
||||
* a real (deterministic) stub over wiring `vi.fn()` — it keeps the
|
||||
* mocks readable and avoids state bleed between cases.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const NitroLogger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
enableContexts: vi.fn(),
|
||||
setDebug: vi.fn()
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `GetEventDispatcher()` in the real SDK returns the renderer-wide event
|
||||
// bus. Tests use `mockEventDispatcher.dispatchEvent(event)` to simulate
|
||||
// a server push. `clearMockEventDispatcher()` resets the listener map
|
||||
// between cases so subscriptions from a previous test don't leak.
|
||||
|
||||
type Listener = (event: any) => void;
|
||||
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
export const mockEventDispatcher = {
|
||||
addEventListener(type: string, handler: Listener)
|
||||
{
|
||||
let bucket = listeners.get(type);
|
||||
|
||||
if(!bucket)
|
||||
{
|
||||
bucket = new Set();
|
||||
listeners.set(type, bucket);
|
||||
}
|
||||
|
||||
bucket.add(handler);
|
||||
},
|
||||
removeEventListener(type: string, handler: Listener)
|
||||
{
|
||||
listeners.get(type)?.delete(handler);
|
||||
},
|
||||
dispatchEvent(event: { type: string })
|
||||
{
|
||||
const bucket = listeners.get(event.type);
|
||||
|
||||
if(!bucket) return;
|
||||
|
||||
for(const handler of bucket) handler(event);
|
||||
},
|
||||
hasListeners(type: string)
|
||||
{
|
||||
return (listeners.get(type)?.size ?? 0) > 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearMockEventDispatcher = () =>
|
||||
{
|
||||
listeners.clear();
|
||||
};
|
||||
|
||||
export const GetEventDispatcher = vi.fn(() => mockEventDispatcher);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event type enums (string-keyed Proxies)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The real `*EventType` is a `class { static readonly FOO = '...'; … }`
|
||||
// with stable wire strings. Tests only need each constant to be a
|
||||
// unique string so dispatch + listener agree.
|
||||
|
||||
const makeEnumProxy = (label: string) => new Proxy({}, {
|
||||
get: (_, prop) => typeof prop === 'string' ? `mock:${ label }:${ prop }` : undefined
|
||||
}) as Record<string, string>;
|
||||
|
||||
export const NitroEventType = makeEnumProxy('NitroEventType');
|
||||
export const MouseEventType = makeEnumProxy('MouseEventType');
|
||||
export const TouchEventType = makeEnumProxy('TouchEventType');
|
||||
export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource');
|
||||
export const RoomObjectType = makeEnumProxy('RoomObjectType');
|
||||
export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable');
|
||||
export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum');
|
||||
export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum');
|
||||
export const FurnitureType = makeEnumProxy('FurnitureType');
|
||||
export const PetType = makeEnumProxy('PetType');
|
||||
export const AvatarFigurePartType = makeEnumProxy('AvatarFigurePartType');
|
||||
export const AvatarScaleType = makeEnumProxy('AvatarScaleType');
|
||||
export const AvatarSetType = makeEnumProxy('AvatarSetType');
|
||||
export const AvatarAction = makeEnumProxy('AvatarAction');
|
||||
export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter');
|
||||
|
||||
// Numeric enums — values mirror the real renderer SDK so comparisons
|
||||
// (`controllerLevel >= GUILD_ADMIN`, category branching) keep working.
|
||||
|
||||
export class RoomControllerLevel
|
||||
{
|
||||
static readonly NONE = 0;
|
||||
static readonly GUEST = 1;
|
||||
static readonly GUILD_MEMBER = 2;
|
||||
static readonly GUILD_ADMIN = 3;
|
||||
static readonly ROOM_OWNER = 4;
|
||||
static readonly MODERATOR = 5;
|
||||
}
|
||||
|
||||
export class RoomObjectCategory
|
||||
{
|
||||
static readonly MINIMUM = 0;
|
||||
static readonly ROOM = 10;
|
||||
static readonly UNIT = 20;
|
||||
static readonly FLOOR = 30;
|
||||
static readonly WALL = 40;
|
||||
static readonly MAXIMUM = 50;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doorbell event class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class RoomSessionDoorbellEvent
|
||||
{
|
||||
// Wire strings copied from the real class so any consumer that
|
||||
// ignores the indirection through the `.DOORBELL` static still
|
||||
// matches.
|
||||
static readonly DOORBELL = 'RSDE_DOORBELL';
|
||||
static readonly RSDE_ACCEPTED = 'RSDE_ACCEPTED';
|
||||
static readonly RSDE_REJECTED = 'RSDE_REJECTED';
|
||||
|
||||
// Mirrors the real constructor signature `(type, session, userName)`
|
||||
// so `tsgo` is happy. Tests can pass `null` for the session: the
|
||||
// SUT only reads `.userName` and `.type`.
|
||||
constructor(public readonly type: string, public readonly _session: unknown, public readonly userName: string) {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic classes — placeholders for symbols that need to exist as
|
||||
// constructors so module-level `new X(...)` calls don't crash during
|
||||
// the barrel cascade, but whose behavior tests don't yet exercise.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class StubClass
|
||||
{
|
||||
constructor(..._args: unknown[]) {}
|
||||
}
|
||||
|
||||
export class NitroAlphaFilter extends StubClass {}
|
||||
export class NitroContainer extends StubClass {}
|
||||
export class NitroRectangle extends StubClass {}
|
||||
export class NitroSprite extends StubClass {}
|
||||
export class NitroTexture extends StubClass {}
|
||||
export class NitroSoundEvent extends StubClass {}
|
||||
export class NitroEvent extends StubClass {}
|
||||
export class MessageEvent extends StubClass {}
|
||||
export class RoomEngineObjectEvent extends StubClass {}
|
||||
export class CreateLinkEvent extends StubClass {}
|
||||
export class EventDispatcher extends StubClass {}
|
||||
export class AdvancedMap extends StubClass {}
|
||||
export class AvatarFigureContainer extends StubClass {}
|
||||
export class Vector3d extends StubClass {}
|
||||
export class ObjectDataFactory extends StubClass {}
|
||||
export class RoomDataParser extends StubClass {}
|
||||
export class RoomModerationSettings extends StubClass {}
|
||||
export class StringDataType extends StubClass {}
|
||||
export class SellablePetPaletteData extends StubClass {}
|
||||
export class PetFigureData extends StubClass {}
|
||||
export class PetData extends StubClass {}
|
||||
export class NodeData extends StubClass {}
|
||||
export class ItemDataStructure extends StubClass {}
|
||||
export class HabboGroupEntryData extends StubClass {}
|
||||
export class FriendParser extends StubClass {}
|
||||
export class FriendCategoryData extends StubClass {}
|
||||
export class FriendRequestData extends StubClass {}
|
||||
export class FurnitureListItemParser extends StubClass {}
|
||||
export class BotData extends StubClass {}
|
||||
export class AchievementData extends StubClass {}
|
||||
export class CatalogPageMessageProductData extends StubClass {}
|
||||
export class GiftWrappingConfigurationParser extends StubClass {}
|
||||
export class WiredFilter extends StubClass {}
|
||||
export class HabboWebTools extends StubClass {}
|
||||
|
||||
// Composers — symbol-only constructors; only their identity matters in the
|
||||
// codebase ("did the SUT call SendMessageComposer(new FooComposer(args))").
|
||||
export class AddFavouriteRoomMessageComposer extends StubClass {}
|
||||
export class DeleteFavouriteRoomMessageComposer extends StubClass {}
|
||||
export class DesktopViewComposer extends StubClass {}
|
||||
export class FurniturePlacePaintComposer extends StubClass {}
|
||||
export class GetGuestRoomMessageComposer extends StubClass {}
|
||||
export class GetProductOfferComposer extends StubClass {}
|
||||
export class GroupFavoriteComposer extends StubClass {}
|
||||
export class GroupInformationComposer extends StubClass {}
|
||||
export class GroupJoinComposer extends StubClass {}
|
||||
export class GroupUnfavoriteComposer extends StubClass {}
|
||||
export class UserProfileComposer extends StubClass {}
|
||||
|
||||
// `ChooserSelectionFilter` is used as a string enum in some call sites.
|
||||
export const ChooserSelectionFilter = makeEnumProxy('ChooserSelectionFilter');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton getters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stubManager = () =>
|
||||
{
|
||||
const sentinel = new Proxy(() => undefined, {
|
||||
get(target, prop)
|
||||
{
|
||||
if(prop === 'then') return undefined;
|
||||
const cached = (target as any)[prop];
|
||||
if(cached !== undefined) return cached;
|
||||
// Most fields read from a real manager are either methods
|
||||
// (return functions) or sub-objects (return proxies). We
|
||||
// return another callable proxy so chained access works.
|
||||
const value = stubManager();
|
||||
(target as any)[prop] = value;
|
||||
return value;
|
||||
},
|
||||
apply()
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return sentinel;
|
||||
};
|
||||
|
||||
export const GetAssetManager = vi.fn(stubManager);
|
||||
export const GetAvatarRenderManager = vi.fn(stubManager);
|
||||
export const GetCommunication = vi.fn(stubManager);
|
||||
export const GetConfiguration = vi.fn(stubManager);
|
||||
export const GetLocalizationManager = vi.fn(stubManager);
|
||||
export const GetRoomEngine = vi.fn(stubManager);
|
||||
export const GetRoomSessionManager = vi.fn(stubManager);
|
||||
export const GetSessionDataManager = vi.fn(stubManager);
|
||||
export const GetTickerTime = vi.fn(() => 0);
|
||||
export const TextureUtils = stubManager();
|
||||
export const NitroVersion = stubManager();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-only re-exports (interfaces erase at compile time, but listing them
|
||||
// here documents what the codebase imports through the type channel).
|
||||
//
|
||||
// IAvatarFigureContainer · IEventDispatcher · IFigurePart · IFigurePartSet ·
|
||||
// IFurnitureData · IFurnitureItemData · IGraphicAsset · IMessageComposer ·
|
||||
// IMessageEvent · IObjectData · IPartColor · IPollQuestion · IProductData ·
|
||||
// IRoomEngine · IRoomModerationSettings · IRoomObject · IRoomObjectController ·
|
||||
// IRoomObjectSpriteVisualization · IRoomPetData · IRoomSession · IRoomUserData
|
||||
//
|
||||
// No need to alias them — TypeScript only consults them in the type
|
||||
// system, and the production `tsconfig.json` resolves them against the
|
||||
// real renderer via `node_modules/@nitrots/nitro-renderer/src`.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catch-all
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Anything else still resolves to `undefined`. If a test fails with
|
||||
// "X is not a constructor" / "X.SOMETHING is not a function", add the
|
||||
// missing symbol above with a real stub. Avoid the temptation to
|
||||
// blanket-mock everything — explicit stubs surface intent and let
|
||||
// failing tests pinpoint what behavior they actually rely on.
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { NitroLogger } from '@nitrots/nitro-renderer';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { FC } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WidgetErrorBoundary } from './WidgetErrorBoundary';
|
||||
|
||||
// `import { NitroLogger } from '@nitrots/nitro-renderer'` resolves to
|
||||
// `src/__mocks__/nitro-renderer.ts` via the alias in vitest.config.mts.
|
||||
// The SUT imports the same path, so both reach the same vi.fn instance.
|
||||
|
||||
describe('WidgetErrorBoundary', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
vi.clearAllMocks();
|
||||
// react-error-boundary lets React's "uncaught error" log through
|
||||
// by default — silence it so jsdom doesn't dump a stack trace
|
||||
// every time we deliberately throw below.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders its children when nothing throws', () =>
|
||||
{
|
||||
render(
|
||||
<WidgetErrorBoundary name="HappyPath">
|
||||
<span data-testid="child">visible</span>
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child')).toHaveTextContent('visible');
|
||||
});
|
||||
|
||||
it('swallows a render-time error to a silent fallback and logs it', () =>
|
||||
{
|
||||
const Boom: FC = () =>
|
||||
{
|
||||
throw new Error('kaboom');
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<WidgetErrorBoundary name="Boom">
|
||||
<Boom />
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
|
||||
// Default fallback is `() => null` → boundary subtree is empty.
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
expect(NitroLogger.error).toHaveBeenCalledTimes(1);
|
||||
const [ message, err ] = (NitroLogger.error as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(message).toBe('[Widget:Boom] crashed');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toBe('kaboom');
|
||||
});
|
||||
|
||||
it('renders a custom fallback node when provided', () =>
|
||||
{
|
||||
const Boom: FC = () =>
|
||||
{
|
||||
throw new Error('explode');
|
||||
};
|
||||
|
||||
render(
|
||||
<WidgetErrorBoundary name="WithFallback" fallback={ <div data-testid="fb">offline</div> }>
|
||||
<Boom />
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('fb')).toHaveTextContent('offline');
|
||||
});
|
||||
|
||||
it('uses "unknown" as the widget name when the prop is omitted', () =>
|
||||
{
|
||||
const Boom: FC = () =>
|
||||
{
|
||||
throw new Error('anonymous');
|
||||
};
|
||||
|
||||
render(
|
||||
<WidgetErrorBoundary>
|
||||
<Boom />
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
|
||||
expect(NitroLogger.error).toHaveBeenCalledTimes(1);
|
||||
expect((NitroLogger.error as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('[Widget:unknown] crashed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useRoomCreatorStore } from './navigatorRoomCreatorStore';
|
||||
|
||||
describe('useRoomCreatorStore', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
vi.useFakeTimers();
|
||||
useRoomCreatorStore.setState({ isCreating: false });
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts with isCreating === false', () =>
|
||||
{
|
||||
expect(useRoomCreatorStore.getState().isCreating).toBe(false);
|
||||
});
|
||||
|
||||
it('beginCreate() latches isCreating to true', () =>
|
||||
{
|
||||
useRoomCreatorStore.getState().beginCreate();
|
||||
expect(useRoomCreatorStore.getState().isCreating).toBe(true);
|
||||
});
|
||||
|
||||
it('isCreating auto-resets to false after the 5s lockout', () =>
|
||||
{
|
||||
useRoomCreatorStore.getState().beginCreate();
|
||||
expect(useRoomCreatorStore.getState().isCreating).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(useRoomCreatorStore.getState().isCreating).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(useRoomCreatorStore.getState().isCreating).toBe(false);
|
||||
});
|
||||
|
||||
it('a second beginCreate() resets the lockout timer (no double-fire)', () =>
|
||||
{
|
||||
useRoomCreatorStore.getState().beginCreate();
|
||||
vi.advanceTimersByTime(4000);
|
||||
|
||||
// Re-entry restarts the 5s window
|
||||
useRoomCreatorStore.getState().beginCreate();
|
||||
|
||||
// At t=4500 (500ms past the second call), we should still be locked
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(useRoomCreatorStore.getState().isCreating).toBe(true);
|
||||
|
||||
// Only after another 4500ms (total 5000 since the second call)
|
||||
vi.advanceTimersByTime(4500);
|
||||
expect(useRoomCreatorStore.getState().isCreating).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createEmptyMonitorSnapshot,
|
||||
formatMonitorHistoryOccurrence,
|
||||
formatMonitorLatestOccurrence,
|
||||
formatMonitorSource,
|
||||
formatVariableTimestamp,
|
||||
normalizeMonitorReason
|
||||
} from './WiredCreatorTools.helpers';
|
||||
|
||||
describe('WiredCreatorTools helpers', () =>
|
||||
{
|
||||
describe('createEmptyMonitorSnapshot', () =>
|
||||
{
|
||||
it('returns a zeroed-out snapshot with empty logs and history arrays', () =>
|
||||
{
|
||||
const snap = createEmptyMonitorSnapshot();
|
||||
|
||||
expect(snap.usageCurrentWindow).toBe(0);
|
||||
expect(snap.usageLimitPerWindow).toBe(0);
|
||||
expect(snap.isHeavy).toBe(false);
|
||||
expect(snap.killedRemainingSeconds).toBe(0);
|
||||
expect(snap.logs).toEqual([]);
|
||||
expect(snap.history).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns fresh arrays each call (no shared state)', () =>
|
||||
{
|
||||
const a = createEmptyMonitorSnapshot();
|
||||
const b = createEmptyMonitorSnapshot();
|
||||
|
||||
expect(a.logs).not.toBe(b.logs);
|
||||
expect(a.history).not.toBe(b.history);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMonitorLatestOccurrence', () =>
|
||||
{
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
it('returns "/" when no occurrence has been recorded yet', () =>
|
||||
{
|
||||
expect(formatMonitorLatestOccurrence(0, NOW)).toBe('/');
|
||||
expect(formatMonitorLatestOccurrence(-1, NOW)).toBe('/');
|
||||
});
|
||||
|
||||
it('returns "Just now" for diffs under 5 seconds', () =>
|
||||
{
|
||||
const occurredAt = NOW / 1000;
|
||||
expect(formatMonitorLatestOccurrence(occurredAt, NOW)).toBe('Just now');
|
||||
});
|
||||
|
||||
it('returns "<n>s ago" for diffs under a minute', () =>
|
||||
{
|
||||
const tenSecondsAgo = (NOW - 10_000) / 1000;
|
||||
expect(formatMonitorLatestOccurrence(tenSecondsAgo, NOW)).toBe('10s ago');
|
||||
});
|
||||
|
||||
it('returns "<n>m ago" for diffs under an hour', () =>
|
||||
{
|
||||
const fiveMinutesAgo = (NOW - 5 * 60 * 1000) / 1000;
|
||||
expect(formatMonitorLatestOccurrence(fiveMinutesAgo, NOW)).toBe('5m ago');
|
||||
});
|
||||
|
||||
it('returns "<n>h ago" for diffs under a day', () =>
|
||||
{
|
||||
const threeHoursAgo = (NOW - 3 * 60 * 60 * 1000) / 1000;
|
||||
expect(formatMonitorLatestOccurrence(threeHoursAgo, NOW)).toBe('3h ago');
|
||||
});
|
||||
|
||||
it('returns "<n>d ago" for older diffs', () =>
|
||||
{
|
||||
const twoDaysAgo = (NOW - 2 * 24 * 60 * 60 * 1000) / 1000;
|
||||
expect(formatMonitorLatestOccurrence(twoDaysAgo, NOW)).toBe('2d ago');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMonitorHistoryOccurrence', () =>
|
||||
{
|
||||
it('returns "/" for non-positive timestamps', () =>
|
||||
{
|
||||
expect(formatMonitorHistoryOccurrence(0)).toBe('/');
|
||||
expect(formatMonitorHistoryOccurrence(-5)).toBe('/');
|
||||
});
|
||||
|
||||
it('returns a non-empty formatted string for a real timestamp', () =>
|
||||
{
|
||||
const out = formatMonitorHistoryOccurrence(1_700_000_000);
|
||||
expect(out).not.toBe('/');
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatVariableTimestamp', () =>
|
||||
{
|
||||
it('returns "/" for zero, negative, or falsy values', () =>
|
||||
{
|
||||
expect(formatVariableTimestamp(0)).toBe('/');
|
||||
expect(formatVariableTimestamp(-1)).toBe('/');
|
||||
expect(formatVariableTimestamp(null)).toBe('/');
|
||||
});
|
||||
|
||||
it('formats a positive epoch-seconds value as a locale string', () =>
|
||||
{
|
||||
const out = formatVariableTimestamp(1_700_000_000);
|
||||
expect(out).not.toBe('/');
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMonitorSource', () =>
|
||||
{
|
||||
it('falls back to "Room monitor" when both label and id are missing', () =>
|
||||
{
|
||||
expect(formatMonitorSource('', 0)).toBe('Room monitor');
|
||||
expect(formatMonitorSource('', -1)).toBe('Room monitor');
|
||||
});
|
||||
|
||||
it('returns just the label when there is no source id', () =>
|
||||
{
|
||||
expect(formatMonitorSource('wired-trigger', 0)).toBe('wired-trigger');
|
||||
});
|
||||
|
||||
it('appends "(#<id>)" when source id is positive', () =>
|
||||
{
|
||||
expect(formatMonitorSource('on-walk', 42)).toBe('on-walk (#42)');
|
||||
});
|
||||
|
||||
it('uses "wired" as default label when only the id is set', () =>
|
||||
{
|
||||
expect(formatMonitorSource('', 7)).toBe('wired (#7)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMonitorReason', () =>
|
||||
{
|
||||
it('returns the trimmed reason when one is provided', () =>
|
||||
{
|
||||
expect(normalizeMonitorReason(' loop detected ')).toBe('loop detected');
|
||||
});
|
||||
|
||||
it('falls back to a placeholder when the reason is empty or whitespace', () =>
|
||||
{
|
||||
expect(normalizeMonitorReason('')).toContain('No detailed reason');
|
||||
expect(normalizeMonitorReason(' ')).toContain('No detailed reason');
|
||||
expect(normalizeMonitorReason(null)).toContain('No detailed reason');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useWiredCreatorToolsUiStore } from './wiredCreatorToolsUiStore';
|
||||
|
||||
const INITIAL = {
|
||||
isVisible: false,
|
||||
activeTab: 'monitor' as const,
|
||||
inspectionType: 'furni' as const,
|
||||
variablesType: 'furni' as const,
|
||||
isMonitorHistoryOpen: false,
|
||||
isMonitorInfoOpen: false,
|
||||
isInspectionGiveOpen: false,
|
||||
isVariableManageOpen: false,
|
||||
isManagedGiveOpen: false,
|
||||
monitorHistorySeverityFilter: 'ALL' as const,
|
||||
monitorHistoryTypeFilter: 'ALL',
|
||||
variableManageTypeFilter: 'ALL',
|
||||
variableManageSort: 'highest_value',
|
||||
variableManagePage: 1
|
||||
};
|
||||
|
||||
describe('useWiredCreatorToolsUiStore', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.setState(INITIAL);
|
||||
});
|
||||
|
||||
it('exposes the documented defaults', () =>
|
||||
{
|
||||
const state = useWiredCreatorToolsUiStore.getState();
|
||||
|
||||
expect(state.isVisible).toBe(false);
|
||||
expect(state.activeTab).toBe('monitor');
|
||||
expect(state.inspectionType).toBe('furni');
|
||||
expect(state.variablesType).toBe('furni');
|
||||
expect(state.isMonitorHistoryOpen).toBe(false);
|
||||
expect(state.isMonitorInfoOpen).toBe(false);
|
||||
expect(state.isInspectionGiveOpen).toBe(false);
|
||||
expect(state.isVariableManageOpen).toBe(false);
|
||||
expect(state.isManagedGiveOpen).toBe(false);
|
||||
expect(state.monitorHistorySeverityFilter).toBe('ALL');
|
||||
expect(state.monitorHistoryTypeFilter).toBe('ALL');
|
||||
expect(state.variableManageTypeFilter).toBe('ALL');
|
||||
expect(state.variableManageSort).toBe('highest_value');
|
||||
expect(state.variableManagePage).toBe(1);
|
||||
});
|
||||
|
||||
describe('setIsVisible', () =>
|
||||
{
|
||||
it('accepts a direct boolean', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setIsVisible(true);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a functional updater (toggle pattern)', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setIsVisible(prev => !prev);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isVisible).toBe(true);
|
||||
|
||||
useWiredCreatorToolsUiStore.getState().setIsVisible(prev => !prev);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveTab', () =>
|
||||
{
|
||||
it('switches the active tab', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setActiveTab('variables');
|
||||
expect(useWiredCreatorToolsUiStore.getState().activeTab).toBe('variables');
|
||||
|
||||
useWiredCreatorToolsUiStore.getState().setActiveTab('inspection');
|
||||
expect(useWiredCreatorToolsUiStore.getState().activeTab).toBe('inspection');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setInspectionType / setVariablesType', () =>
|
||||
{
|
||||
it('updates the inspection element type', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setInspectionType('user');
|
||||
expect(useWiredCreatorToolsUiStore.getState().inspectionType).toBe('user');
|
||||
});
|
||||
|
||||
it('updates the variables element type (including context)', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setVariablesType('context');
|
||||
expect(useWiredCreatorToolsUiStore.getState().variablesType).toBe('context');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal/popover flags', () =>
|
||||
{
|
||||
it('setIsMonitorHistoryOpen toggles the history modal flag', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setIsMonitorHistoryOpen(true);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isMonitorHistoryOpen).toBe(true);
|
||||
|
||||
useWiredCreatorToolsUiStore.getState().setIsMonitorHistoryOpen(false);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isMonitorHistoryOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('setIsMonitorInfoOpen toggles the info modal flag', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setIsMonitorInfoOpen(true);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isMonitorInfoOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('setIsInspectionGiveOpen accepts a functional updater', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setIsInspectionGiveOpen(prev => !prev);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isInspectionGiveOpen).toBe(true);
|
||||
|
||||
useWiredCreatorToolsUiStore.getState().setIsInspectionGiveOpen(prev => !prev);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isInspectionGiveOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('setIsVariableManageOpen takes a direct boolean', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setIsVariableManageOpen(true);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isVariableManageOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('setIsManagedGiveOpen accepts a functional updater', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setIsManagedGiveOpen(prev => !prev);
|
||||
expect(useWiredCreatorToolsUiStore.getState().isManagedGiveOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monitor history filters', () =>
|
||||
{
|
||||
it('setMonitorHistorySeverityFilter narrows to ERROR / WARNING / ALL', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setMonitorHistorySeverityFilter('ERROR');
|
||||
expect(useWiredCreatorToolsUiStore.getState().monitorHistorySeverityFilter).toBe('ERROR');
|
||||
|
||||
useWiredCreatorToolsUiStore.getState().setMonitorHistorySeverityFilter('WARNING');
|
||||
expect(useWiredCreatorToolsUiStore.getState().monitorHistorySeverityFilter).toBe('WARNING');
|
||||
|
||||
useWiredCreatorToolsUiStore.getState().setMonitorHistorySeverityFilter('ALL');
|
||||
expect(useWiredCreatorToolsUiStore.getState().monitorHistorySeverityFilter).toBe('ALL');
|
||||
});
|
||||
|
||||
it('setMonitorHistoryTypeFilter stores an arbitrary type label', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setMonitorHistoryTypeFilter('FurnitureRuntime');
|
||||
expect(useWiredCreatorToolsUiStore.getState().monitorHistoryTypeFilter).toBe('FurnitureRuntime');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable manage UI', () =>
|
||||
{
|
||||
it('setVariableManageTypeFilter / setVariableManageSort store string filters', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setVariableManageTypeFilter('Number');
|
||||
useWiredCreatorToolsUiStore.getState().setVariableManageSort('lowest_value');
|
||||
|
||||
expect(useWiredCreatorToolsUiStore.getState().variableManageTypeFilter).toBe('Number');
|
||||
expect(useWiredCreatorToolsUiStore.getState().variableManageSort).toBe('lowest_value');
|
||||
});
|
||||
|
||||
it('setVariableManagePage accepts a direct value', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setVariableManagePage(4);
|
||||
expect(useWiredCreatorToolsUiStore.getState().variableManagePage).toBe(4);
|
||||
});
|
||||
|
||||
it('setVariableManagePage accepts a functional updater (next/prev pagination)', () =>
|
||||
{
|
||||
useWiredCreatorToolsUiStore.getState().setVariableManagePage(2);
|
||||
useWiredCreatorToolsUiStore.getState().setVariableManagePage(prev => prev + 1);
|
||||
expect(useWiredCreatorToolsUiStore.getState().variableManagePage).toBe(3);
|
||||
|
||||
useWiredCreatorToolsUiStore.getState().setVariableManagePage(prev => Math.max(1, prev - 1));
|
||||
expect(useWiredCreatorToolsUiStore.getState().variableManagePage).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// `useCatalogStore` mounts ~30 useState calls, opens a fresh
|
||||
// RoomPreviewer, subscribes to a dozen renderer message events, and
|
||||
// reaches into `useNotification()` for the alert helpers — too much
|
||||
// surface to render under jsdom and not what these tests are about.
|
||||
//
|
||||
// We just want to lock down the *contract* of the three filters
|
||||
// (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) and
|
||||
// the shim: each one must read its specific subset of keys from the
|
||||
// same `useBetween` singleton.
|
||||
//
|
||||
// Stub `use-between` so all four hooks share one deterministic store
|
||||
// object. `vi.hoisted` lets us reference the fake from the mock
|
||||
// factory (which is itself hoisted).
|
||||
|
||||
const { fakeStore } = vi.hoisted(() =>
|
||||
{
|
||||
const fakeStore = {
|
||||
// Data slice
|
||||
isBusy: false,
|
||||
rootNode: null,
|
||||
offersToNodes: null,
|
||||
currentPage: null,
|
||||
currentOffer: null,
|
||||
frontPageItems: [],
|
||||
searchResult: null,
|
||||
roomPreviewer: null,
|
||||
catalogLocalizationVersion: 0,
|
||||
furniCount: 0,
|
||||
furniLimit: 0,
|
||||
maxFurniLimit: 0,
|
||||
secondsLeft: 0,
|
||||
secondsLeftWithGrace: 0,
|
||||
updateTime: 0,
|
||||
// UiState slice
|
||||
isVisible: false,
|
||||
setIsVisible: vi.fn(),
|
||||
pageId: -1,
|
||||
previousPageId: -1,
|
||||
currentType: 'NORMAL',
|
||||
activeNodes: [] as any[],
|
||||
navigationHidden: false,
|
||||
setNavigationHidden: vi.fn(),
|
||||
purchaseOptions: { quantity: 1 },
|
||||
setPurchaseOptions: vi.fn(),
|
||||
catalogPlaceMultipleObjects: false,
|
||||
setCatalogPlaceMultipleObjects: vi.fn(),
|
||||
setCurrentPage: vi.fn(),
|
||||
setCurrentOffer: vi.fn(),
|
||||
setSearchResult: vi.fn(),
|
||||
// Actions slice
|
||||
openCatalogByType: vi.fn(),
|
||||
toggleCatalogByType: vi.fn(),
|
||||
activateNode: vi.fn(),
|
||||
openPageById: vi.fn(),
|
||||
openPageByName: vi.fn(),
|
||||
openPageByOfferId: vi.fn(),
|
||||
requestOfferToMover: vi.fn(),
|
||||
selectCatalogOffer: vi.fn(),
|
||||
getNodeById: vi.fn(),
|
||||
getNodeByName: vi.fn(),
|
||||
getBuilderFurniPlaceableStatus: vi.fn()
|
||||
};
|
||||
|
||||
return { fakeStore };
|
||||
});
|
||||
|
||||
vi.mock('use-between', () => ({
|
||||
useBetween: () => fakeStore
|
||||
}));
|
||||
|
||||
// Import AFTER the mock is set up. The hooks resolve `useBetween` at
|
||||
// import time via the module graph, so the order matters.
|
||||
import { useCatalogActions, useCatalogData, useCatalogUiState } from './useCatalog';
|
||||
|
||||
describe('useCatalog filter contract', () =>
|
||||
{
|
||||
it('useCatalogData returns the read-only data slice', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useCatalogData());
|
||||
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'catalogLocalizationVersion',
|
||||
'currentOffer',
|
||||
'currentPage',
|
||||
'frontPageItems',
|
||||
'furniCount',
|
||||
'furniLimit',
|
||||
'isBusy',
|
||||
'maxFurniLimit',
|
||||
'offersToNodes',
|
||||
'roomPreviewer',
|
||||
'rootNode',
|
||||
'searchResult',
|
||||
'secondsLeft',
|
||||
'secondsLeftWithGrace',
|
||||
'updateTime'
|
||||
]);
|
||||
|
||||
// Reads point at the same underlying values.
|
||||
expect(result.current.rootNode).toBe(fakeStore.rootNode);
|
||||
expect(result.current.furniCount).toBe(fakeStore.furniCount);
|
||||
expect(result.current.frontPageItems).toBe(fakeStore.frontPageItems);
|
||||
});
|
||||
|
||||
it('useCatalogUiState returns the UI fields plus their setters', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useCatalogUiState());
|
||||
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'activeNodes',
|
||||
'catalogPlaceMultipleObjects',
|
||||
'currentType',
|
||||
'isVisible',
|
||||
'navigationHidden',
|
||||
'pageId',
|
||||
'previousPageId',
|
||||
'purchaseOptions',
|
||||
'setCatalogPlaceMultipleObjects',
|
||||
'setCurrentOffer',
|
||||
'setCurrentPage',
|
||||
'setIsVisible',
|
||||
'setNavigationHidden',
|
||||
'setPurchaseOptions',
|
||||
'setSearchResult'
|
||||
]);
|
||||
|
||||
expect(result.current.setIsVisible).toBe(fakeStore.setIsVisible);
|
||||
expect(result.current.setCurrentPage).toBe(fakeStore.setCurrentPage);
|
||||
});
|
||||
|
||||
it('useCatalogActions returns only imperative operations', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useCatalogActions());
|
||||
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'activateNode',
|
||||
'getBuilderFurniPlaceableStatus',
|
||||
'getNodeById',
|
||||
'getNodeByName',
|
||||
'openCatalogByType',
|
||||
'openPageById',
|
||||
'openPageByName',
|
||||
'openPageByOfferId',
|
||||
'requestOfferToMover',
|
||||
'selectCatalogOffer',
|
||||
'toggleCatalogByType'
|
||||
]);
|
||||
|
||||
// No data fields leak through.
|
||||
expect(result.current).not.toHaveProperty('rootNode');
|
||||
expect(result.current).not.toHaveProperty('isVisible');
|
||||
expect(result.current).not.toHaveProperty('currentPage');
|
||||
|
||||
expect(result.current.activateNode).toBe(fakeStore.activateNode);
|
||||
expect(result.current.openCatalogByType).toBe(fakeStore.openCatalogByType);
|
||||
});
|
||||
|
||||
it('all three filters observe the same singleton — refs are ===', () =>
|
||||
{
|
||||
const { result } = renderHook(() =>
|
||||
({
|
||||
data: useCatalogData(),
|
||||
ui: useCatalogUiState(),
|
||||
actions: useCatalogActions()
|
||||
}));
|
||||
|
||||
// Each slice reaches the same fakeStore via useBetween. Any
|
||||
// accidental copy would break these `===` checks.
|
||||
expect(result.current.actions.activateNode).toBe(fakeStore.activateNode);
|
||||
expect(result.current.actions.openCatalogByType).toBe(fakeStore.openCatalogByType);
|
||||
expect(result.current.ui.setIsVisible).toBe(fakeStore.setIsVisible);
|
||||
expect(result.current.ui.setCurrentPage).toBe(fakeStore.setCurrentPage);
|
||||
expect(result.current.data.rootNode).toBe(fakeStore.rootNode);
|
||||
expect(result.current.data.furniCount).toBe(fakeStore.furniCount);
|
||||
expect(result.current.data.roomPreviewer).toBe(fakeStore.roomPreviewer);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BuilderFurniPlaceableStatus } from '../../api/catalog/BuilderFurniPlaceableStatus';
|
||||
import { CatalogType } from '../../api/catalog/CatalogType';
|
||||
import {
|
||||
buildCatalogNodeTree,
|
||||
findNodeById,
|
||||
findNodeByName,
|
||||
getNodesByOfferIdFromMap,
|
||||
getOfferProductKeys,
|
||||
normalizeCatalogType,
|
||||
resolveBuilderFurniPlaceableStatus
|
||||
} from './useCatalog.helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeCatalogType
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizeCatalogType', () =>
|
||||
{
|
||||
it('returns BUILDER when explicitly asked for BUILDER', () =>
|
||||
{
|
||||
expect(normalizeCatalogType(CatalogType.BUILDER)).toBe(CatalogType.BUILDER);
|
||||
});
|
||||
|
||||
it('returns NORMAL for the explicit NORMAL value', () =>
|
||||
{
|
||||
expect(normalizeCatalogType(CatalogType.NORMAL)).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
|
||||
it('returns NORMAL when type is omitted', () =>
|
||||
{
|
||||
expect(normalizeCatalogType()).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
|
||||
it('returns NORMAL for any unknown string', () =>
|
||||
{
|
||||
expect(normalizeCatalogType('something_else')).toBe(CatalogType.NORMAL);
|
||||
expect(normalizeCatalogType('')).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getOfferProductKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getOfferProductKeys', () =>
|
||||
{
|
||||
const makeOffer = (overrides: any = {}) =>
|
||||
({
|
||||
product: {
|
||||
productType: 'floor',
|
||||
productClassId: 42,
|
||||
furnitureData: { className: 'chair_basic' },
|
||||
...overrides
|
||||
}
|
||||
}) as any;
|
||||
|
||||
it('returns both id and className keys when the product has both', () =>
|
||||
{
|
||||
expect(getOfferProductKeys(makeOffer())).toEqual([
|
||||
'floor:id:42',
|
||||
'floor:class:chair_basic'
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the id key when productClassId is negative', () =>
|
||||
{
|
||||
const offer = makeOffer({ productClassId: -1 });
|
||||
|
||||
expect(getOfferProductKeys(offer)).toEqual([ 'floor:class:chair_basic' ]);
|
||||
});
|
||||
|
||||
it('omits the className key when furnitureData has no className', () =>
|
||||
{
|
||||
const offer = makeOffer({ furnitureData: { className: '' } });
|
||||
|
||||
expect(getOfferProductKeys(offer)).toEqual([ 'floor:id:42' ]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the offer has no product', () =>
|
||||
{
|
||||
expect(getOfferProductKeys(null)).toEqual([]);
|
||||
expect(getOfferProductKeys(undefined)).toEqual([]);
|
||||
expect(getOfferProductKeys({} as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when productType is missing', () =>
|
||||
{
|
||||
const offer = makeOffer({ productType: '' });
|
||||
|
||||
expect(getOfferProductKeys(offer)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findNodeById / findNodeByName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeNode = (overrides: { pageId?: number; pageName?: string; children?: any[] } = {}) =>
|
||||
({
|
||||
pageId: overrides.pageId ?? -1,
|
||||
pageName: overrides.pageName ?? 'unnamed',
|
||||
isVisible: true,
|
||||
children: overrides.children ?? []
|
||||
});
|
||||
|
||||
describe('findNodeById', () =>
|
||||
{
|
||||
it('returns null when the input node is null', () =>
|
||||
{
|
||||
expect(findNodeById(7, null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips the root node even when its pageId matches', () =>
|
||||
{
|
||||
const root = makeNode({ pageId: 7, pageName: 'root' }) as any;
|
||||
|
||||
expect(findNodeById(7, root, root)).toBeNull();
|
||||
});
|
||||
|
||||
it('finds an immediate child by pageId', () =>
|
||||
{
|
||||
const child = makeNode({ pageId: 7, pageName: 'shop' }) as any;
|
||||
const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any;
|
||||
|
||||
expect(findNodeById(7, root, root)).toBe(child);
|
||||
});
|
||||
|
||||
it('descends into grandchildren', () =>
|
||||
{
|
||||
const grandchild = makeNode({ pageId: 42, pageName: 'sale' }) as any;
|
||||
const child = makeNode({ pageId: 7, pageName: 'shop', children: [ grandchild ] }) as any;
|
||||
const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any;
|
||||
|
||||
expect(findNodeById(42, root, root)).toBe(grandchild);
|
||||
});
|
||||
|
||||
it('returns null when no node has that pageId', () =>
|
||||
{
|
||||
const child = makeNode({ pageId: 7, pageName: 'shop' }) as any;
|
||||
const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any;
|
||||
|
||||
expect(findNodeById(99, root, root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNodeByName', () =>
|
||||
{
|
||||
it('finds a node by pageName ignoring the root', () =>
|
||||
{
|
||||
const child = makeNode({ pageName: 'frontpage' }) as any;
|
||||
const root = makeNode({ pageName: 'root', children: [ child ] }) as any;
|
||||
|
||||
expect(findNodeByName('frontpage', root, root)).toBe(child);
|
||||
expect(findNodeByName('root', root, root)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () =>
|
||||
{
|
||||
const root = makeNode({ pageName: 'root', children: [ makeNode({ pageName: 'a' }) as any ] }) as any;
|
||||
|
||||
expect(findNodeByName('b', root, root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getNodesByOfferIdFromMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getNodesByOfferIdFromMap', () =>
|
||||
{
|
||||
const visibleNode = (id: number) => ({ pageId: id, isVisible: true } as any);
|
||||
const hiddenNode = (id: number) => ({ pageId: id, isVisible: false } as any);
|
||||
|
||||
it('returns null when the map is missing or empty', () =>
|
||||
{
|
||||
expect(getNodesByOfferIdFromMap(1, null)).toBeNull();
|
||||
expect(getNodesByOfferIdFromMap(1, undefined)).toBeNull();
|
||||
expect(getNodesByOfferIdFromMap(1, new Map())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the raw bucket when onlyVisible is false', () =>
|
||||
{
|
||||
const bucket = [ visibleNode(1), hiddenNode(2) ];
|
||||
const map = new Map([ [ 9, bucket ] ]);
|
||||
|
||||
expect(getNodesByOfferIdFromMap(9, map)).toBe(bucket);
|
||||
});
|
||||
|
||||
it('filters out hidden nodes when onlyVisible is true', () =>
|
||||
{
|
||||
const visible = visibleNode(1);
|
||||
const map = new Map([ [ 9, [ visible, hiddenNode(2) ] ] ]);
|
||||
|
||||
expect(getNodesByOfferIdFromMap(9, map, true)).toEqual([ visible ]);
|
||||
});
|
||||
|
||||
it('falls back to the raw bucket when no visible nodes remain', () =>
|
||||
{
|
||||
const bucket = [ hiddenNode(1), hiddenNode(2) ];
|
||||
const map = new Map([ [ 9, bucket ] ]);
|
||||
|
||||
expect(getNodesByOfferIdFromMap(9, map, true)).toBe(bucket);
|
||||
});
|
||||
|
||||
it('returns null for an offerId not in the map', () =>
|
||||
{
|
||||
const map = new Map([ [ 1, [ visibleNode(1) ] ] ]);
|
||||
|
||||
expect(getNodesByOfferIdFromMap(99, map)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCatalogNodeTree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildCatalogNodeTree', () =>
|
||||
{
|
||||
// CatalogNode in the real codebase reads `node.pageId`, `node.pageName`,
|
||||
// `node.offerIds`, `node.children`. Anything else is irrelevant to the
|
||||
// build step.
|
||||
const makeNodeData = (overrides: any = {}) =>
|
||||
({
|
||||
pageId: -1,
|
||||
pageName: 'unnamed',
|
||||
localization: '',
|
||||
iconId: -1,
|
||||
offerIds: [] as number[],
|
||||
children: [] as any[],
|
||||
visible: true,
|
||||
...overrides
|
||||
});
|
||||
|
||||
it('returns a CatalogNode root with the depth=0', () =>
|
||||
{
|
||||
const rootData = makeNodeData({ pageId: 0, pageName: 'root' });
|
||||
const { rootNode, offersToNodes } = buildCatalogNodeTree(rootData as any);
|
||||
|
||||
expect(rootNode.pageId).toBe(0);
|
||||
expect(rootNode.depth).toBe(0);
|
||||
expect(offersToNodes.size).toBe(0);
|
||||
});
|
||||
|
||||
it('walks children depth-first and tracks offerId mappings', () =>
|
||||
{
|
||||
const leaf = makeNodeData({ pageId: 5, pageName: 'sale', offerIds: [ 100, 200 ] });
|
||||
const branch = makeNodeData({ pageId: 3, pageName: 'shop', offerIds: [ 100 ], children: [ leaf ] });
|
||||
const rootData = makeNodeData({ pageId: 0, pageName: 'root', children: [ branch ] });
|
||||
|
||||
const { rootNode, offersToNodes } = buildCatalogNodeTree(rootData as any);
|
||||
|
||||
// tree shape
|
||||
expect(rootNode.children).toHaveLength(1);
|
||||
expect(rootNode.children[0].pageId).toBe(3);
|
||||
expect(rootNode.children[0].children[0].pageId).toBe(5);
|
||||
|
||||
// depth incremented
|
||||
expect(rootNode.depth).toBe(0);
|
||||
expect(rootNode.children[0].depth).toBe(1);
|
||||
expect(rootNode.children[0].children[0].depth).toBe(2);
|
||||
|
||||
// offerId index records both nodes for offer 100, only the leaf for 200
|
||||
expect(offersToNodes.get(100)).toHaveLength(2);
|
||||
expect(offersToNodes.get(100)?.map(n => n.pageId)).toEqual([ 3, 5 ]);
|
||||
expect(offersToNodes.get(200)?.map(n => n.pageId)).toEqual([ 5 ]);
|
||||
});
|
||||
|
||||
it('preserves child-parent relationships', () =>
|
||||
{
|
||||
const leaf = makeNodeData({ pageId: 5, pageName: 'sale' });
|
||||
const rootData = makeNodeData({ pageId: 0, pageName: 'root', children: [ leaf ] });
|
||||
|
||||
const { rootNode } = buildCatalogNodeTree(rootData as any);
|
||||
|
||||
expect(rootNode.children[0].parent).toBe(rootNode);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveBuilderFurniPlaceableStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveBuilderFurniPlaceableStatus', () =>
|
||||
{
|
||||
const offer = { offerId: 1 } as any;
|
||||
|
||||
const baseInput = {
|
||||
offer,
|
||||
roomSession: { isGuildRoom: false, isRoomOwner: true, controllerLevel: 0 },
|
||||
secondsLeft: 60,
|
||||
furniCount: 0,
|
||||
furniLimit: 10,
|
||||
builderPlacementAllowedInCurrentRoom: false,
|
||||
builderPlacementBlockedByVisitors: false,
|
||||
visitorCount: 0
|
||||
};
|
||||
|
||||
it('returns MISSING_OFFER when offer is null', () =>
|
||||
{
|
||||
expect(resolveBuilderFurniPlaceableStatus({ ...baseInput, offer: null })).toBe(BuilderFurniPlaceableStatus.MISSING_OFFER);
|
||||
});
|
||||
|
||||
it('returns NOT_IN_ROOM when roomSession is null', () =>
|
||||
{
|
||||
expect(resolveBuilderFurniPlaceableStatus({ ...baseInput, roomSession: null })).toBe(BuilderFurniPlaceableStatus.NOT_IN_ROOM);
|
||||
});
|
||||
|
||||
it('returns OKAY for the room owner with time on the clock', () =>
|
||||
{
|
||||
expect(resolveBuilderFurniPlaceableStatus(baseInput)).toBe(BuilderFurniPlaceableStatus.OKAY);
|
||||
});
|
||||
|
||||
it('returns NOT_GROUP_ADMIN for a non-owner without group fallback or shared pool', () =>
|
||||
{
|
||||
const input = {
|
||||
...baseInput,
|
||||
roomSession: { isGuildRoom: false, isRoomOwner: false, controllerLevel: 0 }
|
||||
};
|
||||
|
||||
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN);
|
||||
});
|
||||
|
||||
it('returns OKAY for guild admin with subscription time remaining', () =>
|
||||
{
|
||||
const input = {
|
||||
...baseInput,
|
||||
roomSession: { isGuildRoom: true, isRoomOwner: false, controllerLevel: 4 /* GUILD_ADMIN */ },
|
||||
secondsLeft: 60
|
||||
};
|
||||
|
||||
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY);
|
||||
});
|
||||
|
||||
it('returns FURNI_LIMIT_REACHED when count meets the limit and no shared pool applies', () =>
|
||||
{
|
||||
const input = { ...baseInput, furniCount: 10, furniLimit: 10 };
|
||||
|
||||
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED);
|
||||
});
|
||||
|
||||
it('skips the furni limit when builderPlacementAllowedInCurrentRoom for a non-owner', () =>
|
||||
{
|
||||
const input = {
|
||||
...baseInput,
|
||||
roomSession: { isGuildRoom: false, isRoomOwner: false, controllerLevel: 0 },
|
||||
furniCount: 99,
|
||||
furniLimit: 10,
|
||||
builderPlacementAllowedInCurrentRoom: true
|
||||
};
|
||||
|
||||
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY);
|
||||
});
|
||||
|
||||
it('returns VISITORS_IN_ROOM when the subscription has expired and the flag is set', () =>
|
||||
{
|
||||
const input = {
|
||||
...baseInput,
|
||||
secondsLeft: 0,
|
||||
builderPlacementBlockedByVisitors: true
|
||||
};
|
||||
|
||||
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.VISITORS_IN_ROOM);
|
||||
});
|
||||
|
||||
it('returns VISITORS_IN_ROOM when the subscription has expired and there are visitors counted', () =>
|
||||
{
|
||||
const input = { ...baseInput, secondsLeft: 0, visitorCount: 3 };
|
||||
|
||||
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.VISITORS_IN_ROOM);
|
||||
});
|
||||
|
||||
it('returns OKAY when the subscription has expired but the room is empty', () =>
|
||||
{
|
||||
const input = { ...baseInput, secondsLeft: 0, visitorCount: 0 };
|
||||
|
||||
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CatalogType } from '../../api/catalog/CatalogType';
|
||||
import { getOffersStorageKey, getPagesStorageKey, normalizeCatalogType, parseOffers, parsePages, STORAGE_KEY_OFFERS_BUILDER, STORAGE_KEY_OFFERS_NORMAL, STORAGE_KEY_PAGES_BUILDER, STORAGE_KEY_PAGES_NORMAL } from './useCatalogFavorites.helpers';
|
||||
|
||||
describe('normalizeCatalogType', () =>
|
||||
{
|
||||
it('returns NORMAL when nothing is passed', () =>
|
||||
{
|
||||
expect(normalizeCatalogType()).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
|
||||
it('returns NORMAL for unknown strings', () =>
|
||||
{
|
||||
expect(normalizeCatalogType('not-a-real-type')).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
|
||||
it('returns BUILDER only for the exact BUILDER constant', () =>
|
||||
{
|
||||
expect(normalizeCatalogType(CatalogType.BUILDER)).toBe(CatalogType.BUILDER);
|
||||
});
|
||||
|
||||
it('returns NORMAL for NORMAL explicitly', () =>
|
||||
{
|
||||
expect(normalizeCatalogType(CatalogType.NORMAL)).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOffersStorageKey / getPagesStorageKey', () =>
|
||||
{
|
||||
it('routes the BUILDER catalog to the builder storage keys', () =>
|
||||
{
|
||||
expect(getOffersStorageKey(CatalogType.BUILDER)).toBe(STORAGE_KEY_OFFERS_BUILDER);
|
||||
expect(getPagesStorageKey(CatalogType.BUILDER)).toBe(STORAGE_KEY_PAGES_BUILDER);
|
||||
});
|
||||
|
||||
it('routes the NORMAL catalog to the normal storage keys', () =>
|
||||
{
|
||||
expect(getOffersStorageKey(CatalogType.NORMAL)).toBe(STORAGE_KEY_OFFERS_NORMAL);
|
||||
expect(getPagesStorageKey(CatalogType.NORMAL)).toBe(STORAGE_KEY_PAGES_NORMAL);
|
||||
});
|
||||
|
||||
it('falls back to NORMAL keys for unknown / missing catalog type', () =>
|
||||
{
|
||||
expect(getOffersStorageKey()).toBe(STORAGE_KEY_OFFERS_NORMAL);
|
||||
expect(getOffersStorageKey('garbage')).toBe(STORAGE_KEY_OFFERS_NORMAL);
|
||||
expect(getPagesStorageKey()).toBe(STORAGE_KEY_PAGES_NORMAL);
|
||||
expect(getPagesStorageKey('garbage')).toBe(STORAGE_KEY_PAGES_NORMAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOffers', () =>
|
||||
{
|
||||
it('returns an empty array on invalid JSON', () =>
|
||||
{
|
||||
expect(parseOffers('not json')).toEqual([]);
|
||||
expect(parseOffers('{')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the parsed value is not an array', () =>
|
||||
{
|
||||
expect(parseOffers('null')).toEqual([]);
|
||||
expect(parseOffers('{}')).toEqual([]);
|
||||
expect(parseOffers('42')).toEqual([]);
|
||||
expect(parseOffers('"hello"')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array unchanged', () =>
|
||||
{
|
||||
expect(parseOffers('[]')).toEqual([]);
|
||||
});
|
||||
|
||||
it('migrates the v2 number[] format into IFavoriteOffer[] with offerId only', () =>
|
||||
{
|
||||
expect(parseOffers('[101, 202, 303]')).toEqual([
|
||||
{ offerId: 101 },
|
||||
{ offerId: 202 },
|
||||
{ offerId: 303 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes through a well-formed v3 IFavoriteOffer[] unchanged', () =>
|
||||
{
|
||||
const v3 = [
|
||||
{ offerId: 5, name: 'red sofa', iconUrl: 'http://example.com/sofa.png' },
|
||||
{ offerId: 9 }
|
||||
];
|
||||
|
||||
expect(parseOffers(JSON.stringify(v3))).toEqual(v3);
|
||||
});
|
||||
|
||||
it('only triggers migration when the first element is a number (mixed arrays go through as-is)', () =>
|
||||
{
|
||||
const mixed = [ { offerId: 1 }, { offerId: 2 } ];
|
||||
|
||||
expect(parseOffers(JSON.stringify(mixed))).toEqual(mixed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePages', () =>
|
||||
{
|
||||
it('returns an empty array on invalid JSON', () =>
|
||||
{
|
||||
expect(parsePages('not json')).toEqual([]);
|
||||
expect(parsePages('}{')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the parsed value is not an array', () =>
|
||||
{
|
||||
expect(parsePages('null')).toEqual([]);
|
||||
expect(parsePages('{ "pages": [1, 2] }')).toEqual([]);
|
||||
expect(parsePages('"42"')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the parsed array as-is', () =>
|
||||
{
|
||||
expect(parsePages('[]')).toEqual([]);
|
||||
expect(parsePages('[1, 2, 3]')).toEqual([ 1, 2, 3 ]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AvatarInfoUser } from '../../../api/room/widgets/AvatarInfoUser';
|
||||
import type { IAvatarInfo } from '../../../api/room/widgets/IAvatarInfo';
|
||||
import { applyFavouriteGroupUpdate, applyUserBadgesUpdate, applyUserFigureUpdate } from './avatarInfo.reducers';
|
||||
|
||||
/**
|
||||
* Pure reducers for the InfoStand pilot. They take the inspected
|
||||
* AvatarInfoUser plus a room-session event and return the next state
|
||||
* (or the same reference if the event doesn't apply, to let React
|
||||
* skip the re-render).
|
||||
*
|
||||
* The TS types reference renderer event classes
|
||||
* (RoomSessionUserBadgesEvent etc.) but the reducer body only reads
|
||||
* plain fields — no `instanceof EventType` checks — so the tests can
|
||||
* pass plain objects cast to the renderer types.
|
||||
*/
|
||||
|
||||
const buildAvatarInfoUser = (overrides: Partial<AvatarInfoUser> = {}): AvatarInfoUser =>
|
||||
{
|
||||
const instance = new AvatarInfoUser(AvatarInfoUser.OWN_USER);
|
||||
|
||||
Object.assign(instance, overrides);
|
||||
|
||||
return instance;
|
||||
};
|
||||
|
||||
describe('applyUserBadgesUpdate', () =>
|
||||
{
|
||||
it('returns the same reference when state is not an AvatarInfoUser', () =>
|
||||
{
|
||||
const state: IAvatarInfo = { type: 'NOT_USER' } as IAvatarInfo;
|
||||
const event = { userId: 42, badges: [ 'a' ] } as any;
|
||||
|
||||
expect(applyUserBadgesUpdate(state, event)).toBe(state);
|
||||
});
|
||||
|
||||
it('returns the same reference when state is null', () =>
|
||||
{
|
||||
const event = { userId: 42, badges: [ 'a' ] } as any;
|
||||
|
||||
expect(applyUserBadgesUpdate(null, event)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the same reference when the event is for a different user', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ webID: 1, badges: [] });
|
||||
const event = { userId: 99, badges: [ 'a' ] } as any;
|
||||
|
||||
expect(applyUserBadgesUpdate(state, event)).toBe(state);
|
||||
});
|
||||
|
||||
it('returns the same reference when the dedup result equals the existing badges', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ webID: 42, badges: [ 'a', 'b' ] });
|
||||
const event = { userId: 42, badges: [ 'a', 'b' ] } as any;
|
||||
|
||||
expect(applyUserBadgesUpdate(state, event)).toBe(state);
|
||||
});
|
||||
|
||||
it('returns a cloned AvatarInfoUser with deduped badges when the event applies', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ webID: 42, badges: [ 'a' ], name: 'alice' });
|
||||
const event = { userId: 42, badges: [ 'b', 'b', 'c' ] } as any;
|
||||
|
||||
const next = applyUserBadgesUpdate(state, event) as AvatarInfoUser;
|
||||
|
||||
expect(next).not.toBe(state);
|
||||
expect(next).toBeInstanceOf(AvatarInfoUser);
|
||||
expect(next.badges).toEqual([ 'b', '', 'c' ]);
|
||||
// surrounding fields propagate via Object.assign
|
||||
expect(next.name).toBe('alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyUserFigureUpdate', () =>
|
||||
{
|
||||
it('returns the same reference when state is not an AvatarInfoUser', () =>
|
||||
{
|
||||
const state: IAvatarInfo = { type: 'NOT_USER' } as IAvatarInfo;
|
||||
const event = { roomIndex: 5, figure: 'hr-100' } as any;
|
||||
|
||||
expect(applyUserFigureUpdate(state, event)).toBe(state);
|
||||
});
|
||||
|
||||
it('ignores events targeting a different roomIndex', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ roomIndex: 3, figure: 'old' });
|
||||
const event = { roomIndex: 7, figure: 'new' } as any;
|
||||
|
||||
expect(applyUserFigureUpdate(state, event)).toBe(state);
|
||||
});
|
||||
|
||||
it('applies all 13 figure-related fields when roomIndex matches', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ roomIndex: 3 });
|
||||
const event = {
|
||||
roomIndex: 3,
|
||||
figure: 'hr-100-7.hd-180-1',
|
||||
customInfo: 'new motto',
|
||||
activityPoints: 1234,
|
||||
nickIcon: 'icon-vip',
|
||||
prefixText: '[Mod]',
|
||||
prefixColor: '#ff0000',
|
||||
prefixIcon: 'icon-mod',
|
||||
prefixEffect: 'glow',
|
||||
displayOrder: 'prefix-icon-name',
|
||||
backgroundId: 8,
|
||||
standId: 4,
|
||||
overlayId: 2,
|
||||
cardBackgroundId: 9
|
||||
} as any;
|
||||
|
||||
const next = applyUserFigureUpdate(state, event) as AvatarInfoUser;
|
||||
|
||||
expect(next).not.toBe(state);
|
||||
expect(next.figure).toBe('hr-100-7.hd-180-1');
|
||||
expect(next.motto).toBe('new motto');
|
||||
expect(next.achievementScore).toBe(1234);
|
||||
expect(next.nickIcon).toBe('icon-vip');
|
||||
expect(next.prefixText).toBe('[Mod]');
|
||||
expect(next.prefixColor).toBe('#ff0000');
|
||||
expect(next.prefixIcon).toBe('icon-mod');
|
||||
expect(next.prefixEffect).toBe('glow');
|
||||
expect(next.displayOrder).toBe('prefix-icon-name');
|
||||
expect(next.backgroundId).toBe(8);
|
||||
expect(next.standId).toBe(4);
|
||||
expect(next.overlayId).toBe(2);
|
||||
expect(next.cardBackgroundId).toBe(9);
|
||||
});
|
||||
|
||||
it('defaults cardBackgroundId to 0 when the server omits it', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ roomIndex: 3, cardBackgroundId: 7 });
|
||||
const event = {
|
||||
roomIndex: 3,
|
||||
figure: 'x',
|
||||
customInfo: '',
|
||||
activityPoints: 0,
|
||||
nickIcon: '',
|
||||
prefixText: '',
|
||||
prefixColor: '',
|
||||
prefixIcon: '',
|
||||
prefixEffect: '',
|
||||
displayOrder: 'icon-prefix-name',
|
||||
backgroundId: 0,
|
||||
standId: 0,
|
||||
overlayId: 0
|
||||
// no cardBackgroundId
|
||||
} as any;
|
||||
|
||||
const next = applyUserFigureUpdate(state, event) as AvatarInfoUser;
|
||||
|
||||
expect(next.cardBackgroundId).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyFavouriteGroupUpdate', () =>
|
||||
{
|
||||
const resolveGroupBadge = (groupId: number) => `badge-${ groupId }`;
|
||||
|
||||
it('returns the same reference when state is not an AvatarInfoUser', () =>
|
||||
{
|
||||
const state: IAvatarInfo = { type: 'NOT_USER' } as IAvatarInfo;
|
||||
const event = { roomIndex: 5, status: 1, habboGroupId: 42, habboGroupName: 'Cool Group' } as any;
|
||||
|
||||
expect(applyFavouriteGroupUpdate(state, event, resolveGroupBadge)).toBe(state);
|
||||
});
|
||||
|
||||
it('ignores events targeting a different roomIndex', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ roomIndex: 3 });
|
||||
const event = { roomIndex: 7, status: 1, habboGroupId: 42, habboGroupName: 'g' } as any;
|
||||
|
||||
expect(applyFavouriteGroupUpdate(state, event, resolveGroupBadge)).toBe(state);
|
||||
});
|
||||
|
||||
it('applies a fresh group when status is positive and groupId is positive', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ roomIndex: 3, groupId: -1 });
|
||||
const event = { roomIndex: 3, status: 1, habboGroupId: 42, habboGroupName: 'Cool Group' } as any;
|
||||
|
||||
const next = applyFavouriteGroupUpdate(state, event, resolveGroupBadge) as AvatarInfoUser;
|
||||
|
||||
expect(next.groupId).toBe(42);
|
||||
expect(next.groupName).toBe('Cool Group');
|
||||
expect(next.groupBadgeId).toBe('badge-42');
|
||||
});
|
||||
|
||||
it('clears the group when status is -1', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ roomIndex: 3, groupId: 42, groupName: 'old', groupBadgeId: 'badge-42' });
|
||||
const event = { roomIndex: 3, status: -1, habboGroupId: 42, habboGroupName: 'ignored' } as any;
|
||||
|
||||
const next = applyFavouriteGroupUpdate(state, event, resolveGroupBadge) as AvatarInfoUser;
|
||||
|
||||
expect(next.groupId).toBe(-1);
|
||||
expect(next.groupName).toBeNull();
|
||||
expect(next.groupBadgeId).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the group when habboGroupId is 0 (no favourite)', () =>
|
||||
{
|
||||
const state = buildAvatarInfoUser({ roomIndex: 3, groupId: 7 });
|
||||
const event = { roomIndex: 3, status: 1, habboGroupId: 0, habboGroupName: 'ignored' } as any;
|
||||
|
||||
const next = applyFavouriteGroupUpdate(state, event, resolveGroupBadge) as AvatarInfoUser;
|
||||
|
||||
expect(next.groupId).toBe(-1);
|
||||
expect(next.groupName).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { RoomSessionDoorbellEvent } from '@nitrots/nitro-renderer';
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useDoorbellState } from './useDoorbellState';
|
||||
import { clearMockEventDispatcher, mockEventDispatcher } from '../../../__mocks__/nitro-renderer';
|
||||
|
||||
// Server push helper — mirrors the renderer wire by emitting the same
|
||||
// constants the SUT listens to. The real constructor takes a session
|
||||
// reference too; pass null since the SUT only reads `.userName`.
|
||||
const dispatchDoorbell = (type: string, userName: string) =>
|
||||
{
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(new RoomSessionDoorbellEvent(type, null as any, userName));
|
||||
});
|
||||
};
|
||||
|
||||
describe('useDoorbellState', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
clearMockEventDispatcher();
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('starts with no users pending', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorbellState());
|
||||
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('appends the username from a DOORBELL event', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorbellState());
|
||||
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice');
|
||||
|
||||
expect(result.current).toEqual([ 'Alice' ]);
|
||||
});
|
||||
|
||||
it('ignores duplicate DOORBELL events for the same username', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorbellState());
|
||||
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice');
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice');
|
||||
|
||||
expect(result.current).toEqual([ 'Alice' ]);
|
||||
});
|
||||
|
||||
it('removes a user on RSDE_ACCEPTED while keeping the others', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorbellState());
|
||||
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice');
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Bob');
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_ACCEPTED, 'Alice');
|
||||
|
||||
expect(result.current).toEqual([ 'Bob' ]);
|
||||
});
|
||||
|
||||
it('removes a user on RSDE_REJECTED', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorbellState());
|
||||
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Carol');
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_REJECTED, 'Carol');
|
||||
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores accept/reject events for users that were never pending', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorbellState());
|
||||
|
||||
dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_ACCEPTED, 'Ghost');
|
||||
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('unsubscribes from all three events on unmount', () =>
|
||||
{
|
||||
const { unmount } = renderHook(() => useDoorbellState());
|
||||
|
||||
expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.DOORBELL)).toBe(true);
|
||||
expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_ACCEPTED)).toBe(true);
|
||||
expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_REJECTED)).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.DOORBELL)).toBe(false);
|
||||
expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_ACCEPTED)).toBe(false);
|
||||
expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_REJECTED)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
Reference in New Issue
Block a user