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();
});
});
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();
});
});