XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
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();
  });
});