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 { ServerProvider } from '../context/server-context';
import { ApiClient, ApiError } from '../lib/api';
import { I18nProvider } from '../lib/i18n';
import type { ContentCatalog, ContentDefinition, ContentProperty, ContentWorkspaceView, MenuDefinition } from '../types';

const timestamp = '2026-09-12T00:00:00Z';
const menuId = '00000000-0000-0000-0000-000000000001';
const triggerId = '00000000-0000-0000-0000-000000000002';
const field = (key: string, nameZh: string, type: ContentProperty['type'] = 'resource', defaultValue = ''): ContentProperty => ({
  key, nameZh, nameEn: key, descriptionZh: `${nameZh}的说明`, descriptionEn: `${key} help`, type,
  defaultValue, min: null, max: null, options: [], requiresRestart: true,
});
const definition: ContentDefinition = { id: 'xfesmcontent:guide', kind: 'ITEM', name: '向导罗盘', description: '打开大厅菜单', archetype: 'generic',
  properties: { max_stack_size: '1', texture: 'minecraft:item/compass' }, behaviors: [] };
const seat: ContentDefinition = { id: 'xfesmcontent:seat', kind: 'BLOCK', name: '木椅', description: '右键坐下', archetype: 'solid',
  properties: { hardness: '2' }, behaviors: [{ id: 'seat', event: 'right_click', action: 'sit', parameters: { height: '0.5' }, cooldownTicks: 10, cancelVanilla: true }] };
