import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ServerProvider } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { I18nProvider } from '../lib/i18n';
import type { MenuCatalog, MenuDefinition, TriggerCatalog } from '../types';
import { ImagePicker, MenusPage } from './menus';
const timestamp = '2026-09-04T00:00:00Z';
const menuCatalog: MenuCatalog = {
controlTypes: ['text', 'rich_text', 'button', 'icon_button', 'image', 'panel', 'border', 'progress',
'input', 'checkbox', 'slider', 'item_slot', 'separator', 'spacer'],
popupPositions: ['center', 'top', 'bottom', 'left', 'right', 'top_left', 'top_right',
'bottom_left', 'bottom_right', 'custom'],
controlEvents: ['click', 'double_click', 'change', 'submit', 'toggle', 'hover', 'focus', 'blur'],
triggers: [{ id: '00000000-0000-0000-0000-000000000001', name: 'Menu click', enabled: true }],
};
const triggerCatalog: TriggerCatalog = {
defaultMessageSender: 'XFEServerManager', events: ['menu.control'], operators: ['eq'], actions: ['log'],
actionParameters: { log: ['message', 'level'] }, variables: [{ key: 'player.name', nameZh: '玩家名称',
nameEn: 'Player name', descriptionZh: '事件玩家', descriptionEn: 'Event player', category: 'player',
scopes: ['menu.*'], templateAllowed: true, conditionAllowed: true }],
};
function savedMenu(controls: MenuDefinition['controls']): MenuDefinition {
return {
id: '00000000-0000-0000-0000-000000000100', name: '新菜单', description: '', width: 360, height: 240,
popupPosition: 'center', offsetX: 0, offsetY: 0, pauseGame: false, backgroundColor: '#101713e8',
controls, revision: 0, createdBy: 'owner', createdAt: timestamp, updatedAt: timestamp,
};
}
function client(writable = true): ApiClient {
const api = new ApiClient();
vi.spyOn(api, 'session').mockResolvedValue({ authenticated: true, csrfToken: 'csrf', actor: {
id: 'owner', displayName: 'Owner', roles: writable ? ['owner'] : ['observer'],
permissions: writable ? ['menu_read', 'menu_write'] : ['menu_read'], totpVerified: true,
} });
vi.spyOn(api, 'status').mockResolvedValue({ sampledAt: timestamp, uptimeSeconds: 1, playersOnline: 0 });
vi.spyOn(api, 'features').mockResolvedValue({ consoleEnabled: false, moderationEnabled: false,
claimsEnabled: false, worldTrackingEnabled: false, rollbackEnabled: false, joinExperienceEnabled: true });
vi.spyOn(api, 'subscribe').mockReturnValue(() => undefined);
vi.spyOn(api, 'menuWorkspace').mockResolvedValue({ menus: [], totalMenus: 0 });
vi.spyOn(api, 'menuCatalog').mockResolvedValue(menuCatalog);
vi.spyOn(api, 'triggerCatalog').mockResolvedValue(triggerCatalog);
vi.spyOn(api, 'createMenu').mockImplementation(async (draft) => {
const menu = savedMenu(draft.controls);
return { menu, workspace: { menus: [menu], totalMenus: 1 } };
});
return api;
}
function renderMenus(api: ApiClient) {
return render(<I18nProvider><ServerProvider client={api}><MenusPage /></ServerProvider></I18nProvider>);
}
describe('menu builder', () => {
afterEach(() => vi.restoreAllMocks());
it('creates a positioned button, binds an event and saves with Ctrl+S', async () => {
const api = client();
const user = userEvent.setup();
renderMenus(api);
expect(await screen.findByText('UI 菜单')).toBeInTheDocument();
await user.click(await screen.findByTitle('按钮 / Button'));
expect(screen.getAllByText('button_1').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: '添加' }));
expect(screen.getByLabelText('事件')).toHaveValue('click');
fireEvent.keyDown(document, { key: 's', ctrlKey: true });
await waitFor(() => expect(api.createMenu).toHaveBeenCalledTimes(1));
const request = vi.mocked(api.createMenu).mock.calls[0][0];
expect(request.controls[0]).toMatchObject({ id: 'button_1', type: 'button', x: 16, y: 16 });
expect(request.controls[0].events[0]).toMatchObject({ event: 'click', closeMenu: true });
});
it('keeps all mutation entry points disabled for a menu reader', async () => {
renderMenus(client(false));
expect(await screen.findByText('只读')).toBeInTheDocument();
expect(await screen.findByTitle('按钮 / Button')).toBeDisabled();
expect(screen.getByRole('button', { name: /保存 Ctrl\+S/ })).toBeDisabled();
});
it('does not delete the selected control when Backspace edits rich text', async () => {
const api = client();
const user = userEvent.setup();
renderMenus(api);
await screen.findByText('UI 菜单');
await user.click(await screen.findByTitle('富文本 / Rich text'));
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
fireEvent.keyDown(editor, { key: 'Backspace' });
fireEvent.keyDown(document, { key: 's', ctrlKey: true });
await waitFor(() => expect(api.createMenu).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.createMenu).mock.calls[0][0].controls)
.toEqual([expect.objectContaining({ id: 'rich_text_1', type: 'rich_text' })]);
});
it('loads a real preview for vanilla and mod resource references', async () => {
const api = client();
vi.spyOn(api, 'menuImages').mockResolvedValue([{ reference: 'item:minecraft:apple',
name: 'minecraft:apple', type: 'item', source: '原版/模组物品' }]);
vi.spyOn(api, 'menuImagePreview').mockResolvedValue({ reference: 'item:minecraft:apple',
mimeType: 'image/png', dataUrl: 'data:image/png;base64,cHJldmlldw==' });
render(<ImagePicker api={api} value="" onSelect={vi.fn()} onClose={vi.fn()} />);
expect(await screen.findByText('minecraft:apple')).toBeInTheDocument();
await waitFor(() => expect(api.menuImagePreview).toHaveBeenCalledWith('item:minecraft:apple'));
await waitFor(() => expect(document.querySelector('.menu-image-picker__preview img'))
.toHaveAttribute('src', 'data:image/png;base64,cHJldmlldw=='));
});
});
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ServerProvider } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { I18nProvider } from '../lib/i18n';
import type { MenuCatalog, MenuDefinition, TriggerCatalog } from '../types';
import { ImagePicker, MenusPage } from './menus';
const timestamp = '2026-09-04T00:00:00Z';
const menuCatalog: MenuCatalog = {
controlTypes: ['text', 'rich_text', 'button', 'icon_button', 'image', 'panel', 'border', 'progress',
'input', 'checkbox', 'slider', 'item_slot', 'separator', 'spacer'],
popupPositions: ['center', 'top', 'bottom', 'left', 'right', 'top_left', 'top_right',
'bottom_left', 'bottom_right', 'custom'],
controlEvents: ['click', 'double_click', 'change', 'submit', 'toggle', 'hover', 'focus', 'blur'],
triggers: [{ id: '00000000-0000-0000-0000-000000000001', name: 'Menu click', enabled: true }],
};
const triggerCatalog: TriggerCatalog = {
defaultMessageSender: 'XFEServerManager', events: ['menu.control'], operators: ['eq'], actions: ['log'],
actionParameters: { log: ['message', 'level'] }, variables: [{ key: 'player.name', nameZh: '玩家名称',
nameEn: 'Player name', descriptionZh: '事件玩家', descriptionEn: 'Event player', category: 'player',
scopes: ['menu.*'], templateAllowed: true, conditionAllowed: true }],
};
function savedMenu(controls: MenuDefinition['controls']): MenuDefinition {
return {
id: '00000000-0000-0000-0000-000000000100', name: '新菜单', description: '', width: 360, height: 240,
popupPosition: 'center', offsetX: 0, offsetY: 0, pauseGame: false, backgroundColor: '#101713e8',
controls, revision: 0, createdBy: 'owner', createdAt: timestamp, updatedAt: timestamp,
};
}
function client(writable = true): ApiClient {
const api = new ApiClient();
vi.spyOn(api, 'session').mockResolvedValue({ authenticated: true, csrfToken: 'csrf', actor: {
id: 'owner', displayName: 'Owner', roles: writable ? ['owner'] : ['observer'],
permissions: writable ? ['menu_read', 'menu_write'] : ['menu_read'], totpVerified: true,
} });
vi.spyOn(api, 'status').mockResolvedValue({ sampledAt: timestamp, uptimeSeconds: 1, playersOnline: 0 });
vi.spyOn(api, 'features').mockResolvedValue({ consoleEnabled: false, moderationEnabled: false,
claimsEnabled: false, worldTrackingEnabled: false, rollbackEnabled: false, joinExperienceEnabled: true });
vi.spyOn(api, 'subscribe').mockReturnValue(() => undefined);
vi.spyOn(api, 'menuWorkspace').mockResolvedValue({ menus: [], totalMenus: 0 });
vi.spyOn(api, 'menuCatalog').mockResolvedValue(menuCatalog);
vi.spyOn(api, 'triggerCatalog').mockResolvedValue(triggerCatalog);
vi.spyOn(api, 'createMenu').mockImplementation(async (draft) => {
const menu = savedMenu(draft.controls);
return { menu, workspace: { menus: [menu], totalMenus: 1 } };
});
return api;
}
function renderMenus(api: ApiClient) {
return render(<I18nProvider><ServerProvider client={api}><MenusPage /></ServerProvider></I18nProvider>);
}
describe('menu builder', () => {
afterEach(() => vi.restoreAllMocks());
it('creates a positioned button, binds an event and saves with Ctrl+S', async () => {
const api = client();
const user = userEvent.setup();
renderMenus(api);
expect(await screen.findByText('UI 菜单')).toBeInTheDocument();
await user.click(await screen.findByTitle('按钮 / Button'));
expect(screen.getAllByText('button_1').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: '添加' }));
expect(screen.getByLabelText('事件')).toHaveValue('click');
fireEvent.keyDown(document, { key: 's', ctrlKey: true });
await waitFor(() => expect(api.createMenu).toHaveBeenCalledTimes(1));
const request = vi.mocked(api.createMenu).mock.calls[0][0];
expect(request.controls[0]).toMatchObject({ id: 'button_1', type: 'button', x: 16, y: 16 });
expect(request.controls[0].events[0]).toMatchObject({ event: 'click', closeMenu: true });
});
it('keeps all mutation entry points disabled for a menu reader', async () => {
renderMenus(client(false));
expect(await screen.findByText('只读')).toBeInTheDocument();
expect(await screen.findByTitle('按钮 / Button')).toBeDisabled();
expect(screen.getByRole('button', { name: /保存 Ctrl\+S/ })).toBeDisabled();
});
it('does not delete the selected control when Backspace edits rich text', async () => {
const api = client();
const user = userEvent.setup();
renderMenus(api);
await screen.findByText('UI 菜单');
await user.click(await screen.findByTitle('富文本 / Rich text'));
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
fireEvent.keyDown(editor, { key: 'Backspace' });
fireEvent.keyDown(document, { key: 's', ctrlKey: true });
await waitFor(() => expect(api.createMenu).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.createMenu).mock.calls[0][0].controls)
.toEqual([expect.objectContaining({ id: 'rich_text_1', type: 'rich_text' })]);
});
it('loads a real preview for vanilla and mod resource references', async () => {
const api = client();
vi.spyOn(api, 'menuImages').mockResolvedValue([{ reference: 'item:minecraft:apple',
name: 'minecraft:apple', type: 'item', source: '原版/模组物品' }]);
vi.spyOn(api, 'menuImagePreview').mockResolvedValue({ reference: 'item:minecraft:apple',
mimeType: 'image/png', dataUrl: 'data:image/png;base64,cHJldmlldw==' });
render(<ImagePicker api={api} value="" onSelect={vi.fn()} onClose={vi.fn()} />);
expect(await screen.findByText('minecraft:apple')).toBeInTheDocument();
await waitFor(() => expect(api.menuImagePreview).toHaveBeenCalledWith('item:minecraft:apple'));
await waitFor(() => expect(document.querySelector('.menu-image-picker__preview img'))
.toHaveAttribute('src', 'data:image/png;base64,cHJldmlldw=='));
});
});