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

XFEServerManager

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

公开
关注 0 Fork 0 Star 0
UTF-8
import { act, 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 { decodeRichText, encodeRichText } from '../components/rich-text-editor';
import { ServerProvider } from '../context/server-context';
import { ApiClient } from '../lib/api';
import { I18nProvider } from '../lib/i18n';
import { serializeTriggerNode } from '../lib/trigger-program-clipboard';
import type {
  SessionActor, StreamEvent, TriggerCatalog, TriggerDefinition, TriggerGroup, TriggerValidation,
  TriggerExpression, TriggerStatement, TriggerWorkspace,
} from '../types';

const timestamp = '2026-01-01T00:00:00Z';

function trigger(overrides: Partial<TriggerDefinition> = {}): TriggerDefinition {
  return {
    id: 'trigger-alpha', groupId: 'group-main', name: 'Alpha trigger', description: '', enabled: true,
    mode: 'visual', event: { type: 'player.join', configuration: {} }, conditionMode: 'all',
    conditions: [], actions: [{ type: 'send_player', parameters: { message: 'Hello' } }], script: '',
    revision: 0, createdBy: 'owner', createdAt: timestamp, updatedAt: timestamp, migrated: false,
    ...overrides,
  };
}

function group(id: string, name: string, triggers: TriggerDefinition[]): TriggerGroup {
  return {
    id, name, description: '', enabled: true, revision: 0, createdBy: 'owner', createdAt: timestamp,
    updatedAt: timestamp, migrated: false, triggers,
  };
}

function workspace(triggers: TriggerDefinition[]): TriggerWorkspace {
  return { groups: [group('group-main', 'Main group', triggers)], totalTriggers: triggers.length };
}

const alpha = trigger();
const beta = trigger({ id: 'trigger-beta', name: 'Beta trigger', event: { type: 'player.leave', configuration: {} },
  actions: [{ type: 'broadcast', parameters: { message: 'Goodbye' } }] });

function v2Expression(overrides: Partial<TriggerExpression> = {}): TriggerExpression {
  return { nodeId: crypto.randomUUID(), kind: 'LITERAL', valueType: 'string', name: '', literal: '', arguments: [], ...overrides };
}
function v2Statement(overrides: Partial<TriggerStatement> = {}): TriggerStatement {
  return { nodeId: crypto.randomUUID(), kind: 'ACTION', name: 'send_player', inputs: {
    message: v2Expression({ literal: '欢迎 {player.name}!' }),
  }, statements: [], elseStatements: [], cases: [], ...overrides };
}
function v2Trigger(statements: TriggerStatement[] = []): TriggerDefinition {
  return trigger({ schemaVersion: 2, events: [{ nodeId: 'event-join', type: 'player.join', configuration: {} }],
    declarations: [], functions: [], statements });
}
function copyProgramSelection(target: HTMLElement): string {
  let content = '';
  // user-event.copy() skips the native copy event when no text range is selected.
  fireEvent.copy(target, { clipboardData: { setData: (_format: string, value: string) => { content = value; } } });
  return content;
}

const catalog: TriggerCatalog = {
  schemaVersion: 2,
  catalogRevision: 2,
  defaultMessageSender: 'Server Default',
  events: ['schedule.daily', 'schedule.interval', 'player.join', 'player.leave', 'player.item_pickup',
    'player.command_trigger', 'protection.item_overflow', 'protection.mob_overflow',
    'protection.entity_overflow', 'protection.mod_entity_overflow', 'protection.spawn_burst',
    'protection.command_block_rate', 'protection.slow_tick', 'protection.loaded_chunk_overflow',
    'protection.memory_pressure', 'region.enter', 'region.leave', 'region.stay', 'custom'],
  operators: ['eq', 'neq', 'contains', 'not_contains', 'starts_with', 'not_starts_with',
    'ends_with', 'not_ends_with', 'matches', 'not_matches', 'gt', 'gte', 'lt', 'lte',
    'between', 'not_between', 'in', 'not_in', 'exists', 'not_exists', 'empty', 'not_empty',
    'true', 'false'],
  actions: ['send_player', 'broadcast', 'kick', 'server_command', 'player_command', 'log',
    'economy_deposit', 'economy_withdraw', 'economy_set_balance', 'economy_transfer',
    'economy_deposit_player', 'economy_withdraw_player', 'economy_set_player_balance',
    'economy_transfer_players'],
  actionParameters: {
    send_player: ['message'], broadcast: ['message'], kick: ['message'],
    server_command: ['command', 'showFeedback'], player_command: ['command', 'showFeedback'],
    log: ['message', 'level'],
    economy_deposit: ['currency', 'amount', 'reason'],
    economy_withdraw: ['currency', 'amount', 'reason'],
    economy_set_balance: ['currency', 'amount', 'reason'],
    economy_transfer: ['currency', 'amount', 'target', 'reason'],
    economy_deposit_player: ['player', 'currency', 'amount', 'reason'],
    economy_withdraw_player: ['player', 'currency', 'amount', 'reason'],
    economy_set_player_balance: ['player', 'currency', 'amount', 'reason'],
    economy_transfer_players: ['source', 'target', 'currency', 'amount', 'reason'],
  },
  variables: [
    { key: 'server.online', nameZh: '在线玩家数', nameEn: 'Online players',
      descriptionZh: '当前在线玩家数量。', descriptionEn: 'Current online player count.',
      category: 'server', scopes: ['*'], events: ['*'], example: '{server.online}',
      sampleValue: '12', type: 'number', templateAllowed: true, conditionAllowed: true },
    { key: 'server.defaultMessageSender', nameZh: '默认消息发送者', nameEn: 'Default message sender',
      descriptionZh: '服务器设置的默认发送者。', descriptionEn: 'Configured default sender.',
      category: 'server', scopes: ['*'], events: ['*'], example: '{server.defaultMessageSender}',
      type: 'string', templateAllowed: true, conditionAllowed: true },
    { key: 'server.time', nameZh: '服务器时间', nameEn: 'Server time',
      descriptionZh: '服务器当前时间。', descriptionEn: 'Current server time.',
      category: 'time', scopes: ['*'], events: ['*'], example: '{server.time}',
      type: 'datetime', templateAllowed: true, conditionAllowed: true },
    { key: 'player.name', nameZh: '玩家名称', nameEn: 'Player name',
      descriptionZh: '触发事件的玩家名称。', descriptionEn: 'Name of the player that triggered the event.',
      category: 'player', scopes: ['player.*'], events: ['player.*'], example: '{player.name}',
      type: 'string', templateAllowed: true, conditionAllowed: true },
    { key: 'chat.message', nameZh: '聊天消息', nameEn: 'Chat message',
      descriptionZh: '玩家发送的聊天消息。', descriptionEn: 'Chat message sent by the player.',
      category: 'event', scopes: ['player.chat'], events: ['player.chat'], example: '{chat.message}',
      type: 'string', templateAllowed: true, conditionAllowed: true },
    { key: 'item.id', nameZh: '物品 ID', nameEn: 'Item ID',
      descriptionZh: '物品事件的资源 ID。', descriptionEn: 'Resource ID for an item event.',
      category: 'event', scopes: ['player.item_*'], events: ['player.item_*'], example: '{item.id}',
      type: 'string', templateAllowed: true, conditionAllowed: true },
    { key: 'command.name', nameZh: '自定义指令名称', nameEn: 'Custom command name',
      descriptionZh: '自定义指令根名称。', descriptionEn: 'Root name of the custom command.',
      category: 'command', scopes: ['player.command_trigger'], events: ['player.command_trigger'],
      example: '{command.name}', type: 'string', templateAllowed: true, conditionAllowed: true },
    { key: 'command.raw', nameZh: '自定义指令原始输入', nameEn: 'Raw custom command input',
      descriptionZh: '玩家输入的完整指令。', descriptionEn: 'Complete command entered by the player.',
      category: 'command', scopes: ['player.command_trigger'], events: ['player.command_trigger'],
      example: '{command.raw}', type: 'string', templateAllowed: true, conditionAllowed: true },
    { key: 'server.time:uuuu-MM-dd HH:mm:ss', nameZh: '格式化服务器时间', nameEn: 'Formatted server time',
      descriptionZh: '按指定格式输出服务器时间。', descriptionEn: 'Formats the current server time.',
      category: 'time', scopes: ['*'], events: ['*'],
      example: '{server.time:uuuu-MM-dd HH:mm:ss}', type: 'formatted-time',
      templateAllowed: true, conditionAllowed: false },
  ],
  budgets: { nodes: 4096, nestingDepth: 32, callDepth: 16, loopIterations: 10000, instructions: 100000 },
  descriptors: [
    { id: 'player.join', kind: 'EVENT', displayName: 'Player joined', description: 'Player joins the server.',
      parameters: [], returnType: 'void', purity: 'SNAPSHOT_QUERY', threadAffinity: 'SERVER', risk: 'SAFE',
      supportedPlatforms: ['forge-1.20.1'], applicableEvents: ['player.join'] },
    { id: 'send_player', kind: 'ACTION', displayName: 'Send player message',
      description: 'Sends a message to the event player.',
      parameters: [{ name: 'message', valueType: 'expression<string>', required: true, defaultValue: '' }],
      returnType: 'void', purity: 'SIDE_EFFECT', threadAffinity: 'SERVER', risk: 'SAFE',
      supportedPlatforms: ['forge-1.20.1'], applicableEvents: ['player.*'] },
    { id: 'string', kind: 'TYPE', displayName: 'String', description: 'Text value.', parameters: [],
      returnType: 'string', purity: 'PURE', threadAffinity: 'BACKGROUND', risk: 'SAFE',
      supportedPlatforms: ['*'], applicableEvents: ['*'] },
  ],
};

const localizedOperators = [
  ['eq', '等于', 'Equals'], ['neq', '不等于', 'Does not equal'],
  ['contains', '包含指定文本', 'Contains the specified text'],
  ['not_contains', '不包含指定文本', 'Does not contain the specified text'],
  ['starts_with', '以指定文本开头', 'Starts with the specified text'],
  ['not_starts_with', '不以指定文本开头', 'Does not start with the specified text'],
  ['ends_with', '以指定文本结尾', 'Ends with the specified text'],
  ['not_ends_with', '不以指定文本结尾', 'Does not end with the specified text'],
  ['matches', '匹配正则表达式', 'Matches a regular expression'],
  ['not_matches', '不匹配正则表达式', 'Does not match a regular expression'],
  ['gt', '大于', 'Is greater than'], ['gte', '大于或等于', 'Is greater than or equal to'],
  ['lt', '小于', 'Is less than'], ['lte', '小于或等于', 'Is less than or equal to'],
  ['between', '介于两个数值之间(含边界)', 'Is between two numbers (inclusive)'],
  ['not_between', '不在两个数值之间(含边界)', 'Is not between two numbers (inclusive)'],
  ['in', '属于列表中的任一值', 'Is one of the listed values'],
  ['not_in', '不属于列表中的任一值', 'Is not one of the listed values'],
  ['exists', '字段存在', 'Field exists'], ['not_exists', '字段不存在', 'Field does not exist'],
  ['empty', '字段为空', 'Field is empty'], ['not_empty', '字段不为空', 'Field is not empty'],
  ['true', '字段为真', 'Field is true'], ['false', '字段为假', 'Field is false'],
] as const;

function client({ data = workspace([alpha, beta]), permissions = ['trigger_read', 'trigger_write'],
  roles = ['owner'] as SessionActor['roles'] }: {
  data?: TriggerWorkspace; permissions?: string[]; roles?: SessionActor['roles'];
} = {}): ApiClient {
  const value = new ApiClient();
  vi.spyOn(value, 'session').mockResolvedValue({ authenticated: true, csrfToken: 'csrf', actor: {
    id: 'actor', displayName: 'actor', roles, permissions, totpVerified: true,
  } });
  vi.spyOn(value, 'status').mockResolvedValue({ sampledAt: timestamp, 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(data);
  vi.spyOn(value, 'triggerCatalog').mockResolvedValue(catalog);
  vi.spyOn(value, 'menuWorkspace').mockResolvedValue({ menus: [], totalMenus: 0 });
  vi.spyOn(value, 'validateVisualTrigger').mockResolvedValue({
    valid: true, event: { type: 'player.join', configuration: {} }, conditionMode: 'all',
    conditions: [], actions: [{ type: 'broadcast', parameters: { message: 'validated' } }],
  });
  vi.spyOn(value, 'validateTriggerScript').mockResolvedValue({
    valid: true, event: { type: 'player.join', configuration: {} }, conditionMode: 'all',
    conditions: [], actions: [{ type: 'broadcast', parameters: { message: 'validated' } }],
  });
  vi.spyOn(value, 'validateTriggerV2').mockResolvedValue({ valid: true });
  vi.spyOn(value, 'triggerExecutions').mockResolvedValue({ items: [] });
  vi.spyOn(value, 'triggerEventCaptures').mockResolvedValue({ items: [] });
  vi.spyOn(value, 'triggerLibraries').mockResolvedValue({ items: [] });
  return value;
}

function renderTriggers(api: ApiClient, locale: 'zh-CN' | 'en-US' = 'zh-CN') {
  localStorage.setItem('xfesm.locale', locale);
  location.hash = '/triggers';
  return render(<I18nProvider><ServerProvider client={api}><App /></ServerProvider></I18nProvider>);
}

function deferred<T>() {
  let resolve!: (value: T) => void;
  const promise = new Promise<T>((accept) => { resolve = accept; });
  return { promise, resolve };
}

describe('trigger editor state and permissions', () => {
  afterEach(() => {
    location.hash = '';
    localStorage.removeItem('xfesm.locale');
    vi.restoreAllMocks();
  });

  it('edits and saves a V2 document through the three-pane catalog workspace', async () => {
    const v2 = trigger({
      schemaVersion: 2,
      events: [{ nodeId: '10000000-0000-0000-0000-000000000001', type: 'player.join', configuration: {} }],
      declarations: [], functions: [], statements: [],
    });
    const api = client({ data: workspace([v2]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (value) => ({
      trigger: value, workspace: workspace([value]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    expect(screen.getByRole('region', { name: '触发器库' })).toBeInTheDocument();
    expect(screen.getByRole('region', { name: '程序树' })).toBeInTheDocument();
    expect(screen.getByRole('region', { name: '节点属性' })).toBeInTheDocument();

    const localizedAction = screen.getByRole('button', { name: /向事件玩家发消息.*send_player.*动作/ });
    expect(localizedAction).toHaveTextContent('向触发当前事件的玩家发送消息');
    expect(screen.getByRole('region', { name: '程序树' }).parentElement).toHaveClass('galaxy-workbench');
    expect(screen.getByRole('button', { name: /Alpha trigger/ }).closest('aside')).toHaveClass('trigger-sidebar');
    await user.click(localizedAction);
    expect(screen.getByRole('button', { name: /执行动作.*向事件玩家发消息.*send_player/ })).toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: '保存' }));

    await waitFor(() => expect(update).toHaveBeenCalledOnce());
    expect(api.validateTriggerV2).toHaveBeenCalledWith(expect.objectContaining({
      schemaVersion: 2,
      statements: [expect.objectContaining({ kind: 'ACTION', name: 'send_player' })],
    }));
    expect(update.mock.calls[0][0]).toMatchObject({
      schemaVersion: 2,
      statements: [expect.objectContaining({ kind: 'ACTION', name: 'send_player' })],
    });
  });

  it('uses localized, guided event forms instead of requiring V2 event JSON', async () => {
    const scheduled = trigger({
      schemaVersion: 2,
      events: [{ nodeId: '10000000-0000-0000-0000-000000000001', type: 'schedule.daily',
        configuration: { time: '08:00', timezone: 'Asia/Shanghai' } }],
      declarations: [], functions: [], statements: [],
    });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([scheduled]) }));

    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    expect(screen.getByLabelText(/^每天时间/)).toHaveValue('08:00');
    expect(screen.getByLabelText(/^时区/)).toHaveValue('Asia/Shanghai');
    expect(screen.getByText('每天在指定时区和时间运行一次。')).toBeInTheDocument();

    await user.selectOptions(screen.getByRole('combobox', { name: '事件类型' }), 'region.enter');
    expect(screen.getByLabelText(/区域 ID/)).toHaveValue('example.region');
    expect(screen.getByLabelText('区域形状')).toHaveTextContent('长方体');
    expect(screen.getByText('玩家或实体从区域外移动到区域内时运行。')).toBeInTheDocument();
    expect(screen.getByText('高级事件配置 JSON')).toBeInTheDocument();
  });

  it('previews actual legacy and typed conditions, rich messages, and live parameter edits in the tree', async () => {
    const legacy = v2Expression({ kind: 'FUNCTION', name: 'legacy_condition', valueType: 'bool', arguments: [
      v2Expression({ valueType: 'record', literal: { field: 'player.firstJoin', operator: 'eq', value: 'true' } }),
    ] });
    const comparison = v2Expression({ kind: 'BINARY', name: '>', valueType: 'bool', arguments: [
      v2Expression({ kind: 'REFERENCE', name: 'server.online' }), v2Expression({ valueType: 'integer', literal: 5 }),
    ] });
    const branch = v2Statement({ kind: 'IF', name: '', inputs: {}, expression: v2Expression({ kind: 'BINARY', name: 'and',
      valueType: 'bool', arguments: [legacy, comparison] }), statements: [v2Statement()],
    elseStatements: [v2Statement({ inputs: { message: v2Expression({ literal: encodeRichText(decodeRichText('欢迎回来!')) }) } })] });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([v2Trigger([branch])]) }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    const tree = within(screen.getByRole('region', { name: '程序树' }));
    const condition = tree.getByRole('button', { name: /条件分支.*如果/ });
    expect(condition).toHaveTextContent('{player.firstJoin} 等于 “true”');
    expect(condition).toHaveTextContent('并且');
    expect(condition).toHaveTextContent('在线玩家数 {server.online} 大于 5');
    expect(condition).not.toHaveTextContent('legacy_condition');
    const thenBranch = within(tree.getByRole('group', { name: '条件成立时' }));
    const action = thenBranch.getByRole('button', { name: /执行动作.*消息内容:.*欢迎/ });
    expect(action).toHaveTextContent('消息内容:“欢迎 {player.name}!”');
    expect(within(tree.getByRole('group', { name: '否则(条件不成立)' })).getByRole('button', { name: /执行动作/ }))
      .toHaveTextContent('消息内容:“欢迎回来!”');
    await user.click(action);
    const message = screen.getByDisplayValue('欢迎 {player.name}!');
    await user.clear(message); await user.type(message, '新的欢迎消息');
    expect(action).toHaveTextContent('消息内容:“新的欢迎消息”');
    await user.click(tree.getByRole('button', { name: /^折叠 如果/ }));
    expect(tree.queryByRole('group', { name: '条件成立时' })).not.toBeInTheDocument();
    await user.click(tree.getByRole('button', { name: /^展开 如果/ }));
    expect(tree.getByRole('group', { name: '条件成立时' })).toBeInTheDocument();
  });

  it('keeps switch cases distinct and localizes typed expression and action parameter summaries in English', async () => {
    const branch = v2Statement({ kind: 'SWITCH', name: '', expression: v2Expression({ kind: 'REFERENCE', name: 'var.tier' }),
      cases: [{ nodeId: 'boss-case', match: v2Expression({ literal: 'boss' }), statements: [v2Statement({ name: 'economy_deposit', inputs: {
        currency: v2Expression({ literal: 'coins' }), amount: v2Expression({ kind: 'BINARY', name: 'add', arguments: [
          v2Expression({ kind: 'REFERENCE', name: 'var.reward' }), v2Expression({ valueType: 'integer', literal: 10 }),
        ] }),
      } })] }], elseStatements: [v2Statement()] });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([v2Trigger([branch])]) }), 'en-US');
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    const tree = within(screen.getByRole('region', { name: 'Program tree' }));
    const boss = within(tree.getByRole('group', { name: 'Case “boss”' }));
    expect(boss.getByRole('button', { name: /Action.*economy_deposit/ })).toHaveTextContent('Amount:({var.reward} + 10)');
    expect(boss.getByRole('button', { name: /Action.*economy_deposit/ })).toHaveTextContent('Currency (code or variable):“coins”');
    expect(within(tree.getByRole('group', { name: 'Default (no match)' })).queryByText(/var.reward/)).not.toBeInTheDocument();
  });

  it('copies selected events with native clipboard shortcuts and supports paste, undo, and redo', async () => {
    const api = client({ data: workspace([v2Trigger()]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (value) => ({ trigger: value, workspace: workspace([value]) }));
    const user = userEvent.setup(); renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    const tree = within(screen.getByRole('region', { name: '程序树' }));
    await user.click(tree.getByRole('button', { name: /玩家进入服务器/ }));
    const copied = copyProgramSelection(tree.getByRole('button', { name: /玩家进入服务器/ }));
    expect(JSON.parse(copied)).toMatchObject({ marker: 'xfesm-trigger-node-v2', category: 'event' });
    await user.paste(copied);
    expect(tree.getAllByRole('button', { name: /玩家进入服务器/ })).toHaveLength(2);
    await user.click(tree.getByRole('button', { name: '撤销' }));
    expect(tree.getAllByRole('button', { name: /玩家进入服务器/ })).toHaveLength(1);
    await user.click(tree.getByRole('button', { name: '重做' }));
    expect(tree.getAllByRole('button', { name: /玩家进入服务器/ })).toHaveLength(2);
    await user.click(screen.getByRole('button', { name: '保存' }));
    await waitFor(() => expect(update).toHaveBeenCalledOnce());
    const events = update.mock.calls[0][0].events!;
    expect(events[0].nodeId).not.toBe(events[1].nodeId);
  });

  it('pastes a selected subtree into an explicit branch without intercepting input-field paste', async () => {
    const branch = v2Statement({ kind: 'IF', name: '', expression: v2Expression({ literal: true, valueType: 'bool' }),
      inputs: {}, statements: [v2Statement()] });
    const api = client({ data: workspace([v2Trigger([branch])]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (value) => ({ trigger: value, workspace: workspace([value]) }));
    const user = userEvent.setup(); renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    const tree = within(screen.getByRole('region', { name: '程序树' }));
    await user.click(tree.getByRole('button', { name: /条件分支.*如果/ }));
    const copied = copyProgramSelection(tree.getByRole('button', { name: /条件分支.*如果/ }));
    await user.click(tree.getByRole('button', { name: /否则(条件不成立)\s*0 条/ }));
    await user.paste(copied);
    expect(tree.getAllByRole('button', { name: /条件分支.*如果/ })).toHaveLength(2);
    expect(tree.getAllByRole('button', { name: /执行动作.*欢迎/ })).toHaveLength(2);
    await user.click(screen.getByLabelText('触发器名称'));
    await user.paste(' appended');
    expect(screen.getByLabelText('触发器名称')).toHaveValue('Alpha trigger appended');
    expect(tree.getAllByRole('button', { name: /条件分支.*如果/ })).toHaveLength(2);
    await user.click(screen.getByRole('button', { name: '保存' }));
    await waitFor(() => expect(update).toHaveBeenCalledOnce());
    const statements = update.mock.calls[0][0].statements!;
    expect(statements).toHaveLength(1);
    expect(statements[0].elseStatements[0].kind).toBe('IF');
    expect(statements[0].elseStatements[0].statements[0].nodeId).not.toBe(branch.statements[0].nodeId);
  });

  it('rejects malformed and Owner-only pasted nodes and does not paste in a read-only editor', async () => {
    const api = client({ data: workspace([v2Trigger()]), roles: ['administrator'] });
    const user = userEvent.setup(); const view = renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.paste('{"marker":"xfesm-trigger-node-v2","category":"statement","value":{}}');
    expect(screen.getByText('剪贴板中没有有效的 V2 程序节点。')).toBeInTheDocument();
    const source = serializeTriggerNode({ category: 'statement', value: v2Statement({ name: 'server_command' }) });
    await user.paste(source);
    expect(screen.getByText('无法粘贴:节点包含仅 Owner 可配置的动作。')).toBeInTheDocument();
    expect(within(screen.getByRole('region', { name: '程序树' })).queryByRole('button', { name: /执行动作/ })).not.toBeInTheDocument();
    view.unmount();
    renderTriggers(client({ data: workspace([v2Trigger()]), permissions: ['trigger_read'], roles: ['observer'] }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.paste(serializeTriggerNode({ category: 'statement', value: v2Statement() }));
    expect(within(screen.getByRole('region', { name: '程序树' })).queryByRole('button', { name: /执行动作/ })).not.toBeInTheDocument();
  });

  it('allows inspecting triggers but disables every editor mutation in read-only mode', async () => {
    const user = userEvent.setup();
    renderTriggers(client({ permissions: ['trigger_read'], roles: ['observer'] }));

    expect(await screen.findByText('当前为只读模式')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '新建组' })).toBeDisabled();
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    expect(screen.getByLabelText('触发器名称')).toBeDisabled();
    expect(screen.getByLabelText('事件类型')).toBeDisabled();
    expect(screen.getByLabelText('操作 1 类型')).toBeDisabled();
    expect(screen.getByRole('button', { name: '模块化 UI' })).toBeDisabled();
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    expect(screen.getByRole('button', { name: '删除触发器' })).toBeDisabled();
  });

  it('hides command actions from non-owners and opens an existing command trigger read-only', async () => {
    const command = trigger({ id: 'trigger-command', name: 'Command trigger',
      actions: [{ type: 'condition', parameters: { field: 'player.name', operator: 'not_empty', value: '' },
        children: [{ type: 'server_command', parameters: { command: 'say hello' } }] }] });
    const api = client({ data: workspace([alpha, command]), permissions: ['trigger_read', 'trigger_write'],
      roles: ['administrator'] });
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    const ordinaryActions = screen.getByLabelText('操作 1 类型');
    expect(ordinaryActions.querySelector('option[value="server_command"]')).toBeNull();
    expect(ordinaryActions.querySelector('option[value="player_command"]')).toBeNull();

    await user.click(screen.getByRole('button', { name: /Command trigger/ }));
    expect(await screen.findByRole('alert')).toHaveTextContent('Owner');
    expect(screen.getByLabelText('操作 1.1 类型')).toHaveValue('server_command');
    expect(screen.getByLabelText('操作 1.1 类型')).toBeDisabled();
    expect(screen.getByLabelText('触发器名称')).toBeDisabled();
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
  });

  it('prevents a non-owner from saving command actions entered through code mode', async () => {
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([alpha]), permissions: ['trigger_read', 'trigger_write'],
      roles: ['administrator'] }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.click(screen.getByRole('button', { name: /XFE Script/ }));
    const editor = screen.getByLabelText('XFE Script');
    await user.clear(editor);
    await user.type(editor, 'on player.join\nmatch all\ndo server_command command="say no"');

    expect(editor).toBeEnabled();
    expect(screen.getByRole('alert')).toHaveTextContent('Owner');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
  });

  it('asks before discarding a dirty draft when selecting another trigger', async () => {
    const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true);
    const user = userEvent.setup();
    renderTriggers(client());

    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    const name = screen.getByLabelText('触发器名称');
    await user.clear(name);
    await user.type(name, 'Edited locally');
    await user.click(screen.getByRole('button', { name: /Beta trigger/ }));
    expect(screen.getByLabelText('触发器名称')).toHaveValue('Edited locally');

    await user.click(screen.getByRole('button', { name: /Beta trigger/ }));
    expect(screen.getByLabelText('触发器名称')).toHaveValue('Beta trigger');
    expect(confirm).toHaveBeenCalledTimes(2);
  });

  it('reorders conditions and actions with accessible move controls', async () => {
    const ordered = trigger({
      conditions: [
        { field: 'player.name', operator: 'eq', value: 'Alex' },
        { field: 'player.uuid', operator: 'contains', value: '123' },
      ],
      actions: [
        { type: 'send_player', parameters: { message: 'First' } },
        { type: 'broadcast', parameters: { message: 'Second' } },
      ],
    });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([ordered]) }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    await user.click(screen.getByRole('button', { name: '下移条件 1' }));
    expect(screen.getByLabelText('条件 1 字段')).toHaveValue('player.uuid');
    expect(screen.getByLabelText('条件 2 字段')).toHaveValue('player.name');

    await user.click(screen.getByRole('button', { name: '下移操作 1' }));
    expect(screen.getByLabelText('操作 1 类型')).toHaveValue('broadcast');
    expect(screen.getByLabelText('操作 2 类型')).toHaveValue('send_player');
  });

  it('edits, reorders, validates, and saves nested conditional action trees', async () => {
    const nested = trigger({ actions: [{
      type: 'condition', parameters: { field: 'player.name', operator: 'eq', value: 'Alex' }, children: [
        { type: 'broadcast', parameters: { message: 'First nested action' } },
        { type: 'condition', parameters: { field: 'server.online', operator: 'gte', value: '1' }, children: [
          { type: 'log', parameters: { message: 'Deep action', level: 'info' } },
        ] },
      ],
    }] });
    const api = client({ data: workspace([nested]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    expect(screen.getByLabelText('条件操作 1')).toHaveTextContent('判断通过后才执行内部节点');
    expect(screen.getByRole('button', { name: '在根层添加操作' })).toBeEnabled();
    expect(screen.getByRole('button', { name: '在根层添加条件' })).toBeEnabled();
    expect(screen.getByRole('button', { name: '在条件操作 1 内添加操作' })).toBeEnabled();
    expect(screen.getByRole('button', { name: '在条件操作 1 内添加条件' })).toBeEnabled();
    expect(screen.getByLabelText('操作 1.1 类型')).toHaveValue('broadcast');
    expect(screen.getByLabelText('条件操作 1.2 运算符')).toHaveValue('gte');

    await user.selectOptions(screen.getByLabelText('条件操作 1.2 运算符'), 'exists');
    expect(screen.queryByLabelText('条件操作 1.2 值')).not.toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: '上移条件操作 1.2' }));
    expect(screen.getByLabelText('条件操作 1.1 运算符')).toHaveValue('exists');
    expect(screen.getByLabelText('操作 1.2 类型')).toHaveValue('broadcast');

    await user.click(screen.getByRole('button', { name: '保存' }));
    await waitFor(() => expect(api.validateVisualTrigger).toHaveBeenCalledWith(expect.objectContaining({
      actions: [expect.objectContaining({ type: 'condition', children: [
        expect.objectContaining({ type: 'condition', parameters: expect.objectContaining({ operator: 'exists' }),
          children: [expect.objectContaining({ type: 'log' })] }),
        expect.objectContaining({ type: 'broadcast' }),
      ] })],
    })));
    expect(update).toHaveBeenCalledWith(expect.objectContaining({
      actions: [expect.objectContaining({ children: expect.arrayContaining([
        expect.objectContaining({ type: 'condition' }),
      ]) })],
    }));
  });

  it('rejects empty conditional containers and recursively validates nested rich text and event compatibility', async () => {
    const unsafe = encodeRichText([{
      ...decodeRichText('Unsafe')[0], clickAction: 'open_url', clickValue: 'javascript:alert(1)',
    }]);
    const empty = trigger({ id: 'empty-tree', name: 'Empty tree',
      actions: [{ type: 'condition', parameters: { field: 'player.name', operator: 'eq', value: 'Alex' }, children: [] }] });
    const nestedUnsafe = trigger({ id: 'unsafe-tree', name: 'Unsafe tree', actions: [{
      type: 'condition', parameters: { field: 'player.name', operator: 'eq', value: 'Alex' },
      children: [{ type: 'send_player', parameters: { message: unsafe } }],
    }] });
    const incompatible = trigger({ id: 'incompatible-tree', name: 'Incompatible tree',
      event: { type: 'schedule.interval', configuration: { seconds: '60' } }, actions: [{
        type: 'condition', parameters: { field: 'server.online', operator: 'gte', value: '1' },
        children: [{ type: 'send_player', parameters: { message: 'No player' } }],
      }] });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([empty, nestedUnsafe, incompatible]) }));

    await user.click(await screen.findByRole('button', { name: /Empty tree/ }));
    expect(screen.getByText('该条件至少需要一个内部操作。')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();

    await user.click(screen.getByRole('button', { name: /Unsafe tree/ }));
    expect(screen.getByRole('alert')).toHaveTextContent('http:// 或 https://');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();

    await user.click(screen.getByRole('button', { name: /Incompatible tree/ }));
    expect(screen.getByRole('alert')).toHaveTextContent('当前事件没有玩家上下文');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
  });

  it('allows action trees beyond the former node and nesting limits', async () => {
    const full = trigger({ id: 'full-tree', name: 'Full tree',
      actions: Array.from({ length: 40 }, (_, index) => ({
        type: 'broadcast', parameters: { message: `Action ${index + 1}` },
      })) });
    let deepActions: TriggerDefinition['actions'] = [
      { type: 'broadcast', parameters: { message: 'Deep leaf' } },
    ];
    for (let depth = 0; depth < 12; depth += 1) {
      deepActions = [{ type: 'condition', parameters: { field: 'player.name', operator: 'not_empty', value: '' },
        children: deepActions }];
    }
    const deepestPath = Array.from({ length: 12 }, () => '1').join('.');
    const deep = trigger({ id: 'deep-tree', name: 'Deep tree', actions: deepActions });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([full, deep]) }));

    await user.click(await screen.findByRole('button', { name: /Full tree/ }));
    expect(screen.getByText(/操作树当前有 40 个节点;上限 4096 个节点、32 层嵌套/)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '在根层添加操作' })).toBeEnabled();
    expect(screen.getByRole('button', { name: '在根层添加条件' })).toBeEnabled();
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();

    await user.click(screen.getByRole('button', { name: /Deep tree/ }));
    expect(screen.getByRole('button', { name: `在条件操作 ${deepestPath} 内添加操作` })).toBeEnabled();
    expect(screen.getByRole('button', { name: `在条件操作 ${deepestPath} 内添加条件` })).toBeEnabled();
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
  });

  it('saves event-player and arbitrary-player economy actions', async () => {
    const eventEconomy = trigger({ id: 'event-economy', name: 'Daily reward',
      conditions: [{ field: 'player.dailyDue', operator: 'eq', value: 'true' }],
      actions: [{ type: 'economy_deposit', parameters: {
        currency: 'coins', amount: '500', reason: 'Trigger economy operation',
      } }] });
    const globalEconomy = trigger({ id: 'global-economy', name: 'Global reward',
      event: { type: 'schedule.interval', configuration: { seconds: '900' } },
      actions: [{ type: 'economy_deposit_player', parameters: {
        player: 'Alex', currency: 'coins', amount: '500', reason: 'Scheduled reward',
      } }] });
    const api = client({ data: workspace([eventEconomy, globalEconomy]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: /Daily reward/ }));
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
    await user.click(screen.getByRole('button', { name: '保存' }));
    await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
      actions: [expect.objectContaining({ type: 'economy_deposit', parameters: expect.objectContaining({
        currency: 'coins', amount: '500',
      }) })],
    })));

    await user.click(screen.getByRole('button', { name: /Global reward/ }));
    expect(screen.getByLabelText('操作 1 类型')).toHaveValue('economy_deposit_player');
    expect(screen.getByLabelText('指定玩家(名称、UUID 或变量)')).toHaveValue('Alex');
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
    await user.click(screen.getByRole('button', { name: '保存' }));
    await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
      actions: [expect.objectContaining({ type: 'economy_deposit_player', parameters: expect.objectContaining({
        player: 'Alex', currency: 'coins', amount: '500',
      }) })],
    })));
  });

  it('localizes every condition operator while retaining stable codes and tailored value rules', async () => {
    const api = client({ data: workspace([alpha]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.click(screen.getByRole('button', { name: '添加条件' }));

    const operator = screen.getByLabelText('条件 1 运算符') as HTMLSelectElement;
    expect(Array.from(operator.options).map((option) => [option.value, option.textContent]))
      .toEqual(localizedOperators.map(([code, chinese]) => [code, chinese]));
    expect(Array.from(operator.querySelectorAll('optgroup')).map((group) => group.label)).toEqual([
      '基础比较', '文本与正则', '数值比较', '列表判断', '字段状态',
    ]);
    expect(screen.getByText(/字段来自当前事件的上下文/)).toHaveTextContent('英文逗号');
    expect(document.querySelector('#trigger-fields option[value="player.name"]'))
      .toHaveAttribute('label', expect.stringContaining('玩家名称'));
    expect(document.querySelector('#trigger-fields option[value="server.defaultMessageSender"]'))
      .toHaveAttribute('label', expect.stringContaining('默认消息发送者'));

    await user.selectOptions(operator, 'between');
    expect(screen.getByLabelText('条件 1 值')).toHaveAttribute('placeholder', expect.stringContaining('下限'));
    await user.type(screen.getByLabelText('条件 1 值'), '10, 1');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    await user.clear(screen.getByLabelText('条件 1 值'));
    await user.type(screen.getByLabelText('条件 1 值'), '1, 10');
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();

    await user.selectOptions(operator, 'not_matches');
    expect(screen.getByLabelText('条件 1 值')).toHaveAttribute('placeholder', expect.stringContaining('Java 正则表达式'));
    await user.clear(screen.getByLabelText('条件 1 值'));
    fireEvent.change(screen.getByLabelText('条件 1 值'), { target: { value: '[' } });
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    await user.clear(screen.getByLabelText('条件 1 值'));
    await user.type(screen.getByLabelText('条件 1 值'), '^Alex$');
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();

    for (const code of ['exists', 'not_exists', 'empty', 'not_empty', 'true', 'false']) {
      await user.selectOptions(operator, code);
      expect(screen.queryByLabelText('条件 1 值')).not.toBeInTheDocument();
    }

    await user.selectOptions(operator, 'not_in');
    expect(operator).toHaveValue('not_in');
    expect(screen.getByLabelText('条件 1 值')).toHaveAttribute('placeholder', expect.stringContaining('英文逗号'));
    await user.clear(screen.getByLabelText('条件 1 值'));
    await user.type(screen.getByLabelText('条件 1 值'), 'Alex, Steve');
    await user.click(screen.getByRole('button', { name: '保存' }));

    await waitFor(() => expect(api.validateVisualTrigger).toHaveBeenCalledWith(expect.objectContaining({
      conditions: [expect.objectContaining({ operator: 'not_in', value: 'Alex, Steve' })],
    })));
    expect(update).toHaveBeenCalledWith(expect.objectContaining({
      conditions: [expect.objectContaining({ operator: 'not_in', value: 'Alex, Steve' })],
    }));
  });

  it('uses complete English condition names instead of raw operator codes', async () => {
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([alpha]) }), 'en-US');
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    const conditionStep = screen.getByText('Conditions').closest('section');
    expect(conditionStep).not.toBeNull();
    await user.click(within(conditionStep!).getByRole('button', { name: 'Add' }));

    const operator = screen.getByLabelText('Condition 1 operator') as HTMLSelectElement;
    expect(Array.from(operator.options).map((option) => [option.value, option.textContent]))
      .toEqual(localizedOperators.map(([code, , english]) => [code, english]));
    expect(Array.from(operator.querySelectorAll('optgroup')).map((group) => group.label)).toEqual([
      'Basic comparisons', 'Text and regular expressions', 'Numeric comparisons', 'List membership',
      'Field state',
    ]);
  });

  it('uses the server catalog for event-scoped condition fields and warns without blocking extensions', async () => {
    const mismatched = trigger({ conditions: [
      { field: 'chat.message', operator: 'neq', value: 'hidden' },
    ] });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([mismatched]) }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    expect(document.querySelector('#trigger-fields option[value="server.time"]')).toBeInTheDocument();
    expect(document.querySelector('#trigger-fields option[value="chat.message"]')).not.toBeInTheDocument();
    expect(document.querySelector('#trigger-fields option[value="server.time:uuuu-MM-dd HH:mm:ss"]'))
      .not.toBeInTheDocument();
    expect(screen.getByLabelText('条件 1 字段')).toHaveAttribute('aria-invalid', 'true');
    expect(screen.getByText(/为兼容旧触发器,该负向判断仍可能成立/)).toHaveTextContent('字段存在');
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();

    fireEvent.change(screen.getByLabelText('条件 1 字段'), {
      target: { value: 'server.time:uuuu/MM/dd HH:mm' },
    });
    expect(screen.getByText(/这是消息模板变量,不能作为条件字段/)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();

    await user.selectOptions(screen.getByLabelText('事件类型'), 'player.item_pickup');
    expect(document.querySelector('#trigger-fields option[value="item.id"]')).toBeInTheDocument();
    await user.clear(screen.getByLabelText('条件 1 字段'));
    await user.type(screen.getByLabelText('条件 1 字段'), 'item.id');
    expect(screen.getByLabelText('条件 1 字段')).not.toHaveAttribute('aria-invalid');

    await user.clear(screen.getByLabelText('条件 1 字段'));
    await user.type(screen.getByLabelText('条件 1 字段'), 'mod.exampleValue');
    expect(screen.getByLabelText('条件 1 字段')).not.toHaveAttribute('aria-invalid');
    expect(screen.queryByText(/为兼容旧触发器/)).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
  });

  it('splits legacy interval seconds and recombines hour, minute, and second inputs', async () => {
    const interval = trigger({
      event: { type: 'schedule.interval', configuration: { seconds: '3661' } },
      actions: [{ type: 'broadcast', parameters: { message: 'Tick' } }],
    });
    const api = client({ data: workspace([interval]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    expect(screen.getByLabelText('间隔小时')).toHaveValue(1);
    expect(screen.getByLabelText('间隔分钟')).toHaveValue(1);
    expect(screen.getByLabelText('间隔秒')).toHaveValue(1);
    expect(screen.getByLabelText('间隔分钟')).toHaveAttribute('max', '59');
    expect(screen.getByLabelText('间隔秒')).toHaveAttribute('max', '59');
    expect(screen.getByText(/按服务器本地时间边界对齐/)).toHaveTextContent('12:58');

    fireEvent.change(screen.getByLabelText('间隔小时'), { target: { value: '0' } });
    fireEvent.change(screen.getByLabelText('间隔分钟'), { target: { value: '15' } });
    fireEvent.change(screen.getByLabelText('间隔秒'), { target: { value: '0' } });
    await user.click(screen.getByRole('button', { name: '保存' }));

    await waitFor(() => expect(api.validateVisualTrigger).toHaveBeenCalledWith(expect.objectContaining({
      event: { type: 'schedule.interval', configuration: { seconds: '900' } },
    })));
    expect(update).toHaveBeenCalledWith(expect.objectContaining({
      event: { type: 'schedule.interval', configuration: { seconds: '900' } },
    }));
  });

  it('edits and saves a namespaced crash-protection trigger with a cooldown', async () => {
    const protection = trigger({
      event: { type: 'protection.mod_entity_overflow', configuration: {
        threshold: '200', namespace: 'create', cooldownSeconds: '60',
      } },
      actions: [{ type: 'broadcast', parameters: { message: 'Create entities: {protection.count}' } }],
    });
    const api = client({ data: workspace([protection]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    expect(screen.getByLabelText('防护事件阈值')).toHaveValue(200);
    expect(screen.getByLabelText('实体模组命名空间')).toHaveValue('create');
    expect(screen.getByLabelText('防护重复触发冷却')).toHaveValue(60);
    fireEvent.change(screen.getByLabelText('防护事件阈值'), { target: { value: '350' } });
    fireEvent.change(screen.getByLabelText('实体模组命名空间'), { target: { value: 'Create_Addition' } });
    fireEvent.change(screen.getByLabelText('防护重复触发冷却'), { target: { value: '120' } });
    await user.click(screen.getByRole('button', { name: '保存' }));

    const expectedEvent = { type: 'protection.mod_entity_overflow', configuration: {
      threshold: '350', namespace: 'create_addition', cooldownSeconds: '120',
    } };
    await waitFor(() => expect(api.validateVisualTrigger).toHaveBeenCalledWith(expect.objectContaining({
      event: expectedEvent,
    })));
    expect(update).toHaveBeenCalledWith(expect.objectContaining({ event: expectedEvent }));
  });

  it('configures a custom player command and exposes named arguments as fields and message variables', async () => {
    const api = client({ data: workspace([alpha]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    const eventType = screen.getByLabelText('事件类型');
    expect(within(eventType).getByRole('option', { name: '玩家输入自定义指令' })).toHaveValue('player.command_trigger');
    await user.selectOptions(eventType, 'player.command_trigger');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();

    const command = screen.getByLabelText('自定义指令根');
    await user.type(command, '/Bad');
    expect(command).toHaveAttribute('aria-invalid', 'true');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    await user.clear(command);
    await user.type(command, 'welcome');
    await user.click(screen.getByRole('button', { name: '添加参数' }));
    await user.click(screen.getByRole('button', { name: '添加下级参数' }));
    const firstArgument = screen.getByLabelText('指令参数 1');
    const secondArgument = screen.getByLabelText('指令参数 2');
    await user.clear(firstArgument);
    await user.type(firstArgument, 'player');
    await user.clear(secondArgument);
    await user.type(secondArgument, 'message');

    expect(screen.getByText('/welcome <player> <message>')).toBeInTheDocument();
    expect(document.querySelector('#trigger-fields option[value="command.name"]'))
      .toHaveAttribute('label', expect.stringContaining('自定义指令名称'));
    expect(document.querySelector('#trigger-fields option[value="command.raw"]'))
      .toHaveAttribute('label', expect.stringContaining('自定义指令原始输入'));
    expect(document.querySelector('#trigger-fields option[value="args.player"]'))
      .toHaveAttribute('label', expect.stringContaining('指令参数 player'));
    await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
    const variableDialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
    expect(within(variableDialog).getByRole('button', { name: /\{args.message\}/ })).toBeInTheDocument();
    await user.click(within(variableDialog).getByRole('button', { name: /\{args.player\}/ }));
    expect(screen.getByRole('textbox', { name: '富文本内容 / Rich text content' }))
      .toHaveTextContent('Hello{args.player}');

    await user.clear(secondArgument);
    await user.type(secondArgument, 'player');
    expect(secondArgument).toHaveAttribute('aria-invalid', 'true');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    await user.clear(secondArgument);
    await user.type(secondArgument, 'message');
    await user.click(screen.getByRole('button', { name: '保存' }));

    const expectedEvent = {
      type: 'player.command_trigger',
      configuration: { command: 'welcome' },
      arguments: [{
        name: 'player', type: 'word', literal: '', optional: false,
        errorMessage: '', minimum: '', maximum: '', suggestions: [],
        children: [{
          name: 'message', type: 'word', literal: '', optional: false,
          errorMessage: '', minimum: '', maximum: '', suggestions: [], children: [],
        }],
      }],
      variables: [],
    };
    await waitFor(() => expect(api.validateVisualTrigger).toHaveBeenCalledWith(expect.objectContaining({
      event: expectedEvent,
    })));
    expect(update).toHaveBeenCalledWith(expect.objectContaining({ event: expectedEvent }));
  });

  it('adds root command arguments as sibling alternatives instead of silently nesting them', async () => {
    const api = client({ data: workspace([alpha]) });
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.selectOptions(screen.getByLabelText('事件类型'), 'player.command_trigger');

    await user.click(screen.getByRole('button', { name: '添加参数' }));
    await user.click(screen.getByRole('button', { name: '添加参数' }));

    expect(screen.getByRole('article', { name: '参数 1' })).toBeInTheDocument();
    expect(screen.getByRole('article', { name: '参数 2' })).toBeInTheDocument();
    expect(screen.queryByRole('article', { name: '参数 1.1' })).not.toBeInTheDocument();
    expect(screen.getByText('/command <arg> | <arg2>')).toBeInTheDocument();
  });

  it('copies and pastes argument subtrees and action modules without flattening them', async () => {
    const api = client({ data: workspace([alpha]) });
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.selectOptions(screen.getByLabelText('事件类型'), 'player.command_trigger');
    await user.click(screen.getByRole('button', { name: '添加参数' }));
    await user.click(screen.getByRole('button', { name: '添加下级参数' }));

    await user.click(screen.getByRole('button', { name: '复制参数 1' }));
    await user.click(screen.getByRole('button', { name: '粘贴根参数' }));
    expect(await screen.findByRole('article', { name: '参数 2' })).toBeInTheDocument();
    expect(screen.getByRole('article', { name: '参数 2.1' })).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: '复制操作 1' }));
    await user.click(screen.getByRole('button', { name: '粘贴操作到根层' }));
    expect(await screen.findByRole('article', { name: '操作 2' })).toBeInTheDocument();
  });

  it('separates global and trigger variables and edits nested generic types', async () => {
    const api = client({ data: workspace([alpha]) });
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    const globalGroup = screen.getByText('全局变量').closest('section');
    expect(globalGroup).not.toBeNull();
    await user.click(within(globalGroup!).getByRole('button', { name: '添加变量' }));
    const rootType = within(globalGroup!).getByLabelText('泛型第 1 层类型');
    await user.selectOptions(rootType, 'array');
    await user.selectOptions(within(globalGroup!).getByLabelText('泛型第 2 层类型'), 'dictionary');

    expect(within(globalGroup!).getAllByLabelText('泛型第 3 层类型')).toHaveLength(2);
    expect(within(globalGroup!).getByText('{global.value}')).toBeInTheDocument();
    expect(within(globalGroup!).getByRole('combobox', { name: '存储位置' })).toHaveValue('server');
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
  });

  it('keeps typed commas and stores each command suggestion as a separate value', async () => {
    const api = client({ data: workspace([alpha]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.selectOptions(screen.getByLabelText('事件类型'), 'player.command_trigger');
    await user.type(screen.getByLabelText('自定义指令根'), 'suggest');
    await user.click(screen.getByRole('button', { name: '添加参数' }));

    const suggestions = screen.getByLabelText('候选值(中英文逗号分隔)');
    await user.type(suggestions, 'alpha,beta,gamma');
    expect(suggestions).toHaveValue('alpha,beta,gamma');
    fireEvent.blur(suggestions);
    expect(suggestions).toHaveValue('alpha, beta, gamma');
    const save = screen.getByRole('button', { name: '保存' });
    expect(save).toBeEnabled();
    await user.click(save);

    await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({
      event: expect.objectContaining({ arguments: [expect.objectContaining({
        suggestions: ['alpha', 'beta', 'gamma'],
      })] }),
    })));
  });

  it('inserts command arguments at plain-action selections and accepts variables in numeric parameters', async () => {
    const commandAction = trigger({
      event: { type: 'player.command_trigger', configuration: { command: 'tools', arguments: 'target amount' } },
      actions: [{ type: 'server_command', parameters: { command: 'say  done' } }],
    });
    const api = client({ data: workspace([commandAction]) });
    vi.mocked(api.triggerCatalog).mockResolvedValue({
      ...catalog,
      actions: [...catalog.actions, 'teleport', 'title'],
      actionParameters: {
        ...catalog.actionParameters,
        teleport: ['destination'],
        title: ['title', 'subtitle', 'numberPrecision', 'fadeIn', 'stay', 'fadeOut'],
      },
    });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    const commandInput = screen.getByLabelText('command') as HTMLInputElement;
    commandInput.setSelectionRange(4, 4);
    fireEvent.select(commandInput);
    await user.click(screen.getByRole('button', { name: '为 command 插入变量 / Insert variable into command' }));
    await user.type(screen.getByRole('searchbox', { name: '搜索变量 / Search variables' }), 'args.target');
    await user.click(screen.getByRole('button', { name: /插入变量 \{args\.target\}/ }));
    expect(commandInput).toHaveValue('say {args.target} done');
    await waitFor(() => expect(commandInput).toHaveFocus());

    await user.selectOptions(screen.getByLabelText('操作 1 类型'), 'teleport');
    const destination = screen.getByLabelText('destination') as HTMLInputElement;
    destination.setSelectionRange(2, 4);
    fireEvent.select(destination);
    await user.click(screen.getByRole('button', { name: '为 destination 插入变量 / Insert variable into destination' }));
    await user.type(screen.getByRole('searchbox', { name: '搜索变量 / Search variables' }), 'args.target');
    await user.click(screen.getByRole('button', { name: /插入变量 \{args\.target\}/ }));
    expect(destination).toHaveValue('0 {args.target} 0');

    await user.selectOptions(screen.getByLabelText('操作 1 类型'), 'title');
    const precision = screen.getByLabelText('numberPrecision');
    expect(precision).toHaveValue('2');
    await user.clear(precision);
    await user.type(precision, '4');
    const fadeIn = screen.getByLabelText('fadeIn') as HTMLInputElement;
    expect(fadeIn).not.toHaveAttribute('type', 'number');
    expect(fadeIn).toHaveAttribute('inputmode', 'numeric');
    fadeIn.setSelectionRange(0, fadeIn.value.length);
    fireEvent.select(fadeIn);
    await user.click(screen.getByRole('button', { name: '为 fadeIn 插入变量 / Insert variable into fadeIn' }));
    await user.type(screen.getByRole('searchbox', { name: '搜索变量 / Search variables' }), 'args.amount');
    await user.click(screen.getByRole('button', { name: /插入变量 \{args\.amount\}/ }));
    expect(fadeIn).toHaveValue('{args.amount}');
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();

    await user.click(screen.getByRole('button', { name: '保存' }));
    await waitFor(() => expect(api.validateVisualTrigger).toHaveBeenCalledWith(expect.objectContaining({
      actions: [expect.objectContaining({
        type: 'title', parameters: expect.objectContaining({
          numberPrecision: '4', fadeIn: '{args.amount}',
        }),
      })],
    })));
    expect(update).toHaveBeenCalledOnce();
  });

  it('inserts declared variables into choice-backed wait fields and exposes every dynamic-mode field', async () => {
    const waiting = trigger({
      event: { type: 'player.join', configuration: {}, variables: [
        { name: 'mode', type: 'string', initialValue: 'duration' },
      ] },
    });
    const api = client({ data: workspace([waiting]) });
    vi.mocked(api.triggerCatalog).mockResolvedValue({
      ...catalog,
      actions: [...catalog.actions, 'wait'],
      actionParameters: { ...catalog.actionParameters,
        wait: ['mode', 'value', 'timeout', 'pollTicks', 'field', 'operator', 'expected'] },
      triggerVariableTypes: ['bool', 'integer', 'float', 'string', 'coordinate', 'uuid', 'player',
        'resource_location', 'block_state', 'item_stack', 'component', 'nbt', 'list'],
    });
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.selectOptions(screen.getByLabelText('操作 1 类型'), 'wait');

    const mode = screen.getByLabelText('mode') as HTMLInputElement;
    mode.setSelectionRange(0, mode.value.length);
    fireEvent.select(mode);
    await user.click(screen.getByRole('button', { name: '为 mode 插入变量 / Insert variable into mode' }));
    await user.type(screen.getByRole('searchbox', { name: '搜索变量 / Search variables' }), 'var.mode');
    await user.click(screen.getByRole('button', { name: /插入变量 \{var\.mode\}/ }));

    expect(mode).toHaveValue('{var.mode}');
    expect(screen.getByLabelText('value')).toBeInTheDocument();
    expect(screen.getByLabelText('field')).toBeInTheDocument();
    expect(screen.getByLabelText('operator')).toBeInTheDocument();
    expect(screen.getByLabelText('expected')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
  });

  it('converts player messaging for leave events and blocks incompatible online actions', async () => {
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([alpha]) }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    await user.selectOptions(screen.getByLabelText('事件类型'), 'player.leave');
    expect(screen.getByLabelText('操作 1 类型')).toHaveValue('broadcast');
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();

    await user.selectOptions(screen.getByLabelText('操作 1 类型'), 'kick');
    expect(screen.getByRole('alert')).toHaveTextContent('玩家离服后');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
  });

  it('blocks saving a visual trigger with an unsafe rich-text click target', async () => {
    const unsafe = encodeRichText([{
      ...decodeRichText('Rules')[0], clickAction: 'open_url', clickValue: 'javascript:alert(1)',
    }]);
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([trigger({
      actions: [{ type: 'send_player', parameters: { message: unsafe } }],
    })]) }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    expect(screen.getByRole('alert')).toHaveTextContent('http:// 或 https://');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
  });

  it('rejects invalid formatted-time variables in plain and rich action parameters', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true);
    const plainInvalid = trigger({
      id: 'trigger-plain-time', name: 'Bad plain time',
      actions: [{ type: 'title', parameters: {
        title: 'Clock', subtitle: '', fadeIn: '{server.time:YYYY-MM-dd}', stay: '70', fadeOut: '20',
      } }],
    });
    const richInvalid = trigger({
      id: 'trigger-rich-time', name: 'Bad rich time',
      actions: [{ type: 'broadcast', parameters: {
        message: encodeRichText([{ ...decodeRichText('')[0], text: 'At {event.time:uuuu-MM-DD}' }]),
      } }],
    });
    const missingBrace = trigger({
      id: 'trigger-missing-time', name: 'Missing time brace',
      actions: [{ type: 'broadcast', parameters: {
        message: encodeRichText([{ ...decodeRichText('')[0], text: 'At {server.time:uuuu-MM-dd' }]),
      } }],
    });
    const api = client({ data: workspace([plainInvalid, richInvalid, missingBrace]) });
    vi.mocked(api.triggerCatalog).mockResolvedValue({
      ...catalog,
      actions: [...catalog.actions, 'title'],
      actionParameters: {
        ...catalog.actionParameters,
        title: ['title', 'subtitle', 'fadeIn', 'stay', 'fadeOut'],
      },
    });
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: /Bad plain time/ }));
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    fireEvent.change(screen.getByLabelText('fadeIn'), { target: { value: '{server.time:aa}' } });
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    fireEvent.change(screen.getByLabelText('fadeIn'), { target: { value: '{server.time:uuuu-MM-dd}' } });
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();

    await user.click(screen.getByRole('button', { name: /Bad rich time/ }));
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    await user.click(screen.getByRole('button', { name: /Missing time brace/ }));
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
  });

  it('validates required action parameters and numeric ranges before saving', async () => {
    const api = client({ data: workspace([alpha]) });
    vi.mocked(api.triggerCatalog).mockResolvedValue({
      ...catalog,
      actions: [...catalog.actions, 'sound'],
      actionParameters: { ...catalog.actionParameters, sound: ['sound', 'volume', 'pitch'] },
    });
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    const actionType = screen.getByLabelText('操作 1 类型');
    const save = screen.getByRole('button', { name: '保存' });
    for (const type of ['broadcast', 'kick', 'server_command', 'player_command', 'log', 'send_player']) {
      await user.selectOptions(actionType, type);
      expect(save).toBeDisabled();
    }

    await user.selectOptions(actionType, 'server_command');
    expect(screen.getByLabelText('显示命令反馈')).toHaveValue('false');
    fireEvent.change(screen.getByLabelText('command'), { target: { value: 'say hello' } });
    expect(save).toBeEnabled();
    fireEvent.change(screen.getByLabelText('显示命令反馈'), { target: { value: 'yes' } });
    expect(save).toBeDisabled();
    fireEvent.change(screen.getByLabelText('显示命令反馈'), { target: { value: 'true' } });
    expect(save).toBeEnabled();

    await user.selectOptions(actionType, 'sound');
    expect(save).toBeEnabled();
    fireEvent.change(screen.getByLabelText('volume'), { target: { value: '1001' } });
    expect(save).toBeDisabled();
    fireEvent.change(screen.getByLabelText('volume'), { target: { value: '{player.name:unsupported-format}' } });
    expect(save).toBeDisabled();
    fireEvent.change(screen.getByLabelText('volume'), { target: { value: '2' } });
    expect(save).toBeEnabled();
    fireEvent.change(screen.getByLabelText('sound'), { target: { value: 'not a resource?' } });
    expect(save).toBeDisabled();
  });

  it('blocks unknown events, invalid time zones, and unsafe regular expressions in visual mode', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true);
    const unknownEvent = trigger({ id: 'trigger-unknown', name: 'Unknown event',
      event: { type: 'server.not_real', configuration: {} },
      actions: [{ type: 'broadcast', parameters: { message: 'Notice' } }] });
    const invalidZone = trigger({ id: 'trigger-zone', name: 'Invalid zone',
      event: { type: 'schedule.daily', configuration: { time: '08:00', timezone: 'Not/AZone' } },
      actions: [{ type: 'broadcast', parameters: { message: 'Notice' } }] });
    const invalidRegex = trigger({ id: 'trigger-regex', name: 'Invalid regex',
      conditions: [{ field: 'player.name', operator: 'matches', value: '[' }] });
    const api = client({ data: workspace([unknownEvent, invalidZone, invalidRegex]) });
    const visualValidation = vi.mocked(api.validateVisualTrigger);
    visualValidation.mockImplementation(async (program) => {
      if (program.event.configuration.timezone === 'Not/AZone') throw new Error('invalid Java time zone');
      return { valid: true, ...program };
    });
    const update = vi.spyOn(api, 'updateTrigger');
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: /Unknown event/ }));
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();

    await user.click(screen.getByRole('button', { name: /Invalid zone/ }));
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
    await user.click(screen.getByRole('button', { name: '保存' }));
    expect(await screen.findByText('invalid Java time zone')).toBeInTheDocument();
    expect(update).not.toHaveBeenCalled();
    fireEvent.change(screen.getByLabelText('时区'), { target: { value: 'UTC' } });
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();

    await user.click(screen.getByRole('button', { name: /Invalid regex/ }));
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
    fireEvent.change(screen.getByLabelText('条件 1 值'), { target: { value: '^Alex$' } });
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
  });

  it('preflights the exact visual program without rejecting Java Unicode properties', async () => {
    const unicode = trigger({ conditions: [{ field: 'player.name', operator: 'matches', value: '\\p{L}+' }] });
    const api = client({ data: workspace([unicode]) });
    const validate = vi.mocked(api.validateVisualTrigger);
    const update = vi.spyOn(api, 'updateTrigger').mockResolvedValue({ trigger: unicode, workspace: workspace([unicode]) });
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
    await user.click(screen.getByRole('button', { name: '保存' }));
    await waitFor(() => expect(validate).toHaveBeenCalledWith({
      event: unicode.event, conditionMode: unicode.conditionMode,
      conditions: unicode.conditions, actions: unicode.actions,
    }));
    expect(update).toHaveBeenCalledOnce();
    expect(api.validateTriggerScript).not.toHaveBeenCalled();
  });

  it('shows the quick-save hint and deduplicates Ctrl+S visual saves while preventing browser save', async () => {
    const api = client({ data: workspace([alpha]) });
    const validation = deferred<TriggerValidation>();
    const validate = vi.mocked(api.validateVisualTrigger).mockReturnValue(validation.promise);
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    expect(screen.getByLabelText('快捷保存:Control 或 Command 加 S')).toHaveTextContent('Ctrl/⌘+S');
    const first = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true });
    const repeated = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true });
    act(() => {
      window.dispatchEvent(first);
      window.dispatchEvent(repeated);
    });

    expect(first.defaultPrevented).toBe(true);
    expect(repeated.defaultPrevented).toBe(true);
    await waitFor(() => expect(validate).toHaveBeenCalledOnce());
    expect(update).not.toHaveBeenCalled();
    await act(async () => {
      validation.resolve({ valid: true, event: alpha.event, conditionMode: alpha.conditionMode,
        conditions: alpha.conditions, actions: alpha.actions });
      await validation.promise;
    });
    await waitFor(() => expect(update).toHaveBeenCalledOnce());
  });

  it('uses Command+S for code-mode saves and prevents the browser shortcut', async () => {
    const script = 'on player.join\nmatch all\ndo broadcast message="Hello"';
    const codeTrigger = trigger({ id: 'trigger-code-shortcut', name: 'Code shortcut', mode: 'code', script,
      actions: [{ type: 'broadcast', parameters: { message: 'Hello' } }] });
    const api = client({ data: workspace([codeTrigger]) });
    const update = vi.spyOn(api, 'updateTrigger').mockImplementation(async (change) => ({
      trigger: change, workspace: workspace([change]),
    }));
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Code shortcut/ }));

    const event = new KeyboardEvent('keydown', { key: 'S', metaKey: true, cancelable: true });
    act(() => { window.dispatchEvent(event); });

    expect(event.defaultPrevented).toBe(true);
    await waitFor(() => expect(api.validateTriggerScript).toHaveBeenCalledWith(script));
    expect(api.validateVisualTrigger).not.toHaveBeenCalled();
    await waitFor(() => expect(update).toHaveBeenCalledOnce());
  });

  it('does not consume the save shortcut or save a group when no trigger draft is open', async () => {
    const api = client({ data: workspace([alpha]) });
    const updateTrigger = vi.spyOn(api, 'updateTrigger');
    const updateGroup = vi.spyOn(api, 'updateTriggerGroup');
    renderTriggers(api);
    await screen.findByText('选择或创建触发器');

    const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true });
    act(() => { window.dispatchEvent(event); });

    expect(event.defaultPrevented).toBe(false);
    expect(api.validateVisualTrigger).not.toHaveBeenCalled();
    expect(api.validateTriggerScript).not.toHaveBeenCalled();
    expect(updateTrigger).not.toHaveBeenCalled();
    expect(updateGroup).not.toHaveBeenCalled();
  });

  it('requires edited code to pass server compilation and preflights keyboard saves', async () => {
    const script = 'on player.join\nmatch all\ndo broadcast message="Hello"';
    const codeTrigger = trigger({ id: 'trigger-code', name: 'Code trigger', mode: 'code', script,
      actions: [{ type: 'broadcast', parameters: { message: 'Hello' } }] });
    const api = client({ data: workspace([codeTrigger]) });
    const validate = vi.spyOn(api, 'validateTriggerScript').mockRejectedValueOnce(new Error('invalid script'));
    const update = vi.spyOn(api, 'updateTrigger');
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Code trigger/ }));

    const editor = screen.getByLabelText('XFE Script');
    expect(screen.getByRole('button', { name: '保存' })).toBeEnabled();
    await user.clear(editor);
    await user.type(editor, 'not valid');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();

    fireEvent.keyDown(window, { key: 's', ctrlKey: true });
    await waitFor(() => expect(validate).toHaveBeenCalledWith('not valid'));
    expect(update).not.toHaveBeenCalled();
    await waitFor(() => expect(editor).toBeEnabled());

    validate.mockResolvedValueOnce({ valid: true, event: { type: 'player.join', configuration: {} },
      conditionMode: 'all', conditions: [], actions: [{ type: 'broadcast', parameters: { message: 'Hello' } }] });
    await user.clear(editor);
    await user.type(editor, script);
    await user.click(screen.getByRole('button', { name: '校验' }));
    await waitFor(() => expect(screen.getByRole('button', { name: '保存' })).toBeEnabled());
  });

  it('deduplicates saves and prevents selection changes while a mutation is running', async () => {
    const api = client();
    const pending = deferred<{ trigger: TriggerDefinition; workspace: TriggerWorkspace }>();
    const update = vi.spyOn(api, 'updateTrigger').mockReturnValue(pending.promise);
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));

    const save = screen.getByRole('button', { name: '保存' });
    fireEvent.click(save);
    fireEvent.click(save);
    await waitFor(() => expect(update).toHaveBeenCalledOnce());
    expect(screen.getByRole('button', { name: /Beta trigger/ })).toBeDisabled();
    fireEvent.click(screen.getByRole('button', { name: /Beta trigger/ }));
    expect(screen.getByLabelText('触发器名称')).toHaveValue('Alpha trigger');

    const saved = trigger({ revision: 1 });
    await act(async () => {
      pending.resolve({ trigger: saved, workspace: workspace([saved, beta]) });
      await pending.promise;
    });
    await waitFor(() => expect(screen.getByRole('button', { name: /Beta trigger/ })).toBeEnabled());
    expect(update).toHaveBeenCalledOnce();
  });

  it('absorbs a parent revision caused by saving a child without losing dirty group fields', async () => {
    let current = workspace([alpha, beta]);
    const api = client({ data: current });
    vi.mocked(api.triggerWorkspace).mockImplementation(async () => current);
    const savedTrigger = trigger({ revision: 1 });
    vi.spyOn(api, 'updateTrigger').mockImplementation(async () => {
      current = {
        groups: [{ ...current.groups[0], revision: 1, updatedAt: '2026-01-02T00:00:00Z',
          triggers: [savedTrigger, beta] }],
        totalTriggers: 2,
      };
      return { trigger: savedTrigger, workspace: current };
    });
    const updateGroup = vi.spyOn(api, 'updateTriggerGroup').mockImplementation(async (change) => ({
      groups: [{ ...current.groups[0], ...change, revision: 2 }], totalTriggers: 2,
    }));
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: '组设置' }));
    const groupDescription = await screen.findByLabelText('说明');
    await user.type(groupDescription, 'Local group edit');
    await user.click(screen.getByRole('button', { name: /Alpha trigger/ }));
    await user.click(screen.getByRole('button', { name: '保存' }));
    await waitFor(() => expect(api.triggerWorkspace).toHaveBeenCalledTimes(2));
    await user.click(screen.getByRole('button', { name: '保存组' }));

    await waitFor(() => expect(updateGroup).toHaveBeenCalledOnce());
    expect(updateGroup.mock.calls[0][0]).toMatchObject({
      id: 'group-main', description: 'Local group edit', revision: 1,
    });
  });

  it('keeps the old group revision when editable fields really changed remotely', async () => {
    let current = workspace([alpha]);
    let emit: ((event: StreamEvent) => void) | undefined;
    const api = client({ data: current });
    vi.mocked(api.triggerWorkspace).mockImplementation(async () => current);
    vi.mocked(api.subscribe).mockImplementation((listener) => { emit = listener; return () => undefined; });
    const updateGroup = vi.spyOn(api, 'updateTriggerGroup').mockRejectedValue(new Error('conflict'));
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: '组设置' }));
    await user.type(await screen.findByLabelText('说明'), 'Local edit');
    await waitFor(() => expect(emit).toBeDefined());
    current = { groups: [{ ...current.groups[0], description: 'Remote edit', revision: 1 }], totalTriggers: 1 };
    act(() => emit?.({ type: 'triggers-changed', data: {} }));
    await waitFor(() => expect(api.triggerWorkspace).toHaveBeenCalledTimes(2));
    await user.click(screen.getByRole('button', { name: '保存组' }));

    await waitFor(() => expect(updateGroup).toHaveBeenCalledOnce());
    expect(updateGroup.mock.calls[0][0]).toMatchObject({ description: 'Local edit', revision: 0 });
    expect(await screen.findByText('conflict')).toBeInTheDocument();
  });

  it('escapes carriage returns when visual actions are serialized to XFE Script', async () => {
    const withCarriageReturn = trigger({
      actions: [{ type: 'send_player', parameters: { message: 'line one\rline two' } }],
    });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([withCarriageReturn]) }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.click(screen.getByRole('button', { name: /XFE Script/ }));

    expect((screen.getByLabelText('XFE Script') as HTMLTextAreaElement).value)
      .toContain('message="line one\\rline two"');
  });

  it('serializes field-state operators without a value and keeps inclusive ranges', async () => {
    const withConditions = trigger({
      conditions: [
        { field: 'chat.message', operator: 'empty', value: 'ignored legacy value' },
        { field: 'server.online', operator: 'between', value: '1,10' },
      ],
    });
    const user = userEvent.setup();
    renderTriggers(client({ data: workspace([withConditions]) }));
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.click(screen.getByRole('button', { name: /XFE Script/ }));

    const script = (screen.getByLabelText('XFE Script') as HTMLTextAreaElement).value;
    expect(script).toContain('when chat.message empty\n');
    expect(script).not.toContain('empty "ignored legacy value"');
    expect(script).toContain('when server.online between "1,10"');
  });

  it('round-trips recursive action conditions through XFE Script with if/end blocks', async () => {
    const nestedActions = [{
      type: 'condition', parameters: { field: 'chat.message', operator: 'empty', value: 'ignored' }, children: [{
        type: 'condition', parameters: { field: 'server.online', operator: 'between', value: '1,10' }, children: [
          { type: 'broadcast', parameters: { message: 'Nested hello' } },
        ],
      }],
    }];
    const nested = trigger({ actions: nestedActions });
    const api = client({ data: workspace([nested]) });
    vi.mocked(api.validateTriggerScript).mockResolvedValue({
      valid: true, event: nested.event, conditionMode: nested.conditionMode,
      conditions: nested.conditions, actions: nestedActions,
    });
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await user.click(screen.getByRole('button', { name: /XFE Script/ }));

    const script = (screen.getByLabelText('XFE Script') as HTMLTextAreaElement).value;
    expect(script).toContain('if chat.message empty\n');
    expect(script).not.toContain('empty "ignored"');
    expect(script).toContain('  if server.online between "1,10"\n    do broadcast message="Nested hello"\n  end\nend');

    await user.click(screen.getByRole('button', { name: '模块化 UI' }));
    expect(await screen.findByLabelText('条件操作 1.1')).toBeInTheDocument();
    expect(screen.getByLabelText('操作 1.1.1 类型')).toHaveValue('broadcast');
    expect(api.validateTriggerScript).toHaveBeenCalledWith(script);
  });

  it('keeps a dirty externally deleted draft as a copy and ignores a late compile result', async () => {
    let current = workspace([alpha, beta]);
    let emit: ((event: StreamEvent) => void) | undefined;
    const api = client({ data: current });
    vi.mocked(api.triggerWorkspace).mockImplementation(async () => current);
    vi.mocked(api.subscribe).mockImplementation((listener) => {
      emit = listener;
      return () => undefined;
    });
    const pending = deferred<TriggerValidation>();
    vi.spyOn(api, 'validateTriggerScript').mockReturnValue(pending.promise);
    const user = userEvent.setup();
    renderTriggers(api);
    await user.click(await screen.findByRole('button', { name: /Alpha trigger/ }));
    await waitFor(() => expect(emit).toBeDefined());
    await user.click(screen.getByRole('button', { name: /XFE Script/ }));
    await user.click(screen.getByRole('button', { name: '模块化 UI' }));

    await waitFor(() => expect(api.validateTriggerScript).toHaveBeenCalledOnce());
    current = workspace([beta]);
    act(() => emit?.({ type: 'triggers-changed', data: {} }));
    expect(await screen.findByText('远端触发器已删除;本地修改已保留为未保存副本。')).toBeInTheDocument();
    expect(screen.getByLabelText('触发器名称')).toHaveValue('Alpha trigger');

    await act(async () => {
      pending.resolve({ valid: true, event: { type: 'player.join', configuration: {} },
        conditionMode: 'all', conditions: [], actions: [{ type: 'broadcast', parameters: { message: 'late' } }] });
      await pending.promise;
    });
    expect(screen.getByLabelText('触发器名称')).toHaveValue('Alpha trigger');
    expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
  });

  it('preserves a dirty externally deleted group and lets it be saved as a new group', async () => {
    let current = workspace([alpha]);
    let emit: ((event: StreamEvent) => void) | undefined;
    const api = client({ data: current });
    vi.mocked(api.triggerWorkspace).mockImplementation(async () => current);
    vi.mocked(api.subscribe).mockImplementation((listener) => {
      emit = listener;
      return () => undefined;
    });
    const createGroup = vi.spyOn(api, 'createTriggerGroup').mockImplementation(async (change) => {
      const recreated = { ...group('group-copy', change.name, []),
        description: change.description, enabled: change.enabled };
      current = { groups: [recreated], totalTriggers: 0 };
      return current;
    });
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: '组设置' }));
    await user.type(await screen.findByLabelText('说明'), 'Local group draft');
    await waitFor(() => expect(emit).toBeDefined());
    current = { groups: [], totalTriggers: 0 };
    act(() => emit?.({ type: 'triggers-changed', data: {} }));

    expect(await screen.findByText('远端触发器组已删除;本地修改已保留,可保存为新组。'))
      .toBeInTheDocument();
    expect(screen.getByLabelText('说明')).toHaveValue('Local group draft');
    expect(screen.getByRole('button', { name: '删除触发器组' })).toBeDisabled();
    await user.click(screen.getByRole('button', { name: '保存组' }));

    await waitFor(() => expect(createGroup).toHaveBeenCalledOnce());
    expect(createGroup).toHaveBeenCalledWith(expect.objectContaining({
      name: 'Main group', description: 'Local group draft', enabled: true,
    }));
    await waitFor(() => expect(screen.getByRole('button', { name: /Main group/ })).toBeInTheDocument());
  });

  it('does not let a late group-save response overwrite an externally deleted group copy', async () => {
    let current = workspace([alpha]);
    let emit: ((event: StreamEvent) => void) | undefined;
    const api = client({ data: current });
    vi.mocked(api.triggerWorkspace).mockImplementation(async () => current);
    vi.mocked(api.subscribe).mockImplementation((listener) => {
      emit = listener;
      return () => undefined;
    });
    const pending = deferred<TriggerWorkspace>();
    vi.spyOn(api, 'updateTriggerGroup').mockReturnValue(pending.promise);
    const user = userEvent.setup();
    renderTriggers(api);

    await user.click(await screen.findByRole('button', { name: '组设置' }));
    await user.type(await screen.findByLabelText('说明'), 'Saved local edit');
    await waitFor(() => expect(emit).toBeDefined());
    await user.click(screen.getByRole('button', { name: '保存组' }));
    current = { groups: [], totalTriggers: 0 };
    act(() => emit?.({ type: 'triggers-changed', data: {} }));
    expect(await screen.findByText('远端触发器组已删除;本地修改已保留,可保存为新组。'))
      .toBeInTheDocument();

    await act(async () => {
      pending.resolve({ groups: [{ ...group('group-main', 'Main group', [alpha]),
        description: 'Saved local edit', revision: 1 }], totalTriggers: 1 });
      await pending.promise;
    });

    expect(screen.getByLabelText('说明')).toHaveValue('Saved local edit');
    expect(screen.getByRole('button', { name: '删除触发器组' })).toBeDisabled();
    expect(screen.queryByRole('button', { name: /Main group/ })).not.toBeInTheDocument();
  });
});