import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { App } from '../app';
import { variableAppliesToEvent } from '../components/rich-text-variables';
import { ServerProvider } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { I18nProvider } from '../lib/i18n';
import { MaintenancePage } from './maintenance';
import { MessagesPage } from './messages';
import type {
MaintenanceState, ServerSettings, TriggerCatalog, TriggerDefinition,
TriggerVariableDefinition, TriggerWorkspace,
} from '../types';
const migrated: TriggerDefinition = {
id: 'trigger-daily', groupId: 'group-migrated', name: '每日公告', description: '', enabled: true,
mode: 'visual', event: { type: 'player.join', configuration: {} }, conditionMode: 'all',
conditions: [{ field: 'player.dailyDue', operator: 'eq', value: 'true' }],
actions: [{ type: 'send_player', parameters: { message: 'Daily {date}' } }], script: '', revision: 0,
createdBy: 'system', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', migrated: true,
};
const workspace: TriggerWorkspace = { groups: [{
id: 'group-migrated', name: '已迁移消息', description: '旧消息已自动迁移', enabled: true,
revision: 0, createdBy: 'system', createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z', migrated: true, triggers: [migrated],
}], totalTriggers: 1 };
const catalog: TriggerCatalog = {
defaultMessageSender: 'Server Default',
events: ['schedule.daily', 'player.join', 'player.leave', 'custom'],
operators: ['eq', 'contains', 'exists'],
actions: ['send_player', 'broadcast', 'server_command'],
actionParameters: { send_player: ['message'], broadcast: ['message'], server_command: ['command'] },
};
const messageSettings: ServerSettings = {
bindAddress: '127.0.0.1', port: 8765, publicBaseUrl: 'http://127.0.0.1:8765',
trustedProxyCidrs: [], idleSessionSeconds: 1800, absoluteSessionSeconds: 28800,
recentAuthenticationSeconds: 300, httpThreads: 8, maximumSseClients: 20,
webConsoleEnabled: false, rawItemEditingEnabled: false, metricsEnabled: false,
moderationEnabled: true, claimsEnabled: false, worldTrackingEnabled: false,
rollbackEnabled: false, joinExperienceEnabled: true, defaultMessageSender: 'Server Default',
joinWelcomeEnabled: true, joinWelcomeMessage: 'Welcome', firstJoinMessageEnabled: true,
firstJoinMessage: 'First welcome', dailyAnnouncementsEnabled: true,
dailyAnnouncements: ['Daily'], announcementTimeZone: 'Asia/Shanghai',
rulesReminderEnabled: true, rulesMessage: 'Rules', maintenanceJoinReminderEnabled: true,
crashProtection: { enabled: true, scanIntervalTicks: 100, itemEntitiesPerDimension: 2000,
itemEntitiesPerChunk: 256, livingEntitiesPerDimension: 2000, livingEntitiesPerChunk: 128,
totalEntitiesPerDimension: 5000, totalEntitiesPerChunk: 512,
entitiesPerNamespacePerDimension: 512, loadedChunksPerDimension: 8000,
spawnBurstLimit: 400, spawnBurstWindowTicks: 100, commandBlockCommandsPerSecond: 100,
slowTickMillis: 200, consecutiveSlowTicks: 3, heapUsagePercent: 90,
blockExcessSpawns: true, removeExcessItems: true, stopRunawayCommandBlocks: true },
features: {
consoleEnabled: false, moderationEnabled: true, claimsEnabled: false,
worldTrackingEnabled: false, rollbackEnabled: false, joinExperienceEnabled: true,
},
};
const maintenanceState: MaintenanceState = {
enabled: false, message: 'Maintenance', defaultMessageSender: 'Server Default',
allowedPlayers: [], revision: 0, schedules: [],
};
function catalogVariable(key: string, events: string[], scopes: string[] = ['event-specific']): TriggerVariableDefinition {
return {
key, nameZh: key, nameEn: key, descriptionZh: `${key} 说明`, descriptionEn: `${key} description`,
category: key.startsWith('player.') ? 'player' : 'server', scopes, events,
example: `{${key}}`, templateAllowed: true, conditionAllowed: true,
};
}
const scopedVariables = [
catalogVariable('server.tps', ['*'], ['global']),
catalogVariable('player.name', ['player.*'], ['player-event']),
catalogVariable('player.firstJoin', ['player.join']),
catalogVariable('chat.message', ['player.chat']),
];
function pageClient(variableCatalog: TriggerVariableDefinition[] | Error): ApiClient {
const value = new ApiClient();
vi.spyOn(value, 'session').mockResolvedValue({ authenticated: true, csrfToken: 'csrf', actor: {
id: 'owner', displayName: 'owner', roles: ['owner'], permissions: [], totpVerified: true,
} });
vi.spyOn(value, 'status').mockResolvedValue({
sampledAt: '2026-01-01T00:00:00Z', uptimeSeconds: 1, playersOnline: 0,
});
vi.spyOn(value, 'features').mockResolvedValue(messageSettings.features);
vi.spyOn(value, 'settings').mockResolvedValue(messageSettings);
vi.spyOn(value, 'maintenance').mockResolvedValue(maintenanceState);
vi.spyOn(value, 'subscribe').mockReturnValue(() => undefined);
const request = vi.spyOn(value, 'triggerCatalog');
if (variableCatalog instanceof Error) request.mockRejectedValue(variableCatalog);
else request.mockResolvedValue({ ...catalog, variables: variableCatalog });
return value;
}
function client(): ApiClient {
const value = new ApiClient();
vi.spyOn(value, 'session').mockResolvedValue({ authenticated: true, csrfToken: 'csrf', actor: {
id: 'owner', displayName: 'owner', roles: ['owner'], permissions: ['trigger_read', 'trigger_write'], totpVerified: true,
} });
vi.spyOn(value, 'status').mockResolvedValue({ sampledAt: '2026-01-01T00:00:00Z', uptimeSeconds: 1, playersOnline: 0 });
vi.spyOn(value, 'features').mockResolvedValue({ consoleEnabled: false, moderationEnabled: false,
claimsEnabled: false, worldTrackingEnabled: false, rollbackEnabled: false, joinExperienceEnabled: true });
vi.spyOn(value, 'subscribe').mockReturnValue(() => undefined);
vi.spyOn(value, 'triggerWorkspace').mockResolvedValue(workspace);
vi.spyOn(value, 'triggerCatalog').mockResolvedValue(catalog);
vi.spyOn(value, 'validateVisualTrigger').mockResolvedValue({
valid: true, event: migrated.event, conditionMode: migrated.conditionMode,
conditions: migrated.conditions, actions: migrated.actions,
});
vi.spyOn(value, 'validateTriggerScript').mockResolvedValue({
valid: true, event: migrated.event, conditionMode: migrated.conditionMode,
conditions: migrated.conditions, actions: migrated.actions,
});
return value;
}
describe('trigger page and migrated messages', () => {
afterEach(() => { location.hash = ''; });
it('edits a migrated daily message through the default visual builder', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/triggers';
const api = client();
const update = vi.spyOn(api, 'updateTrigger').mockResolvedValue({ trigger: migrated, workspace });
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={api}><App /></ServerProvider></I18nProvider>);
expect(await screen.findByRole('heading', { name: '触发器' })).toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: /每日公告/ }));
expect(await screen.findByText('条件判断')).toBeInTheDocument();
expect(screen.getByDisplayValue('player.dailyDue')).toBeInTheDocument();
expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Server Default]');
const inheritSender = await screen.findAllByLabelText('使用全局默认发送者 / Use global default sender');
await user.click(inheritSender[0]);
const senderFields = await screen.findAllByLabelText('发送者名称 / Sender name');
fireEvent.change(senderFields[0], { target: { value: '公告中心' } });
await user.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(update).toHaveBeenCalled());
expect(update.mock.calls[0][0].id).toBe('trigger-daily');
expect(update.mock.calls[0][0].actions[0].parameters.message).toContain('公告中心');
});
it('converts visual blocks into XFE Script and validates the script', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/triggers';
const api = client();
const compiled = {
valid: true as const,
event: { type: 'player.leave', configuration: {} },
conditionMode: 'any' as const,
conditions: [{ field: 'player.name', operator: 'contains', value: 'Alex' }],
actions: [{ type: 'broadcast', parameters: { message: 'Bye, {player.name}!' } }],
};
const validate = vi.spyOn(api, 'validateTriggerScript').mockResolvedValue(compiled);
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={api}><App /></ServerProvider></I18nProvider>);
await user.click(await screen.findByRole('button', { name: /每日公告/ }));
await user.click(screen.getByRole('button', { name: /XFE Script/ }));
const editor = screen.getByLabelText('XFE Script');
expect((editor as HTMLTextAreaElement).value).toContain('do send_player');
const script = 'on player.leave\nmatch any\nwhen player.name contains "Alex"\ndo broadcast message="Bye"';
await user.clear(editor);
await user.type(editor, script);
await user.click(screen.getByRole('button', { name: '模块化 UI' }));
await waitFor(() => expect(validate).toHaveBeenCalledWith(script));
expect(await screen.findByLabelText('事件类型')).toHaveValue('player.leave');
expect(screen.getByLabelText('条件匹配模式')).toHaveValue('any');
expect(screen.getByLabelText('操作 1 类型')).toHaveValue('broadcast');
expect(screen.getByLabelText('条件 1 字段')).toHaveValue('player.name');
});
it('keeps the custom-event editor mounted while entering a namespaced event', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/triggers';
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client()}><App /></ServerProvider></I18nProvider>);
await user.click(await screen.findByRole('button', { name: /每日公告/ }));
const eventType = screen.getByLabelText('事件类型');
await user.selectOptions(eventType, 'custom');
expect(eventType).toHaveValue('custom');
const input = await screen.findByLabelText('自定义事件 ID');
await user.type(input, 'example.boss_spawned');
expect(screen.getByLabelText('自定义事件 ID')).toHaveValue('example.boss_spawned');
});
});
describe('message variable event scopes', () => {
it('matches arbitrary safe glob positions and treats regular-expression syntax literally', () => {
expect(variableAppliesToEvent(catalogVariable('item', ['player.item_*']),
'PLAYER.ITEM_PICKUP')).toBe(true);
expect(variableAppliesToEvent(catalogVariable('item', ['player.item_*']),
'player.join')).toBe(false);
expect(variableAppliesToEvent(catalogVariable('literal', ['custom.mod+event']),
'custom.mod+event')).toBe(true);
expect(variableAppliesToEvent(catalogVariable('literal', ['custom.mod+event']),
'custom.moddddddevent')).toBe(false);
const authoritative = catalogVariable('join-only', ['player.join'], ['global']);
expect(variableAppliesToEvent(authoritative, 'player.maintenance')).toBe(false);
expect(variableAppliesToEvent({ ...authoritative, events: undefined, scopes: ['player.*'] },
'player.maintenance')).toBe(true);
});
it('shows only player.join variables on the retired message editor', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={pageClient(scopedVariables)}>
<MessagesPage />
</ServerProvider></I18nProvider>);
await user.click(await screen.findByRole('button', { name: /Search 3/ }));
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
expect(within(dialog).getByText('{server.tps}')).toBeInTheDocument();
expect(within(dialog).getByText('{player.name}')).toBeInTheDocument();
expect(within(dialog).getByText('{player.firstJoin}')).toBeInTheDocument();
expect(within(dialog).queryByText('{chat.message}')).not.toBeInTheDocument();
});
it('uses the synthetic player.maintenance event for maintenance messages', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={pageClient(scopedVariables)}>
<MaintenancePage />
</ServerProvider></I18nProvider>);
const launchers = await screen.findAllByRole('button', { name: /Search 2/ });
await user.click(launchers[0]);
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
expect(within(dialog).getByText('{server.tps}')).toBeInTheDocument();
expect(within(dialog).getByText('{player.name}')).toBeInTheDocument();
expect(within(dialog).queryByText('{player.firstJoin}')).not.toBeInTheDocument();
expect(within(dialog).queryByText('{chat.message}')).not.toBeInTheDocument();
});
it('keeps the legacy variable fallback when the catalog request fails', async () => {
render(<I18nProvider><ServerProvider client={pageClient(new Error('old server'))}>
<MessagesPage />
</ServerProvider></I18nProvider>);
expect(await screen.findByRole('button', { name: /Search 4/ })).toBeInTheDocument();
});
});
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { App } from '../app';
import { variableAppliesToEvent } from '../components/rich-text-variables';
import { ServerProvider } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { I18nProvider } from '../lib/i18n';
import { MaintenancePage } from './maintenance';
import { MessagesPage } from './messages';
import type {
MaintenanceState, ServerSettings, TriggerCatalog, TriggerDefinition,
TriggerVariableDefinition, TriggerWorkspace,
} from '../types';
const migrated: TriggerDefinition = {
id: 'trigger-daily', groupId: 'group-migrated', name: '每日公告', description: '', enabled: true,
mode: 'visual', event: { type: 'player.join', configuration: {} }, conditionMode: 'all',
conditions: [{ field: 'player.dailyDue', operator: 'eq', value: 'true' }],
actions: [{ type: 'send_player', parameters: { message: 'Daily {date}' } }], script: '', revision: 0,
createdBy: 'system', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', migrated: true,
};
const workspace: TriggerWorkspace = { groups: [{
id: 'group-migrated', name: '已迁移消息', description: '旧消息已自动迁移', enabled: true,
revision: 0, createdBy: 'system', createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z', migrated: true, triggers: [migrated],
}], totalTriggers: 1 };
const catalog: TriggerCatalog = {
defaultMessageSender: 'Server Default',
events: ['schedule.daily', 'player.join', 'player.leave', 'custom'],
operators: ['eq', 'contains', 'exists'],
actions: ['send_player', 'broadcast', 'server_command'],
actionParameters: { send_player: ['message'], broadcast: ['message'], server_command: ['command'] },
};
const messageSettings: ServerSettings = {
bindAddress: '127.0.0.1', port: 8765, publicBaseUrl: 'http://127.0.0.1:8765',
trustedProxyCidrs: [], idleSessionSeconds: 1800, absoluteSessionSeconds: 28800,
recentAuthenticationSeconds: 300, httpThreads: 8, maximumSseClients: 20,
webConsoleEnabled: false, rawItemEditingEnabled: false, metricsEnabled: false,
moderationEnabled: true, claimsEnabled: false, worldTrackingEnabled: false,
rollbackEnabled: false, joinExperienceEnabled: true, defaultMessageSender: 'Server Default',
joinWelcomeEnabled: true, joinWelcomeMessage: 'Welcome', firstJoinMessageEnabled: true,
firstJoinMessage: 'First welcome', dailyAnnouncementsEnabled: true,
dailyAnnouncements: ['Daily'], announcementTimeZone: 'Asia/Shanghai',
rulesReminderEnabled: true, rulesMessage: 'Rules', maintenanceJoinReminderEnabled: true,
crashProtection: { enabled: true, scanIntervalTicks: 100, itemEntitiesPerDimension: 2000,
itemEntitiesPerChunk: 256, livingEntitiesPerDimension: 2000, livingEntitiesPerChunk: 128,
totalEntitiesPerDimension: 5000, totalEntitiesPerChunk: 512,
entitiesPerNamespacePerDimension: 512, loadedChunksPerDimension: 8000,
spawnBurstLimit: 400, spawnBurstWindowTicks: 100, commandBlockCommandsPerSecond: 100,
slowTickMillis: 200, consecutiveSlowTicks: 3, heapUsagePercent: 90,
blockExcessSpawns: true, removeExcessItems: true, stopRunawayCommandBlocks: true },
features: {
consoleEnabled: false, moderationEnabled: true, claimsEnabled: false,
worldTrackingEnabled: false, rollbackEnabled: false, joinExperienceEnabled: true,
},
};
const maintenanceState: MaintenanceState = {
enabled: false, message: 'Maintenance', defaultMessageSender: 'Server Default',
allowedPlayers: [], revision: 0, schedules: [],
};
function catalogVariable(key: string, events: string[], scopes: string[] = ['event-specific']): TriggerVariableDefinition {
return {
key, nameZh: key, nameEn: key, descriptionZh: `${key} 说明`, descriptionEn: `${key} description`,
category: key.startsWith('player.') ? 'player' : 'server', scopes, events,
example: `{${key}}`, templateAllowed: true, conditionAllowed: true,
};
}
const scopedVariables = [
catalogVariable('server.tps', ['*'], ['global']),
catalogVariable('player.name', ['player.*'], ['player-event']),
catalogVariable('player.firstJoin', ['player.join']),
catalogVariable('chat.message', ['player.chat']),
];
function pageClient(variableCatalog: TriggerVariableDefinition[] | Error): ApiClient {
const value = new ApiClient();
vi.spyOn(value, 'session').mockResolvedValue({ authenticated: true, csrfToken: 'csrf', actor: {
id: 'owner', displayName: 'owner', roles: ['owner'], permissions: [], totpVerified: true,
} });
vi.spyOn(value, 'status').mockResolvedValue({
sampledAt: '2026-01-01T00:00:00Z', uptimeSeconds: 1, playersOnline: 0,
});
vi.spyOn(value, 'features').mockResolvedValue(messageSettings.features);
vi.spyOn(value, 'settings').mockResolvedValue(messageSettings);
vi.spyOn(value, 'maintenance').mockResolvedValue(maintenanceState);
vi.spyOn(value, 'subscribe').mockReturnValue(() => undefined);
const request = vi.spyOn(value, 'triggerCatalog');
if (variableCatalog instanceof Error) request.mockRejectedValue(variableCatalog);
else request.mockResolvedValue({ ...catalog, variables: variableCatalog });
return value;
}
function client(): ApiClient {
const value = new ApiClient();
vi.spyOn(value, 'session').mockResolvedValue({ authenticated: true, csrfToken: 'csrf', actor: {
id: 'owner', displayName: 'owner', roles: ['owner'], permissions: ['trigger_read', 'trigger_write'], totpVerified: true,
} });
vi.spyOn(value, 'status').mockResolvedValue({ sampledAt: '2026-01-01T00:00:00Z', uptimeSeconds: 1, playersOnline: 0 });
vi.spyOn(value, 'features').mockResolvedValue({ consoleEnabled: false, moderationEnabled: false,
claimsEnabled: false, worldTrackingEnabled: false, rollbackEnabled: false, joinExperienceEnabled: true });
vi.spyOn(value, 'subscribe').mockReturnValue(() => undefined);
vi.spyOn(value, 'triggerWorkspace').mockResolvedValue(workspace);
vi.spyOn(value, 'triggerCatalog').mockResolvedValue(catalog);
vi.spyOn(value, 'validateVisualTrigger').mockResolvedValue({
valid: true, event: migrated.event, conditionMode: migrated.conditionMode,
conditions: migrated.conditions, actions: migrated.actions,
});
vi.spyOn(value, 'validateTriggerScript').mockResolvedValue({
valid: true, event: migrated.event, conditionMode: migrated.conditionMode,
conditions: migrated.conditions, actions: migrated.actions,
});
return value;
}
describe('trigger page and migrated messages', () => {
afterEach(() => { location.hash = ''; });
it('edits a migrated daily message through the default visual builder', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/triggers';
const api = client();
const update = vi.spyOn(api, 'updateTrigger').mockResolvedValue({ trigger: migrated, workspace });
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={api}><App /></ServerProvider></I18nProvider>);
expect(await screen.findByRole('heading', { name: '触发器' })).toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: /每日公告/ }));
expect(await screen.findByText('条件判断')).toBeInTheDocument();
expect(screen.getByDisplayValue('player.dailyDue')).toBeInTheDocument();
expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Server Default]');
const inheritSender = await screen.findAllByLabelText('使用全局默认发送者 / Use global default sender');
await user.click(inheritSender[0]);
const senderFields = await screen.findAllByLabelText('发送者名称 / Sender name');
fireEvent.change(senderFields[0], { target: { value: '公告中心' } });
await user.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(update).toHaveBeenCalled());
expect(update.mock.calls[0][0].id).toBe('trigger-daily');
expect(update.mock.calls[0][0].actions[0].parameters.message).toContain('公告中心');
});
it('converts visual blocks into XFE Script and validates the script', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/triggers';
const api = client();
const compiled = {
valid: true as const,
event: { type: 'player.leave', configuration: {} },
conditionMode: 'any' as const,
conditions: [{ field: 'player.name', operator: 'contains', value: 'Alex' }],
actions: [{ type: 'broadcast', parameters: { message: 'Bye, {player.name}!' } }],
};
const validate = vi.spyOn(api, 'validateTriggerScript').mockResolvedValue(compiled);
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={api}><App /></ServerProvider></I18nProvider>);
await user.click(await screen.findByRole('button', { name: /每日公告/ }));
await user.click(screen.getByRole('button', { name: /XFE Script/ }));
const editor = screen.getByLabelText('XFE Script');
expect((editor as HTMLTextAreaElement).value).toContain('do send_player');
const script = 'on player.leave\nmatch any\nwhen player.name contains "Alex"\ndo broadcast message="Bye"';
await user.clear(editor);
await user.type(editor, script);
await user.click(screen.getByRole('button', { name: '模块化 UI' }));
await waitFor(() => expect(validate).toHaveBeenCalledWith(script));
expect(await screen.findByLabelText('事件类型')).toHaveValue('player.leave');
expect(screen.getByLabelText('条件匹配模式')).toHaveValue('any');
expect(screen.getByLabelText('操作 1 类型')).toHaveValue('broadcast');
expect(screen.getByLabelText('条件 1 字段')).toHaveValue('player.name');
});
it('keeps the custom-event editor mounted while entering a namespaced event', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
location.hash = '/triggers';
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={client()}><App /></ServerProvider></I18nProvider>);
await user.click(await screen.findByRole('button', { name: /每日公告/ }));
const eventType = screen.getByLabelText('事件类型');
await user.selectOptions(eventType, 'custom');
expect(eventType).toHaveValue('custom');
const input = await screen.findByLabelText('自定义事件 ID');
await user.type(input, 'example.boss_spawned');
expect(screen.getByLabelText('自定义事件 ID')).toHaveValue('example.boss_spawned');
});
});
describe('message variable event scopes', () => {
it('matches arbitrary safe glob positions and treats regular-expression syntax literally', () => {
expect(variableAppliesToEvent(catalogVariable('item', ['player.item_*']),
'PLAYER.ITEM_PICKUP')).toBe(true);
expect(variableAppliesToEvent(catalogVariable('item', ['player.item_*']),
'player.join')).toBe(false);
expect(variableAppliesToEvent(catalogVariable('literal', ['custom.mod+event']),
'custom.mod+event')).toBe(true);
expect(variableAppliesToEvent(catalogVariable('literal', ['custom.mod+event']),
'custom.moddddddevent')).toBe(false);
const authoritative = catalogVariable('join-only', ['player.join'], ['global']);
expect(variableAppliesToEvent(authoritative, 'player.maintenance')).toBe(false);
expect(variableAppliesToEvent({ ...authoritative, events: undefined, scopes: ['player.*'] },
'player.maintenance')).toBe(true);
});
it('shows only player.join variables on the retired message editor', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={pageClient(scopedVariables)}>
<MessagesPage />
</ServerProvider></I18nProvider>);
await user.click(await screen.findByRole('button', { name: /Search 3/ }));
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
expect(within(dialog).getByText('{server.tps}')).toBeInTheDocument();
expect(within(dialog).getByText('{player.name}')).toBeInTheDocument();
expect(within(dialog).getByText('{player.firstJoin}')).toBeInTheDocument();
expect(within(dialog).queryByText('{chat.message}')).not.toBeInTheDocument();
});
it('uses the synthetic player.maintenance event for maintenance messages', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
const user = userEvent.setup();
render(<I18nProvider><ServerProvider client={pageClient(scopedVariables)}>
<MaintenancePage />
</ServerProvider></I18nProvider>);
const launchers = await screen.findAllByRole('button', { name: /Search 2/ });
await user.click(launchers[0]);
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
expect(within(dialog).getByText('{server.tps}')).toBeInTheDocument();
expect(within(dialog).getByText('{player.name}')).toBeInTheDocument();
expect(within(dialog).queryByText('{player.firstJoin}')).not.toBeInTheDocument();
expect(within(dialog).queryByText('{chat.message}')).not.toBeInTheDocument();
});
it('keeps the legacy variable fallback when the catalog request fails', async () => {
render(<I18nProvider><ServerProvider client={pageClient(new Error('old server'))}>
<MessagesPage />
</ServerProvider></I18nProvider>);
expect(await screen.findByRole('button', { name: /Search 4/ })).toBeInTheDocument();
});
});