Files
Nitro-V3/src/nitro-renderer.mock.ts
T
simoleo89 b540b163c6 feat(floorplan-editor): React rewrite + live in-room preview + UX polish
Complete modernization of the floor-plan editor. Three layered
changes shipped together since they share state shapes and the
test infrastructure stubs.

1) React rewrite (state + hooks + views + tests)

   Drops the FloorplanEditorContext singleton + legacy view
   components and replaces them with a pure-React reducer
   architecture:

   - state/ — typed FloorplanState + FloorplanAction union,
     pure reducer covering PAINT_TILE / ERASE_TILE /
     ADJUST_HEIGHT / SET_DOOR / SET_DOOR_DIR / SET_THICKNESS /
     SET_WALL_HEIGHT / BRUSH_SET / SELECT_RECT / SELECT_ALL /
     CLEAR_SELECTION / SQUARE_SELECT_TOGGLE / IMPORT_STRING /
     APPLY_REMOTE_DIFF / APPLY_REMOTE_SNAPSHOT. Source-tagged
     ('local' | 'remote') so the editor can distinguish user
     edits from server pushes. Co-located encoding helpers
     (parseTilemap / serializeTilemap) and area-counter
     selectors.
   - hooks/ — useFloorplanReducer (wraps useReducer with a
     history stack + loadFromServer + undo/redo), useTool
     (pointer events -> dispatch), usePointerToTile (screen
     -> tile projection that respects the viewBox origin so
     pan/zoom stays accurate).
   - views/ — FloorplanCanvasSVG, FloorplanHeightPicker,
     FloorplanToolbar, FloorplanOptionsPanel,
     FloorplanImportExport, FloorplanTile,
     FloorplanPreviewSVG (alternative iso preview kept as a
     fallback view, not wired into the main layout).
   - Co-located Vitest suites for every module above (encoding,
     reducer, selectors, hooks, views, integration). 100+ new
     test cases.

2) Live in-room preview (NEW capability)

   useFloorplanLiveSync drives client-side preview of the edit
   directly into the active room — every tile / door / wall
   height / thickness change is applied through
   GetRoomMessageHandler().applyFloorModelLocally (new public
   method on the renderer, see paired renderer PR) with
   zero server traffic during editing. The wire
   UpdateFloorPropertiesMessageComposer is only sent when the
   user explicitly clicks Save. Thickness slider additionally
   calls RoomEngine.updateRoomInstancePlaneThickness for
   zero-latency wall/floor-depth feedback while dragging.

   Toggle 'Live preview ON / OFF' in the bottom strip (default
   ON) lets the user opt out if they want to keep changes
   contained to the editor's own preview until Save.
   Revert button re-applies the original snapshot locally so
   the room snaps back to where it was when the editor opened.

3) UX polish

   - Undo / Redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y) backed by a
     100-step history stack inside useFloorplanReducer. Local
     mutating actions push history; brush/selection UI bumps
     and remote dispatches bypass it; loadFromServer wipes the
     stack.
   - Zoom 40-600 % with Ctrl+wheel, +/- buttons, % label.
     Shift+drag or middle-mouse drag pans the canvas.
   - Auto-fit on first paint: computes the screen-space
     bounding box of the painted (non-blocked) tiles, picks the
     zoom that just contains them with a 5 % margin, pans so
     the room sits in the viewport centre. Default view is now
     'room fills the canvas' instead of 'room is a dot at the
     top-centre of a huge empty canvas'. Clicking the % label
     re-runs the fit; crosshair button keeps zoom and recentres
     the pan only.
   - Door direction control: arrows + door icon triplet
     (8-way rotate by single click on prev/next, full cycle
     forward on the icon itself). Wall and floor thickness
     collapse from two 4-button rows into two compact
     segmented selectors (active state in emerald). Saves
     significant horizontal space.
   - Habbo floor pattern tile (~186 B PNG, vendored from
     habbofurni.com/images/furni_floor.png) tiled as the
     canvas background with image-rendering: pixelated so the
     texture stays crisp at every zoom level. Replaces the
     solid black background.

Test infrastructure

   nitro-renderer.mock grows constructors / proxies / functions
   for everything the new floor-editor tests transitively
   import (floor composers + events, RoomEngineEvent,
   ILinkEventTracker, convertNumbersForSaving /
   convertSettingToNumber, GetRoomMessageHandler,
   GetTicker, GetRenderer, NitroTicker, RoomPreviewer with a
   sufficiently real .updatePreviewModel / dispose surface,
   and a TextureUtils.createRenderTexture that returns an
   object with a no-op .destroy). test-setup adds a no-op
   ResizeObserver polyfill (jsdom doesn't ship one and the
   optional FloorplanRoomPreview observes its container) and
   a draggable-windows-container portal root for tests that
   mount NitroCardView.

Files: 44 changed (mostly new). yarn typecheck 0 errors,
yarn test 341/341 green.
2026-05-24 21:19:10 +02:00

421 lines
16 KiB
TypeScript

