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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user