refactor(hooks/rooms): collapse usePetPackageWidget 5 useStates into useReducer

The hook tracked five related useState fields driving the pet-package
naming dialog (isVisible / objectId / objectType / petName / errorResult).
They transitioned in lockstep on the two RoomSessionPetPackageEvent
types and the inline change handler — textbook state-machine territory.

Collapse into a single useReducer with four explicit transitions:
- 'open'      → REQUESTED event lands; flips visible, records target
- 'close'     → REQUESTED-result success OR user dismiss; resets to INITIAL
- 'set-name'  → input change; updates petName AND clears any error
                (the previous code had this side effect inlined in
                onChangePetName as `if(errorResult.length) setErrorResult('')`,
                now it's part of the reducer contract)
- 'set-error' → REQUESTED-result with validation failure; sets the label

Plus extract `getPetPackageNameError(code)` to a top-level exported
pure function (was an inline closure named getErrorResultForCode).
The mapping is server-protocol contract, not UI state — moving it out
of the hook means it's testable, reusable, and won't be recreated on
every render.

Public API of usePetPackageWidget is unchanged — the one consumer
(PetPackageWidgetView) reads the same destructured fields. Verified
via grep.

Tests: 4 new cases on getPetPackageNameError covering code 0 / 1-4 /
falsy / unknown-fallback. Suite: 207/207 (was 203/203).
This commit is contained in:
simoleo89
2026-05-18 21:41:43 +02:00
parent 2db6df71a9
commit c3a76b643d
2 changed files with 116 additions and 49 deletions
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../../api', () => ({
LocalizeText: (key: string) => key,
SendMessageComposer: vi.fn()
}));
import { getPetPackageNameError } from './usePetPackageWidget';
describe('getPetPackageNameError', () =>
{
it('returns empty string for code 0 (no error)', () =>
{
expect(getPetPackageNameError(0)).toBe('');
});
it('returns empty string for falsy values (null/undefined coerced)', () =>
{
expect(getPetPackageNameError(null as never)).toBe('');
expect(getPetPackageNameError(undefined as never)).toBe('');
});
it('maps the four documented error codes to their localization keys', () =>
{
expect(getPetPackageNameError(1)).toBe('catalog.alert.petname.long');
expect(getPetPackageNameError(2)).toBe('catalog.alert.petname.short');
expect(getPetPackageNameError(3)).toBe('catalog.alert.petname.chars');
expect(getPetPackageNameError(4)).toBe('catalog.alert.petname.bobba');
});
it('falls back to the bobba label for unknown error codes (defensive default)', () =>
{
expect(getPetPackageNameError(99)).toBe('catalog.alert.petname.bobba');
expect(getPetPackageNameError(-1)).toBe('catalog.alert.petname.bobba');
});
});
+80 -49
View File
@@ -1,53 +1,74 @@
import { GetRoomEngine, OpenPetPackageMessageComposer, RoomObjectCategory, RoomSessionPetPackageEvent } from '@nitrots/nitro-renderer'; import { GetRoomEngine, OpenPetPackageMessageComposer, RoomObjectCategory, RoomSessionPetPackageEvent } from '@nitrots/nitro-renderer';
import { useState } from 'react'; import { useReducer } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../api'; import { LocalizeText, SendMessageComposer } from '../../../api';
import { useNitroEvent } from '../../events'; import { useNitroEvent } from '../../events';
interface PetPackageState
{
isVisible: boolean;
objectId: number;
objectType: string;
petName: string;
errorResult: string;
}
type PetPackageAction =
| { type: 'open'; objectId: number; objectType: string }
| { type: 'close' }
| { type: 'set-name'; petName: string }
| { type: 'set-error'; errorResult: string };
const INITIAL_STATE: PetPackageState = {
isVisible: false,
objectId: -1,
objectType: '',
petName: '',
errorResult: ''
};
const petPackageReducer = (state: PetPackageState, action: PetPackageAction): PetPackageState =>
{
switch(action.type)
{
case 'open':
return { ...INITIAL_STATE, isVisible: true, objectId: action.objectId, objectType: action.objectType };
case 'close':
return INITIAL_STATE;
case 'set-name':
// Typing into the input always clears any previous error label.
return { ...state, petName: action.petName, errorResult: '' };
case 'set-error':
return { ...state, errorResult: action.errorResult };
}
};
/**
* Maps the pet-package name-validation error code returned by the
* server to a localized error label. Exported for testability — the
* mapping is server-protocol contract, not UI state.
*/
export const getPetPackageNameError = (errorCode: number): string =>
{
if(!errorCode) return '';
switch(errorCode)
{
case 1: return LocalizeText('catalog.alert.petname.long');
case 2: return LocalizeText('catalog.alert.petname.short');
case 3: return LocalizeText('catalog.alert.petname.chars');
case 4:
default:
return LocalizeText('catalog.alert.petname.bobba');
}
};
const usePetPackageWidgetState = () => const usePetPackageWidgetState = () =>
{ {
const [ isVisible, setIsVisible ] = useState<boolean>(false); const [ state, dispatch ] = useReducer(petPackageReducer, INITIAL_STATE);
const [ objectId, setObjectId ] = useState<number>(-1);
const [ objectType, setObjectType ] = useState<string>('');
const [ petName, setPetName ] = useState<string>('');
const [ errorResult, setErrorResult ] = useState<string>('');
const onClose = () => const onClose = () => dispatch({ type: 'close' });
{ const onConfirm = () => SendMessageComposer(new OpenPetPackageMessageComposer(state.objectId, state.petName));
setErrorResult(''); const onChangePetName = (petName: string) => dispatch({ type: 'set-name', petName });
setPetName('');
setObjectType('');
setObjectId(-1);
setIsVisible(false);
};
const onConfirm = () =>
{
SendMessageComposer(new OpenPetPackageMessageComposer(objectId, petName));
};
const onChangePetName = (petName: string) =>
{
setPetName(petName);
if(errorResult.length > 0) setErrorResult('');
};
const getErrorResultForCode = (errorCode: number) =>
{
if(!errorCode || errorCode === 0) return;
switch(errorCode)
{
case 1:
return LocalizeText('catalog.alert.petname.long');
case 2:
return LocalizeText('catalog.alert.petname.short');
case 3:
return LocalizeText('catalog.alert.petname.chars');
case 4:
default:
return LocalizeText('catalog.alert.petname.bobba');
}
};
useNitroEvent<RoomSessionPetPackageEvent>(RoomSessionPetPackageEvent.RSOPPE_OPEN_PET_PACKAGE_REQUESTED, event => useNitroEvent<RoomSessionPetPackageEvent>(RoomSessionPetPackageEvent.RSOPPE_OPEN_PET_PACKAGE_REQUESTED, event =>
{ {
@@ -55,21 +76,31 @@ const usePetPackageWidgetState = () =>
const roomObject = GetRoomEngine().getRoomObject(event.session.roomId, event.objectId, RoomObjectCategory.FLOOR); const roomObject = GetRoomEngine().getRoomObject(event.session.roomId, event.objectId, RoomObjectCategory.FLOOR);
setObjectId(event.objectId); dispatch({ type: 'open', objectId: event.objectId, objectType: roomObject.type });
setObjectType(roomObject.type);
setIsVisible(true);
}); });
useNitroEvent<RoomSessionPetPackageEvent>(RoomSessionPetPackageEvent.RSOPPE_OPEN_PET_PACKAGE_RESULT, event => useNitroEvent<RoomSessionPetPackageEvent>(RoomSessionPetPackageEvent.RSOPPE_OPEN_PET_PACKAGE_RESULT, event =>
{ {
if(!event) return; if(!event) return;
if(event.nameValidationStatus === 0) onClose(); if(event.nameValidationStatus === 0)
{
dispatch({ type: 'close' });
return;
}
if(event.nameValidationStatus !== 0) setErrorResult(getErrorResultForCode(event.nameValidationStatus)); dispatch({ type: 'set-error', errorResult: getPetPackageNameError(event.nameValidationStatus) });
}); });
return { isVisible, errorResult, petName, objectType, onChangePetName, onConfirm, onClose }; return {
isVisible: state.isVisible,
errorResult: state.errorResult,
petName: state.petName,
objectType: state.objectType,
onChangePetName,
onConfirm,
onClose
};
}; };
export const usePetPackageWidget = usePetPackageWidgetState; export const usePetPackageWidget = usePetPackageWidgetState;