/**
* 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;
}
// Mirrors `packages/api/src/nitro/session/enum/SecurityLevel.ts`. Kept
// around so any consumer that still imports the renderer enum
// (non-deprecated code path) compiles cleanly under the mock.
export class SecurityLevel
{
static readonly NONE = 0;
static readonly CELEBRITY = 1;
static readonly PARTNER = 2;
static readonly BUS_PARTNER = 3;
static readonly EMPLOYEE = 4;
static readonly MODERATOR = 5;
static readonly PLAYER_SUPPORT = 6;
static readonly COMMUNITY = 7;
static readonly ADMINISTRATOR = 8;
static readonly SUPER_USER = 9;
}
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');
// ---------------------------------------------------------------------------
// Floor plan constants
// ---------------------------------------------------------------------------
export const FloorAction = makeEnumProxy('FloorAction');
export const TILE_SIZE = 32;
export const HEIGHT_SCHEME = 'x0123456789abcdefghijklmnopq';
export const MAX_NUM_TILE_PER_AXIS = 64;
export const COLORMAP: object = {
'x': '101010',
'0': '0065ff', '1': '0091ff', '2': '00bcff', '3': '00e8ff',
'4': '00ffea', '5': '00ffbf', '6': '00ff93', '7': '00ff68',
'8': '00ff3d', '9': '19ff00',
'a': '44ff00', 'b': '70ff00', 'c': '9bff00', 'd': 'f2ff00',
'e': 'ffe000', 'f': 'ffb500', 'g': 'ff8900', 'h': 'ff5e00',
'i': 'ff3200', 'j': 'ff0700', 'k': 'ff0023', 'l': 'ff007a',
'm': 'ff00a5', 'n': 'ff00d1', 'o': 'ff00fc',
'p': 'd600ff', 'q': 'aa00ff'
};
// ---------------------------------------------------------------------------
// Floor plan editor — composer stubs and event classes
// ---------------------------------------------------------------------------
// Composer stubs for floor-plan-editor message events
export class GetRoomEntryTileMessageComposer extends StubClass {}
export class GetOccupiedTilesMessageComposer extends StubClass {}
export class UpdateFloorPropertiesMessageComposer extends StubClass
{
public tilemap: string;
public doorX: number;
public doorY: number;
public dir: number;
public thicknessWall: number;
public thicknessFloor: number;
public wallHeight: number;
constructor(tilemap: string, doorX: number, doorY: number, dir: number, thicknessWall: number, thicknessFloor: number, wallHeight: number)
{
super();
this.tilemap = tilemap;
this.doorX = doorX;
this.doorY = doorY;
this.dir = dir;
this.thicknessWall = thicknessWall;
this.thicknessFloor = thicknessFloor;
this.wallHeight = wallHeight;
}
}
// Event class stubs for useMessageEvent registration
export class FloorHeightMapEvent extends StubClass {}
export class RoomVisualizationSettingsEvent extends StubClass {}
export class RoomEntryTileMessageEvent extends StubClass {}
export class RoomOccupiedTilesMessageEvent extends StubClass {}
export const RoomEngineEvent = makeEnumProxy('RoomEngineEvent');
// Link tracker stubs
export type ILinkEventTracker = { linkReceived: (url: string) => void; eventUrlPrefix: string };
export const AddLinkEventTracker = vi.fn();
export const RemoveLinkEventTracker = vi.fn();
// Thickness conversion helpers — mirror the renderer's real mapping
export const convertNumbersForSaving = (v: number): number =>
{
switch(v)
{
case 0: return -2;
case 1: return -1;
case 3: return 1;
default: return 0;
}
};
export const convertSettingToNumber = (v: number): number =>
{
switch(v)
{
case 0.25: return 0;
case 0.5: return 1;
case 2: return 3;
default: return 2;
}
};
// ---------------------------------------------------------------------------
// 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 GetRoomMessageHandler = vi.fn(stubManager);
export const GetRoomSessionManager = vi.fn(stubManager);
// RoomPreviewer — only the bits the editor's FloorplanRoomPreview
// component touches. PREVIEW_COUNTER is a static field that the
// real renderer increments to allocate unique preview-room IDs;
// keeping it as a mutable static lets the editor mount/unmount
// repeatedly across tests without colliding.
export class RoomPreviewer
{
static PREVIEW_COUNTER = 0;
constructor(public readonly _engine: unknown, public readonly _id: number) {}
public updatePreviewModel(_model: string, _wallHeight: number, _scale?: boolean): void {}
public modifyRoomCanvas(_w: number, _h: number): void {}
public getRoomCanvas(_w: number, _h: number): unknown { return null; }
public getRenderingCanvas(): unknown { return null; }
public updatePreviewRoomView(): void {}
public changeRoomObjectDirection(): void {}
public changeRoomObjectState(): void {}
public dispose(): void {}
}
export const GetSessionDataManager = vi.fn(stubManager);
export const GetTickerTime = vi.fn(() => 0);
export const GetTicker = vi.fn(stubManager);
export const GetRenderer = vi.fn(stubManager);
export class NitroTicker {}
// TextureUtils — a real-enough stub of the createRenderTexture
// roundtrip. Tests that mount LayoutRoomPreviewerView allocate a
// texture on mount and destroy it on unmount; without a real
// `.destroy` method the unmount cleanup throws.
export const TextureUtils = {
createRenderTexture: (_w: number, _h: number) => ({
destroy: (_options?: unknown) => undefined
}),
generateImage: () => null
};
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.