nitro-v36-with-renderer-v36
/
src /
components /
furni-editor /
views /
FurniEditorEditView.test.tsx
FurniEditorEditView.test.tsx 22 Ko
Télécharger le fichier
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CatalogRef, FurniDetail, FurniItem } from '../../../hooks/furni-editor';
import { FurniEditorEditView } from './FurniEditorEditView';
vi.mock('../../../api', () => ({
CopyToClipboard: () => Promise.resolve(true)
}));
vi.mock('../../../common', () => ({
Button: ({ children, onClick, disabled }: any) => (
<button type="button" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Column: ({ children }: any) => <div>{children}</div>,
Flex: ({ children }: any) => <div>{children}</div>,
Text: ({ children }: any) => <span>{children}</span>,
LayoutFurniIconImageView: () => null,
LayoutFurniImageView: () => null
}));
const item: FurniDetail = {
id: 42,
spriteId: 4200,
itemName: 'throne',
publicName: 'Throne',
type: 's',
width: 1,
length: 1,
stackHeight: 1.5,
allowStack: false,
allowWalk: false,
allowSit: true,
allowLay: false,
interactionType: 'default',
interactionModesCount: 1,
allowGift: true,
allowTrade: true,
allowRecycle: true,
allowMarketplaceSell: true,
allowInventoryStack: true,
vendingIds: '',
customparams: '',
effectIdMale: 0,
effectIdFemale: 0,
clothingOnWalk: '',
multiheight: '',
description: 'Royal seat',
usageCount: 3
};
const catalogItems: CatalogRef[] = [
{ id: 7, catalogName: 'throne', costCredits: 25, costPoints: 0, pointsType: 0, pageId: 3, pageName: 'Rares' },
{ id: 8, catalogName: 'throne_bundle', costCredits: 0, costPoints: 10, pointsType: 5, pageId: 9, pageName: 'Diamonds' }
];
const renderView = (overrides: Partial<React.ComponentProps<typeof FurniEditorEditView>> = {}) => {
const props = {
item,
catalogItems,
furniDataEntry: null,
furniDataDiagnostic: null,
interactions: ['default', 'gate'],
relatedItems: [] as FurniItem[],
loading: false,
onUpdate: vi.fn(),
onDelete: vi.fn(),
onBack: vi.fn(),
onUpdateFurnidata: vi.fn(),
onRevertFurnidata: vi.fn(),
onUpdateFurnidataStructure: vi.fn(),
onSyncPublicName: vi.fn(),
onImportText: vi.fn(),
importResult: null,
...overrides
};
render(<FurniEditorEditView {...props} />);
return props;
};
afterEach(() => cleanup());
describe('FurniEditorEditView', () => {
it('renders the items_base fields that the server accepts but the form used to hide', () => {
renderView({ item: { ...item, effectIdMale: 12, effectIdFemale: 13, clothingOnWalk: 'ch-210', vendingIds: '1,2', multiheight: '0.5,1.0' } });
expect(screen.getByLabelText('Effect ID (male)')).toHaveValue('12');
expect(screen.getByLabelText('Effect ID (female)')).toHaveValue('13');
expect(screen.getByLabelText('Clothing on walk')).toHaveValue('ch-210');
expect(screen.getByLabelText('Vending IDs')).toHaveValue('1,2');
expect(screen.getByLabelText('Multiheight')).toHaveValue('0.5,1.0');
});
it('confirms a save through a diff of the changed fields only, without the immutable identity columns', () => {
const onUpdate = vi.fn<(id: number, fields: Record<string, unknown>) => void>();
renderView({ onUpdate });
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
fireEvent.change(screen.getByLabelText('Effect ID (male)'), { target: { value: '5' } });
fireEvent.change(screen.getByLabelText('Multiheight'), { target: { value: '0.5,1.0' } });
fireEvent.click(screen.getByRole('button', { name: 'Save (2)' }));
const dialog = screen.getByRole('dialog', { name: 'Confirm changes' });
expect(within(dialog).getByText('Effect ID (male)')).toBeInTheDocument();
expect(within(dialog).getByText('Multiheight')).toBeInTheDocument();
expect(within(dialog).queryByText('Width')).toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }));
expect(onUpdate).toHaveBeenCalledTimes(1);
const [id, fields] = onUpdate.mock.calls[0];
expect(id).toBe(42);
expect(fields).toMatchObject({ effectIdMale: 5, multiheight: '0.5,1.0', width: 1 });
expect(fields).not.toHaveProperty('itemName');
expect(fields).not.toHaveProperty('spriteId');
expect(fields).not.toHaveProperty('type');
});
it('rejects values outside the server bounds before offering the save', () => {
renderView();
fireEvent.change(screen.getByLabelText('Multiheight'), { target: { value: 'x'.repeat(51) } });
expect(screen.getByText('Max 50 chars')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save (1)' })).toBeDisabled();
});
it('lists the catalogue offers that sell this furni', () => {
renderView();
const section = screen.getByTestId('furni-editor-catalog');
expect(within(section).getByText('Rares')).toBeInTheDocument();
expect(within(section).getByText('25 credits')).toBeInTheDocument();
expect(within(section).getByText('Diamonds')).toBeInTheDocument();
expect(within(section).getByText('10 points (type 5)')).toBeInTheDocument();
});
it('says so when the furni is not sold anywhere', () => {
renderView({ catalogItems: [] });
expect(within(screen.getByTestId('furni-editor-catalog')).getByText('Not in the catalogue')).toBeInTheDocument();
});
it('guards a dirty form behind an in-app dialog instead of window.confirm', () => {
const confirmSpy = vi.spyOn(window, 'confirm');
const { onBack } = renderView();
fireEvent.change(screen.getByLabelText('Effect ID (male)'), { target: { value: '5' } });
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(confirmSpy).not.toHaveBeenCalled();
expect(onBack).not.toHaveBeenCalled();
const dialog = screen.getByRole('dialog', { name: 'Unsaved changes' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Discard' }));
expect(onBack).toHaveBeenCalledTimes(1);
confirmSpy.mockRestore();
});
it('flags a stored interaction type that no server class is registered for, and keeps it selectable', () => {
renderView({ item: { ...item, interactionType: 'wf_trg_typo' } });
expect(screen.getByText('No class registered for this type: the furni behaves as default')).toBeInTheDocument();
expect(screen.getByLabelText('Interaction type')).toHaveValue('wf_trg_typo');
});
it('does not flag a registered interaction type, whatever its case', () => {
renderView({ item: { ...item, interactionType: 'Gate' } });
expect(screen.queryByText('No class registered for this type: the furni behaves as default')).toBeNull();
});
it('shows one field group at a time and marks the groups holding unsaved changes', () => {
renderView();
const names = screen.getByRole('tab', { name: 'Names' });
const behaviour = screen.getByRole('tab', { name: 'Behaviour' });
expect(names).toHaveAttribute('aria-selected', 'true');
expect(behaviour).toHaveAttribute('aria-selected', 'false');
fireEvent.change(screen.getByLabelText('Effect ID (male)'), { target: { value: '5' } });
expect(within(behaviour).getByLabelText('1 unsaved')).toBeInTheDocument();
expect(within(names).queryByLabelText(/unsaved/)).toBeNull();
fireEvent.click(behaviour);
expect(behaviour).toHaveAttribute('aria-selected', 'true');
});
it('lists every unsaved change in the sidebar and jumps to the catalogue from its status row', () => {
renderView();
fireEvent.change(screen.getByLabelText('Effect ID (male)'), { target: { value: '5' } });
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '2 offers ›' }));
expect(screen.getByRole('tab', { name: 'Catalogue' })).toHaveAttribute('aria-selected', 'true');
});
it('offers a per-field revert that puts the stored value back', () => {
renderView();
fireEvent.change(screen.getByLabelText('Effect ID (male)'), { target: { value: '5' } });
fireEvent.change(screen.getByLabelText('Multiheight'), { target: { value: '1' } });
expect(screen.getByRole('button', { name: 'Save (2)' })).toBeEnabled();
// A real click moves focus off the number field first, which lets Base UI re-sync its text.
fireEvent.blur(screen.getByLabelText('Effect ID (male)'));
fireEvent.click(screen.getByRole('button', { name: 'Revert Effect ID (male)' }));
expect(screen.getByLabelText('Effect ID (male)')).toHaveValue('0');
expect(screen.getByLabelText('Multiheight')).toHaveValue('1');
expect(screen.getByRole('button', { name: 'Save (1)' })).toBeEnabled();
});
it('jumps to a field by name, switching to its group', () => {
vi.useFakeTimers();
renderView();
fireEvent.change(screen.getByLabelText('Jump to field'), { target: { value: 'multi' } });
vi.runAllTimers();
expect(screen.getByRole('tab', { name: 'Behaviour' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByLabelText('Multiheight')).toHaveFocus();
expect(screen.getByLabelText('Jump to field')).toHaveValue('');
vi.useRealTimers();
});
it('jumps to a field picked from the suggestion list', () => {
vi.useFakeTimers();
renderView();
fireEvent.change(screen.getByLabelText('Jump to field'), { target: { value: 'eight' } });
fireEvent.click(screen.getByRole('option', { name: 'Multiheight' }));
vi.runAllTimers();
expect(screen.getByLabelText('Multiheight')).toHaveFocus();
expect(screen.getByLabelText('Jump to field')).toHaveValue('');
vi.useRealTimers();
});
it('flips an allow flag from its toggle button', () => {
renderView();
// allowWalk is stored false, allowSit true.
const walk = screen.getByRole('button', { name: 'Walk' });
const sit = screen.getByRole('button', { name: 'Sit' });
expect(walk).toHaveAttribute('aria-pressed', 'false');
expect(sit).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(walk);
expect(walk).toHaveAttribute('aria-pressed', 'true');
expect(sit).toHaveAttribute('aria-pressed', 'true');
});
it('previews the furni with rotation, a state stepper and the stored footprint', () => {
renderView({ item: { ...item, width: 2, length: 3, interactionModesCount: 3 } });
const preview = screen.getByTestId('furni-editor-preview');
expect(within(preview).getByLabelText('Footprint 2 by 3').children).toHaveLength(6);
const rotate = within(preview).getByRole('button', { name: 'Rotate, facing 2' });
fireEvent.click(rotate);
expect(within(preview).getByRole('button', { name: 'Rotate, facing 4' })).toBeInTheDocument();
fireEvent.click(within(preview).getByRole('button', { name: 'Next state, showing base of 3' }));
expect(within(preview).getByRole('button', { name: 'Next state, showing 1 of 3' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Width'), { target: { value: '4' } });
expect(within(preview).getByLabelText('Footprint 4 by 3').children).toHaveLength(12);
});
it('suggests the interaction type the classname points at, and applies it in one click', () => {
renderView({
item: { ...item, itemName: 'wf_trg_enter_room', interactionType: 'default' },
interactions: ['default', 'gate', 'wf_trg_enter_room', 'teleport']
});
const suggestion = screen.getByRole('button', { name: /Suggested: wf_trg_enter_room/ });
fireEvent.click(suggestion);
expect(screen.getByLabelText('Interaction type')).toHaveValue('wf_trg_enter_room');
expect(screen.queryByRole('button', { name: /Suggested:/ })).toBeNull();
});
it('filters the registered types while typing and takes the first match on Enter', () => {
renderView({ interactions: ['default', 'gate', 'guild_gate', 'teleport'] });
const picker = screen.getByLabelText('Interaction type');
fireEvent.focus(picker);
fireEvent.change(picker, { target: { value: 'gat' } });
const list = screen.getByRole('listbox');
expect(
within(list)
.getAllByRole('option')
.map((o) => o.textContent)
).toEqual(['gate', 'guild_gate']);
fireEvent.keyDown(picker, { key: 'Enter' });
expect(picker).toHaveValue('gate');
expect(screen.getByRole('button', { name: 'Save (1)' })).toBeEnabled();
});
it('picks a registered type with a click and closes the list', () => {
renderView({ interactions: ['default', 'gate', 'teleport'] });
const picker = screen.getByLabelText('Interaction type');
fireEvent.focus(picker);
fireEvent.change(picker, { target: { value: 'tele' } });
const option = within(screen.getByRole('listbox')).getByRole('option', { name: 'teleport' });
fireEvent.pointerDown(option);
fireEvent.click(option);
expect(picker).toHaveValue('teleport');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('closes the list on Escape without keeping the typed text', () => {
renderView({ item: { ...item, interactionType: 'gate' }, interactions: ['default', 'gate'] });
const picker = screen.getByLabelText('Interaction type');
fireEvent.focus(picker);
fireEvent.change(picker, { target: { value: 'xyz' } });
fireEvent.keyDown(picker, { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
expect(picker).toHaveValue('gate');
});
it('offers what furnidata says about footprint and flags, one click each or all at once', () => {
renderView({
furniDataEntry: {
classname: 'throne',
xdim: 2,
ydim: 1,
canstandon: true,
cansiton: true,
canlayon: false,
tradeable: true,
recyclable: false,
description: 'Royal seat'
}
});
expect(screen.getByRole('button', { name: '3 suggestions ›' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Apply Width 2' }));
expect(screen.getByRole('spinbutton', { name: 'Width' })).toHaveValue('2');
expect(screen.queryByRole('button', { name: 'Apply Width 2' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'apply all' }));
expect(screen.getByRole('button', { name: 'Walk', pressed: true })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Recycle', pressed: false })).toBeInTheDocument();
expect(screen.queryByText(/suggestion/)).toBeNull();
expect(screen.getByRole('button', { name: 'Save (3)' })).toBeEnabled();
});
it('ignores a furnidata entry that belongs to another classname', () => {
renderView({ furniDataEntry: { classname: 'other_chair', xdim: 4, ydim: 4 } });
expect(screen.queryByRole('button', { name: /suggestion/ })).toBeNull();
});
it('proposes the state count a type drives and warns when a list-driven type has an empty list', () => {
renderView({ item: { ...item, interactionType: 'gate' }, interactions: ['default', 'gate', 'vendingmachine'] });
fireEvent.click(screen.getByRole('button', { name: 'Apply Modes 2' }));
expect(screen.getByLabelText('Modes')).toHaveValue('2');
const picker = screen.getByLabelText('Interaction type');
fireEvent.focus(picker);
fireEvent.change(picker, { target: { value: 'vending' } });
fireEvent.keyDown(picker, { key: 'Enter' });
expect(screen.getByRole('note')).toHaveTextContent('vendingmachine hands out nothing without vending ids');
expect(screen.getByRole('button', { name: '1 warning ›' })).toBeInTheDocument();
});
it('flags a furnidata entry whose id is not the sprite id', () => {
renderView({ furniDataEntry: { classname: 'throne', id: 4201 } });
expect(screen.getByText('Furnidata id 4201 ≠ sprite')).toBeInTheDocument();
});
it('warns in the diff when a placement field changes on a furni standing in rooms', () => {
renderView();
fireEvent.change(screen.getByRole('spinbutton', { name: 'Width' }), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: 'Save (1)' }));
const dialog = screen.getByRole('dialog', { name: 'Confirm changes' });
expect(within(dialog).getByRole('note')).toHaveTextContent('3 placed furni will take the new footprint');
});
it('offers to undo the last save once the stored values have moved', () => {
const onUpdate = vi.fn();
const { rerender } = render(
<FurniEditorEditView
item={item}
catalogItems={[]}
furniDataEntry={null}
furniDataDiagnostic={null}
interactions={['default']}
relatedItems={[]}
loading={false}
onUpdate={onUpdate}
onDelete={vi.fn()}
onBack={vi.fn()}
onUpdateFurnidata={vi.fn()}
onRevertFurnidata={vi.fn()}
onUpdateFurnidataStructure={vi.fn()}
onSyncPublicName={vi.fn()}
onImportText={vi.fn()}
importResult={null}
/>
);
fireEvent.change(screen.getByLabelText('Effect ID (male)'), { target: { value: '5' } });
fireEvent.click(screen.getByRole('button', { name: 'Save (1)' }));
fireEvent.click(within(screen.getByRole('dialog', { name: 'Confirm changes' })).getByRole('button', { name: 'Confirm' }));
expect(screen.queryByRole('button', { name: 'Undo last save' })).toBeNull();
rerender(
<FurniEditorEditView
item={{ ...item, effectIdMale: 5 }}
catalogItems={[]}
furniDataEntry={null}
furniDataDiagnostic={null}
interactions={['default']}
relatedItems={[]}
loading={false}
onUpdate={onUpdate}
onDelete={vi.fn()}
onBack={vi.fn()}
onUpdateFurnidata={vi.fn()}
onRevertFurnidata={vi.fn()}
onUpdateFurnidataStructure={vi.fn()}
onSyncPublicName={vi.fn()}
onImportText={vi.fn()}
importResult={null}
/>
);
// A real click moves focus off the number field first, which lets Base UI re-sync its text.
fireEvent.blur(screen.getByLabelText('Effect ID (male)'));
fireEvent.click(screen.getByRole('button', { name: 'Undo last save' }));
expect(screen.getByLabelText('Effect ID (male)')).toHaveValue('0');
expect(screen.getByRole('button', { name: 'Save (1)' })).toBeEnabled();
});
it('lists duplicates and line siblings from the probe, and offers the line majority under the field', async () => {
const row = (id: number, itemName: string, spriteId: number, width = 1): FurniItem => ({
id,
spriteId,
itemName,
publicName: itemName,
type: 's',
width,
length: 1,
stackHeight: 1.5,
allowStack: false,
allowWalk: false,
allowSit: true,
allowLay: false,
interactionType: 'default',
interactionModesCount: 1
});
renderView({
item: { ...item, itemName: 'throne_gold' },
relatedItems: [
row(42, 'throne_gold', 4200),
row(50, 'throne_gold', 4300),
row(51, 'throne', 4301, 2),
row(52, 'throne_silver', 4302, 2),
row(53, 'lamp', 4200)
]
});
expect(screen.getByText('2 duplicates')).toBeInTheDocument();
expect(within(screen.getByTestId('furni-editor-duplicates')).getAllByRole('button')).toHaveLength(2);
expect(within(screen.getByTestId('furni-editor-siblings')).getAllByRole('button')).toHaveLength(2);
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Apply Width 2' }));
expect(await screen.findByRole('tooltip')).toHaveTextContent('2 of 2 in the line');
});
it('writes the DB structural values into the furnidata entry after confirmation', () => {
const onUpdateFurnidataStructure = vi.fn();
renderView({
onUpdateFurnidataStructure,
furniDataEntry: { classname: 'throne', xdim: 2, ydim: 1, height: 1.5, canstandon: false, cansiton: false, canlayon: false }
});
expect(within(screen.getByTestId('furni-editor-structure')).getAllByText(/xdim|cansiton/)).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: 'Write DB values into furnidata' }));
fireEvent.click(within(screen.getByRole('dialog', { name: 'Rewrite furnidata structure?' })).getByRole('button', { name: 'Write' }));
expect(onUpdateFurnidataStructure).toHaveBeenCalledWith(42, { xdim: 1, cansiton: true });
});
it('resets every field to the stored values with one click', () => {
renderView();
fireEvent.change(screen.getByLabelText('Effect ID (male)'), { target: { value: '5' } });
// A real click moves focus off the number field first, which lets Base UI re-sync its text.
fireEvent.blur(screen.getByLabelText('Effect ID (male)'));
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }));
expect(screen.getByLabelText('Effect ID (male)')).toHaveValue('0');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
});
