FloorplanEditorView.test.tsx 12.1 Ko
Télécharger le fichier
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Capture handlers registered by useMessageEvent / useOctaneEvent so we can fire fake events.
const messageHandlers = new Map<unknown, (event: unknown) => void>();
const octaneHandlers = new Map<unknown, (event: unknown) => void>();
vi.mock('../../hooks', async () => {
return {
useMessageEvent: (eventClass: unknown, handler: (event: unknown) => void) => {
messageHandlers.set(eventClass, handler);
},
useOctaneEvent: (eventType: unknown, handler: (event: unknown) => void) => {
octaneHandlers.set(eventType, handler);
}
};
});
// Spy SendMessageComposer — use importOriginal to keep all other api exports intact
// (DraggableWindow et al. rely on GetLocalStorage and others at mount time).
const sendMessageComposer = vi.fn();
vi.mock('../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api')>();
return {
...actual,
SendMessageComposer: (...args: unknown[]) => sendMessageComposer(...args),
LocalizeText: (key: string) => key
};
});
import {
AddLinkEventTracker,
FloorHeightMapEvent,
GetOccupiedTilesMessageComposer,
GetRoomEntryTileMessageComposer,
RemoveLinkEventTracker,
RoomEngineEvent,
RoomEntryTileMessageEvent,
RoomOccupiedTilesMessageEvent,
RoomVisualizationSettingsEvent,
UpdateFloorPropertiesMessageComposer
} from '@octane/renderer';
import { FloorplanEditorView } from './FloorplanEditorView';
// The Button component in this codebase renders as a <div> (via Base), not <button>.
// OctaneCardView portals everything into #draggable-windows-container.
// Find a clickable element by its exact trimmed text content in the portal.
const findByExactText = (text: string): Element | undefined => {
const container = document.getElementById('draggable-windows-container') ?? document.body;
return Array.from(container.querySelectorAll('button, div')).find((el: Element) => el.textContent?.trim() === text);
};
describe('FloorplanEditorView container', () => {
beforeEach(() => {
messageHandlers.clear();
octaneHandlers.clear();
sendMessageComposer.mockClear();
(AddLinkEventTracker as ReturnType<typeof vi.fn>).mockClear();
(RemoveLinkEventTracker as ReturnType<typeof vi.fn>).mockClear();
});
afterEach(() => cleanup());
const openEditor = () => {
render(<FloorplanEditorView />);
// Trigger link tracker: 'floor-editor/show' to make editor visible
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
act(() => tracker.linkReceived('floor-editor/show'));
};
it('registers a link tracker on mount with floor-editor/ prefix', () => {
render(<FloorplanEditorView />);
expect(AddLinkEventTracker).toHaveBeenCalledTimes(1);
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(tracker.eventUrlPrefix).toBe('floor-editor/');
});
it('dispatches GetRoomEntryTileMessageComposer when editor becomes visible', () => {
openEditor();
const composers = sendMessageComposer.mock.calls.map((c: unknown[]) => c[0]);
const entryComposer = composers.find((c: unknown) => c instanceof GetRoomEntryTileMessageComposer);
expect(entryComposer).toBeTruthy();
});
it('dispatches GetOccupiedTilesMessageComposer when editor becomes visible', () => {
openEditor();
const composers = sendMessageComposer.mock.calls.map((c: unknown[]) => c[0]);
const occupiedComposer = composers.find((c: unknown) => c instanceof GetOccupiedTilesMessageComposer);
expect(occupiedComposer).toBeTruthy();
});
it('seeds door from RoomEntryTileMessageEvent', () => {
openEditor();
const handler = messageHandlers.get(RoomEntryTileMessageEvent);
expect(handler).toBeTruthy();
act(() => handler!({ getParser: () => ({ x: 3, y: 4, direction: 6 }) }));
// Seed tilemap + thickness so Save is callable
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
act(() => fhmHandler!({ getParser: () => ({ model: '00\rxq', wallHeight: 5 }) }));
const rvsHandler = messageHandlers.get(RoomVisualizationSettingsEvent);
act(() => rvsHandler!({ getParser: () => ({ thicknessWall: 1, thicknessFloor: 1 }) }));
// With LocalizeText mocked to identity, the button text is literally the i18n key.
// findByExactText matches by exact label, whatever element renders it.
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
expect(sendMessageComposer).toHaveBeenCalledTimes(1);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
expect(composer.doorX).toBe(3);
expect(composer.doorY).toBe(4);
expect(composer.dir).toBe(6);
});
it('Save composer carries wallHeight - 1 from the reducer state', () => {
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
// parser.wallHeight = 4 → state.wallHeight = 4 + 1 = 5 → Save sends 5 - 1 = 4
act(() => fhmHandler!({ getParser: () => ({ model: '0', wallHeight: 4 }) }));
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
expect(composer.wallHeight).toBe(4);
});
it('Save composer thickness goes through convertNumbersForSaving', () => {
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
act(() => fhmHandler!({ getParser: () => ({ model: '0', wallHeight: 0 }) }));
const rvsHandler = messageHandlers.get(RoomVisualizationSettingsEvent);
// server sends 2 for both; convertSettingToNumber(2) = 3; reducer stores thickness=3
// Save applies convertNumbersForSaving(3) = 1
act(() => rvsHandler!({ getParser: () => ({ thicknessWall: 2, thicknessFloor: 2 }) }));
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
expect(composer.thicknessWall).toBe(1);
expect(composer.thicknessFloor).toBe(1);
});
it('RoomOccupiedTilesMessageEvent marks tiles occupied without altering the saved tilemap', () => {
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
// 2x2 grid: '00\r00' → rows 0 and 1, each with 2 walkable tiles
act(() => fhmHandler!({ getParser: () => ({ model: '00\r00', wallHeight: 0 }) }));
const occHandler = messageHandlers.get(RoomOccupiedTilesMessageEvent);
expect(occHandler).toBeTruthy();
// Mark col 1 of row 0 as occupied; blockedTilesMap[row][col]
const blockedTilesMap = [
[false, true],
[false, false]
];
act(() => occHandler!({ getParser: () => ({ blockedTilesMap }) }));
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
// Occupied is purely informational: the tile stays walkable and the
// saved tilemap is unchanged (row 0 stays '00', NOT voided to '0x').
expect(composer.tilemap.split(/\r/)[0]).toBe('00');
});
it('the pick-up switch reaches the save', () => {
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
act(() => fhmHandler!({ getParser: () => ({ model: '00', wallHeight: 0 }) }));
const saveBtn = findByExactText('floor.plan.editor.save');
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
expect(sendMessageComposer.mock.calls[0][0].autoPickup).toBe(false);
const toggle = document.querySelector('[data-testid="floorplan-auto-pickup"]');
expect(toggle?.getAttribute('data-active')).toBe('false');
fireEvent.click(toggle!);
expect(toggle?.getAttribute('data-active')).toBe('true');
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
expect(sendMessageComposer.mock.calls[0][0].autoPickup).toBe(true);
});
it('RoomEngineEvent.DISPOSED hides the editor', () => {
render(<FloorplanEditorView />);
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
act(() => tracker.linkReceived('floor-editor/show'));
// Editor should be visible — OctaneCardHeaderView renders the title
expect(document.body.textContent).toContain('floor.plan.editor.title');
const disposeHandler = octaneHandlers.get(RoomEngineEvent.DISPOSED);
expect(disposeHandler).toBeTruthy();
act(() => disposeHandler!({}));
expect(document.body.textContent).not.toContain('floor.plan.editor.title');
});
it('a closed inventory or catalog preview does not hide the editor', () => {
render(<FloorplanEditorView />);
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
act(() => tracker.linkReceived('floor-editor/show'));
const disposeHandler = octaneHandlers.get(RoomEngineEvent.DISPOSED);
act(() => disposeHandler!({ roomId: 0x7fff0001 }));
expect(document.body.textContent).toContain('floor.plan.editor.title');
});
it('cleans up the link tracker on unmount', () => {
const { unmount } = render(<FloorplanEditorView />);
expect(RemoveLinkEventTracker).not.toHaveBeenCalled();
unmount();
expect(RemoveLinkEventTracker).toHaveBeenCalledTimes(1);
});
it('reuses the editor for an external arena floor plan without room messages', () => {
const onClose = vi.fn();
const onSave = vi.fn();
const { queryByTestId } = render(
<FloorplanEditorView
externalSession={{
tilemap: '00\r0x',
occupiedTiles: [[false, true], [false, false]],
title: 'SnowStorm Floor Plan Editor',
onClose,
onSave
}}
/>
);
expect(document.body.textContent).toContain('SnowStorm Floor Plan Editor');
expect(queryByTestId('tool-door')).toBeNull();
expect(AddLinkEventTracker).not.toHaveBeenCalled();
expect(sendMessageComposer).not.toHaveBeenCalled();
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
fireEvent.click(saveBtn!);
expect(onSave).toHaveBeenCalledWith('00\r0x');
expect(onClose).toHaveBeenCalledTimes(1);
expect(sendMessageComposer).not.toHaveBeenCalled();
});
it('shows only the flat preview, with no 3D switch (WebGL is too heavy for some devices)', () => {
window.localStorage.setItem('octane.floorplan.preview3d', 'true');
try {
openEditor();
const container = document.getElementById('draggable-windows-container') ?? document.body;
expect(container.querySelector('[data-testid="floorplan-preview-2d"]')).toBeTruthy();
expect(container.querySelector('[data-testid="floorplan-3d"]')).toBeNull();
expect(container.querySelector('[data-testid="floorplan-view-switch"]')).toBeNull();
} finally {
window.localStorage.removeItem('octane.floorplan.preview3d');
}
});
});