const catalog: ContentCatalog = { schemaVersion: 1,
  archetypes: [
    { id: 'generic', kind: 'ITEM', nameZh: '普通物品', nameEn: 'Generic item', descriptionZh: '普通物品与道具', descriptionEn: 'Generic item',
      properties: [{ ...field('max_stack_size', '堆叠上限', 'int', '64'), min: 1, max: 64 }, field('texture', '贴图', 'resource')] },
    { id: 'sword', kind: 'ITEM', nameZh: '剑', nameEn: 'Sword', descriptionZh: '近战武器', descriptionEn: 'Melee weapon',
      properties: [field('attack_damage', '攻击伤害', 'number', '6')] },
    { id: 'solid', kind: 'BLOCK', nameZh: '实体方块', nameEn: 'Solid block', descriptionZh: '可放置的方块', descriptionEn: 'Placeable block', properties: [field('hardness', '硬度', 'number', '1')] },
    { id: 'cow', kind: 'ENTITY', nameZh: '牛', nameEn: 'Cow', descriptionZh: '牛的行为原型', descriptionEn: 'Cow archetype', properties: [field('max_health', '最大生命值', 'number', '10')] },
  ],
  events: [{ id: 'right_click', nameZh: '玩家右键', nameEn: 'Player right click', descriptionZh: '玩家使用或交互时触发', descriptionEn: 'When a player interacts', kinds: ['ITEM', 'BLOCK', 'ENTITY'], archetypes: [], cancellable: true },
    { id: 'consume', nameZh: '食用完成', nameEn: 'Consumed', descriptionZh: '完成食用', descriptionEn: 'After eating', kinds: ['ITEM'], archetypes: ['food'], cancellable: false },
    { id: 'hurt', nameZh: '受到伤害', nameEn: 'Hurt', descriptionZh: '受到伤害后触发', descriptionEn: 'After damage', kinds: ['ENTITY'], archetypes: [], cancellable: false }],
  actions: [
    { id: 'open_menu', nameZh: '打开菜单', nameEn: 'Open menu', descriptionZh: '为事件玩家打开已有菜单', descriptionEn: 'Open an existing menu', events: ['right_click'], kinds: ['ITEM', 'BLOCK', 'ENTITY'], parameters: [field('menu_id', '目标菜单', 'menu_ref')] },
    { id: 'trigger', nameZh: '调用触发器', nameEn: 'Invoke trigger', descriptionZh: '执行已有触发器', descriptionEn: 'Invoke an existing trigger', events: ['right_click', 'hurt'], kinds: ['ITEM', 'BLOCK', 'ENTITY'], parameters: [field('trigger_id', '目标触发器', 'trigger_ref')] },
    { id: 'sit', nameZh: '坐下', nameEn: 'Sit', descriptionZh: '在交互方块上坐下', descriptionEn: 'Sit on the block', events: ['right_click'], kinds: ['BLOCK'], parameters: [field('height', '座位高度', 'number', '0.5')] },
  ], templates: [{ id: 'seat', nameZh: '右键座椅', nameEn: 'Seat', descriptionZh: '创建可以右键坐下的方块', descriptionEn: 'A block players can sit on', definition: seat }],
};
function state(definitions: ContentDefinition[] = [definition], revision = 4): ContentWorkspaceView {
  return { workspace: { schemaVersion: 1, revision, enabled: true, definitions: structuredClone(definitions), updatedAt: timestamp, updatedBy: 'owner' },
    publishedRevision: 3, loadedRevision: 3, restartRequired: false, clientRequired: true };
}
function client(owner = true, definitions = [definition]) {
  const api = new ApiClient();
  vi.spyOn(api, 'session').mockResolvedValue({ authenticated: true, csrfToken: 'csrf', actor: { id: 'owner', displayName: 'Owner',
    roles: owner ? ['owner'] : ['administrator'], permissions: ['trigger_read', '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, 'contentCatalog').mockResolvedValue(catalog);
  vi.spyOn(api, 'contentWorkspace').mockResolvedValue(state(definitions));
  vi.spyOn(api, 'contentAssets').mockResolvedValue([]);
  vi.spyOn(api, 'menuWorkspace').mockResolvedValue({ menus: [{ id: menuId, name: '大厅菜单' } as MenuDefinition], totalMenus: 1 });
  vi.spyOn(api, 'triggerWorkspace').mockResolvedValue({ groups: [{ id: 'group', name: '技能', description: '', enabled: true, revision: 1, createdAt: timestamp, updatedAt: timestamp,
    createdBy: 'owner', migrated: false, triggers: [{ id: triggerId, name: '欢迎技能' } as never] }], totalTriggers: 1 });
  vi.spyOn(api, 'validateContent').mockResolvedValue({ valid: true, errors: [], warnings: [] });
  vi.spyOn(api, 'saveContent').mockImplementation(async (input) => ({ ...state(input.definitions, input.expectedRevision + 1), workspace: { ...state(input.definitions, input.expectedRevision + 1).workspace, enabled: input.enabled } }));
  vi.spyOn(api, 'publishContent').mockResolvedValue({ ...state(definitions), publishedRevision: 4, restartRequired: true });
  vi.spyOn(api, 'rollbackContent').mockResolvedValue(state([seat], 5));
  vi.spyOn(api, 'uploadContentAsset').mockResolvedValue({ id: 'asset1', name: 'music.ogg', mimeType: 'audio/ogg', bytes: 100, resourceLocation: 'xfesmcontent:music' });
  vi.spyOn(api, 'exportContent').mockResolvedValue({ fileName: 'content-r3.zip', mimeType: 'application/zip', dataBase64: 'UEs=', sha256: 'hash', revision: 3 });
  return api;
}
function renderContent(api: ApiClient, locale = 'zh-CN') {
  localStorage.setItem('xfesm.locale', locale); location.hash = '/content';
  return render(<I18nProvider><ServerProvider client={api}><App /></ServerProvider></I18nProvider>);
}

describe('content studio', () => {
  afterEach(() => vi.restoreAllMocks());

  it('renders server-described properties, saves a menu behavior with revision CAS and Ctrl+S', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api);
    expect(await screen.findByLabelText('显示名称')).toHaveValue('向导罗盘');
    expect(screen.getByLabelText(/堆叠上限/)).toHaveValue(1);
    expect(screen.getByLabelText(/堆叠上限/)).toHaveAttribute('max', '64');
    await user.click(screen.getByRole('button', { name: '添加行为' }));
    expect(within(screen.getByLabelText(/^触发事件/)).queryByRole('option', { name: '食用完成' })).not.toBeInTheDocument();
    expect(within(screen.getByLabelText(/^执行动作/)).queryByRole('option', { name: '坐下' })).not.toBeInTheDocument();
    await user.selectOptions(screen.getByLabelText(/目标菜单/), menuId);
    fireEvent.keyDown(document, { key: 's', ctrlKey: true });
    await waitFor(() => expect(api.saveContent).toHaveBeenCalledTimes(1));
    expect(api.saveContent).toHaveBeenCalledWith({ expectedRevision: 4, enabled: true, definitions: [expect.objectContaining({
      id: 'xfesmcontent:guide', behaviors: [expect.objectContaining({ action: 'open_menu', parameters: { menu_id: menuId } })],
    })] });
    expect(api.publishContent).not.toHaveBeenCalled();
    expect(screen.getByRole('button', { name: /保存草稿/ })).toBeDisabled();
  });

  it('selects an existing trigger by name and preserves its ID', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api);
    await screen.findByLabelText('显示名称'); await user.click(screen.getByRole('button', { name: '添加行为' }));
    await user.selectOptions(screen.getByLabelText(/^执行动作/), 'trigger');
    await user.selectOptions(screen.getByLabelText(/目标触发器/), triggerId);
    await user.click(screen.getByRole('button', { name: /保存草稿/ }));
    await waitFor(() => expect(api.saveContent).toHaveBeenCalledTimes(1));
    expect(vi.mocked(api.saveContent).mock.calls[0][0].definitions[0].behaviors[0].parameters).toEqual({ trigger_id: triggerId });
  });

  it('creates from the server template, duplicates unique registry IDs and confirms deletion', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api);
    await screen.findByLabelText('显示名称');
    await user.selectOptions(screen.getByLabelText('官方玩法模板'), 'seat');
    await user.click(screen.getByRole('button', { name: '从模板创建' }));
    expect(screen.getByLabelText('显示名称')).toHaveValue('木椅');
    expect(screen.getByLabelText(/座位高度/)).toHaveValue(0.5);
    await user.click(screen.getByRole('button', { name: '复制内容' }));
    expect(screen.getByLabelText('显示名称')).toHaveValue('木椅 副本');
    await user.click(screen.getByRole('button', { name: '删除内容' }));
    expect(screen.getByRole('dialog')).toHaveTextContent('旧世界');
    await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: '取消' }));
    await user.click(screen.getByRole('button', { name: /保存草稿/ }));
    await waitFor(() => expect(api.saveContent).toHaveBeenCalled());
    const saved = vi.mocked(api.saveContent).mock.calls[0][0].definitions;
    expect(saved).toHaveLength(3); expect(new Set(saved.map((item) => item.id)).size).toBe(3);
    expect(saved[1].behaviors[0]).toMatchObject({ action: 'sit', parameters: { height: '0.5' } });
  });

  it('filters the library and creates a typed entity with catalog defaults', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api);
    await screen.findByLabelText('显示名称');
    await user.click(within(screen.getByRole('group', { name: '内容分类' })).getByRole('button', { name: '生物' }));
    await user.click(screen.getByRole('button', { name: '创建内容' }));
    expect(screen.getByLabelText(/最大生命值/)).toHaveValue(10);
    await user.click(screen.getByRole('button', { name: /保存草稿/ }));
    await waitFor(() => expect(api.saveContent).toHaveBeenCalled());
    expect(vi.mocked(api.saveContent).mock.calls[0][0].definitions[1]).toMatchObject({ kind: 'ENTITY', archetype: 'cow', properties: { max_health: '10' } });
  });

  it('does not save invalid drafts and keeps inputs intact after revision conflicts', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api);
    await screen.findByLabelText('显示名称');
    await user.clear(screen.getByLabelText('显示名称')); await user.type(screen.getByLabelText('显示名称'), '新名称');
    vi.mocked(api.validateContent).mockResolvedValueOnce({ valid: false, errors: ['测试属性超出范围'], warnings: [] });
    await user.click(screen.getByRole('button', { name: /保存草稿/ }));
    expect(await screen.findByText('测试属性超出范围')).toBeInTheDocument(); expect(api.saveContent).not.toHaveBeenCalled();
    vi.mocked(api.saveContent).mockRejectedValueOnce(new ApiError('修订冲突', 409));
    await user.click(screen.getByRole('button', { name: /保存草稿/ }));
    await screen.findByText('修订冲突'); expect(screen.getByLabelText('显示名称')).toHaveValue('新名称');
    expect(screen.getByRole('button', { name: '发布已保存版本' })).toBeDisabled();
  });

  it('requires explicit publication confirmation and labels the result as pending restart', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api);
    await screen.findByLabelText('显示名称'); await user.click(screen.getByRole('button', { name: '发布已保存版本' }));
    expect(api.publishContent).not.toHaveBeenCalled(); expect(screen.getByRole('dialog')).toHaveTextContent('双因素认证');
    await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: '确认继续' }));
    await waitFor(() => expect(api.publishContent).toHaveBeenCalledWith(4));
    expect(await screen.findByText('待同步内容包并重启')).toBeInTheDocument();
  });

  it('warns about public downloads and a managed restart before publication', async () => {
    const api = client(); const user = userEvent.setup();
    const update: NonNullable<ContentWorkspaceView['update']> = { mode: 'MANAGED', managed: true,
      reason: '受管启动器在线', state: 'IDLE', countdownSeconds: 0, clientSync: 'PRE_LOGIN_STATUS' };
    vi.mocked(api.contentWorkspace).mockResolvedValue({ ...state(), update });
    vi.mocked(api.publishContent).mockResolvedValue({ ...state(), publishedRevision: 4, restartRequired: true,
      update: { ...update, state: 'SCHEDULED', countdownSeconds: 30, reason: '30 秒后安全重启' } });
    renderContent(api); await screen.findByLabelText('显示名称');
    expect(screen.getByText(/自动更新检测:支持受管重启/)).toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: '发布已保存版本' }));
    expect(screen.getByRole('dialog')).toHaveTextContent('公开供客户端在登录前下载');
    expect(screen.getByRole('dialog')).toHaveTextContent('发布后 30 秒将保存世界、数据库并自动重启服务器');
    await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: '确认继续' }));
    expect(await screen.findByText(/版本已发布,服务器将在 30 秒后保存并自动重启/)).toBeInTheDocument();
  });

  it('does not label a detected launcher as restart-capable', async () => {
    const api = client(); const user = userEvent.setup();
    vi.mocked(api.contentWorkspace).mockResolvedValue({ ...state(), update: { mode: 'LAUNCHER', managed: false,
      reason: '检测到启动器,但未接管进程', state: 'UNMANAGED', countdownSeconds: 0, clientSync: 'PRE_LOGIN_STATUS' } });
    renderContent(api); await screen.findByLabelText('显示名称');
    expect(screen.getByText(/自动更新检测:尚未接管重启/)).toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: '发布已保存版本' }));
    expect(screen.getByRole('dialog')).toHaveTextContent('服务器会保持运行');
    expect(screen.getByRole('dialog')).not.toHaveTextContent('30 秒将');
  });

  it('restores a historical revision with CAS after confirmation', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api);
    await screen.findByLabelText('显示名称'); await user.type(screen.getByLabelText(/^要恢复的历史修订号/), '2');
    await user.click(screen.getByRole('button', { name: '恢复历史修订' }));
    await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: '确认继续' }));
    await waitFor(() => expect(api.rollbackContent).toHaveBeenCalledWith(4, 2));
    expect(screen.getByLabelText('显示名称')).toHaveValue('木椅');
  });

  it('explicitly warns that publishing a disabled draft does not register its content', async () => {
    const api = client(); const user = userEvent.setup();
    const disabled = { ...state(), workspace: { ...state().workspace, enabled: false }, publishedRevision: null, loadedRevision: null, clientRequired: false };
    vi.mocked(api.contentWorkspace).mockResolvedValue(disabled);
    vi.mocked(api.publishContent).mockResolvedValue({ ...disabled, publishedRevision: 4 });
    renderContent(api); await screen.findByLabelText('显示名称');
    expect(screen.getByText('当前草稿未启用内容模式')).toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: '发布已保存版本' }));
    expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent('仅发布禁用草稿,不注册内容');
    expect(api.publishContent).not.toHaveBeenCalled();
    await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: '确认继续' }));
    await screen.findByText('已发布禁用草稿,不会注册内容。要启用新内容,请先开启完整内容模式,保存并重新发布。');
  });

  it('restricts all mutation controls for an administrator reader including shortcuts', async () => {
    const api = client(false); renderContent(api); await screen.findByLabelText('显示名称');
    expect(screen.getByLabelText('显示名称')).toBeDisabled();
    for (const name of ['创建内容', '从模板创建', '复制内容', '删除内容', '添加行为', '发布已保存版本', '恢复历史修订']) {
      expect(screen.getByRole('button', { name })).toBeDisabled();
    }
    expect(screen.getByLabelText('上传素材文件')).toBeDisabled();
    fireEvent.keyDown(document, { key: 's', ctrlKey: true });
    expect(api.saveContent).not.toHaveBeenCalled(); expect(api.publishContent).not.toHaveBeenCalled();
  });

  it('uploads OGG and rejects unsupported assets without sending them', async () => {
    const api = client(); const user = userEvent.setup({ applyAccept: false }); renderContent(api); await screen.findByLabelText('显示名称');
    await user.upload(screen.getByLabelText('上传素材文件'), new File(['music'], 'music.ogg', { type: 'audio/ogg' }));
    await waitFor(() => expect(api.uploadContentAsset).toHaveBeenCalledWith('music.ogg', 'audio/ogg', 'bXVzaWM='));
    expect(await screen.findByText('xfesmcontent:music')).toBeInTheDocument();
    await user.upload(screen.getByLabelText('上传素材文件'), new File(['script'], 'script.js', { type: 'text/javascript' }));
    expect(await screen.findByText('仅支持不超过 8 MiB 的 PNG 贴图或 OGG Vorbis 音频。')).toBeInTheDocument();
    expect(api.uploadContentAsset).toHaveBeenCalledTimes(1);
  });

  it('downloads the actual published pack, without publishing the draft', async () => {
    const api = client(); const user = userEvent.setup();
    const objectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:content-export');
    vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
    const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
    renderContent(api); await screen.findByLabelText('显示名称');
    await user.click(screen.getByRole('button', { name: '导出已发布安装包' }));
    await waitFor(() => expect(api.exportContent).toHaveBeenCalledOnce());
    expect(objectUrl).toHaveBeenCalledWith(expect.any(Blob));
    expect(clicked).toHaveBeenCalledOnce(); expect(api.publishContent).not.toHaveBeenCalled();
    expect(await screen.findByText('已导出修订 3 · SHA-256: hash')).toBeInTheDocument();
  });

  it('keeps focus inside a confirmation and Escape cancels without mutation', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api); await screen.findByLabelText('显示名称');
    await user.click(screen.getByRole('button', { name: '删除内容' }));
    expect(screen.getByRole('button', { name: '关闭确认' })).toHaveFocus();
    await user.tab({ shift: true }); expect(screen.getByRole('button', { name: '确认继续' })).toHaveFocus();
    await user.keyboard('{Escape}'); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: '删除内容' })).toHaveFocus(); expect(api.saveContent).not.toHaveBeenCalled();
  });

  it('renders English catalog labels and requires confirmation before dropping unsupported properties', async () => {
    const api = client(); const user = userEvent.setup(); renderContent(api, 'en-US');
    await screen.findByLabelText('Display name'); await user.selectOptions(screen.getByLabelText(/^Archetype/), 'sword');
    expect(screen.getByLabelText(/^Archetype/)).toHaveValue('generic');
    await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm' }));
    expect(screen.getByLabelText(/attack_damage/)).toHaveValue(6);
    await user.click(screen.getByRole('button', { name: /Save draft/ }));
    await waitFor(() => expect(api.saveContent).toHaveBeenCalled());
    expect(vi.mocked(api.saveContent).mock.calls[0][0].definitions[0].properties).toEqual({ attack_damage: '6' });
  });
});