chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
264
client/electron/test/components/AddressInput.test.tsx
Normal file
264
client/electron/test/components/AddressInput.test.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import AddressInput from '../../src/components/AddressInput';
|
||||
import * as api from '../../src/api/client';
|
||||
import type { SessionCandidate } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 三段式地址补全。
|
||||
*
|
||||
* 每条断言都对应一个真实存在过或很容易犯的错:
|
||||
* - 问错层(写了 @ 还在问 name 的候选)
|
||||
* - 过滤后 suggestions 与 candidates 错位,标题挂到别的别名上
|
||||
* - 选中 name 后没自动补 `@`,人得自己敲
|
||||
* - session 段选完还开着下拉,挡住下面的输入框
|
||||
*/
|
||||
|
||||
/** 造一个 suggestAddress 的响应。candidates 与 suggestions 必须同序同长。 */
|
||||
function res(
|
||||
kind: 'name' | 'path' | 'session',
|
||||
suggestions: string[],
|
||||
candidates?: Partial<SessionCandidate>[]
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
suggestions,
|
||||
candidates: candidates?.map((c, i) => ({
|
||||
alias: suggestions[i],
|
||||
title: '',
|
||||
source: 'mail' as const,
|
||||
unread: 0,
|
||||
...c
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染一个受控的 AddressInput,返回 input 与「当前值」读取器。 */
|
||||
function setup(initial = '') {
|
||||
let current = initial;
|
||||
const onChange = vi.fn((v: string) => {
|
||||
current = v;
|
||||
rerender();
|
||||
});
|
||||
const { rerender: rr } = render(
|
||||
React.createElement(AddressInput, { value: current, onChange })
|
||||
);
|
||||
function rerender() {
|
||||
rr(React.createElement(AddressInput, { value: current, onChange }));
|
||||
}
|
||||
return {
|
||||
onChange,
|
||||
value: () => current,
|
||||
input: () => screen.getByRole('textbox') as HTMLInputElement
|
||||
};
|
||||
}
|
||||
|
||||
describe('AddressInput 三段式补全', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('还没写 @ 时问 name 层', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
// 问 name 层时不带任何参数
|
||||
expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('写了 @ 没写 . 时带 name 去问 path 层', async () => {
|
||||
const spy = vi
|
||||
.spyOn(api, 'suggestAddress')
|
||||
.mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'dsh@/home');
|
||||
|
||||
// 问错层的后果:候选里全是 Agent 名,人以为这个工作区下没有会话
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh'));
|
||||
});
|
||||
|
||||
it('写了 . 时带 name 与 path 去问 session 层', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'dsh@/home/x.ref');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/x'));
|
||||
});
|
||||
|
||||
it('path 里的 . 不被当作 session 分隔符(按最后一个 . 切)', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['a']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
// path 自身含 .:a.b/proj
|
||||
await userEvent.type(input(), 'dsh@/home/a.b/proj.ref');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/a.b/proj'));
|
||||
});
|
||||
|
||||
it('选中 name 候选后自动补 @', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('dsh')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('dsh'));
|
||||
|
||||
// 不补 @ 的话人得自己敲,而这是三段里唯一没有歧义的分隔符
|
||||
expect(value()).toBe('dsh@');
|
||||
});
|
||||
|
||||
it('选中 path 候选后自动补 .', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||||
const { input, value } = setup('dsh@');
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('/home/program/agentmail')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('/home/program/agentmail'));
|
||||
|
||||
expect(value()).toBe('dsh@/home/program/agentmail.');
|
||||
});
|
||||
|
||||
it('选中 session 候选后拼出完整地址并关掉下拉', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor', 'new']));
|
||||
const { input, value } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('refactor')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('refactor'));
|
||||
|
||||
expect(value()).toBe('dsh@/home/x.refactor');
|
||||
// session 是最后一段,选完还开着下拉会挡住下面的输入框
|
||||
await waitFor(() => expect(screen.queryByText('会话别名(new 为新建)')).toBeNull());
|
||||
});
|
||||
|
||||
it('标题参与过滤,且过滤后标题不错位到别的别名上', async () => {
|
||||
// 三条候选,只有第二条的标题含「缓存」
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res(
|
||||
'session',
|
||||
['witty-planet', 'brisk-harbor', 'calm-river'],
|
||||
[{ title: '重构导入路径' }, { title: '缓存选型讨论' }, { title: '修 CI' }]
|
||||
)
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), '缓存');
|
||||
|
||||
await waitFor(() => {
|
||||
// 命中的是 brisk-harbor,标题必须还是它自己的
|
||||
expect(screen.getByText('brisk-harbor')).toBeInTheDocument();
|
||||
expect(screen.getByText('缓存选型讨论')).toBeInTheDocument();
|
||||
});
|
||||
// 分别过滤两个数组会让 witty-planet 的标题挂到 brisk-harbor 上
|
||||
expect(screen.queryByText('重构导入路径')).toBeNull();
|
||||
expect(screen.queryByText('witty-planet')).toBeNull();
|
||||
});
|
||||
|
||||
it('platform 来源的候选标出「平台」,new 标出「新建会话」', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res(
|
||||
'session',
|
||||
['ses-mirror', 'new'],
|
||||
[
|
||||
{ title: '平台侧在跑的会话', source: 'platform' },
|
||||
{ source: 'new' }
|
||||
]
|
||||
)
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
|
||||
await waitFor(() => {
|
||||
// 不标的话人不知道这一封是「接入」一条已在跑的会话
|
||||
expect(screen.getByText('平台')).toBeInTheDocument();
|
||||
expect(screen.getByText('新建会话')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('未读数显示在候选上', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res('session', ['busy-session'], [{ title: '有新消息', unread: 3 }])
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
|
||||
await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('键盘上下键移动选中项,Enter 采用', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
// 初始 active=0(dsh),下移一格到 opencode
|
||||
expect(value()).toBe('opencode@');
|
||||
});
|
||||
|
||||
it('Escape 关掉下拉但不清空已输入的内容', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
await waitFor(() => expect(screen.getByText('Agent 名')).toBeInTheDocument());
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||||
expect(value()).toBe('ds');
|
||||
});
|
||||
|
||||
it('接口失败时静默清空候选,不炸也不弹错', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockRejectedValue(new Error('network down'));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
|
||||
// 补全只是便利功能,失败不该阻止人手动输入完整地址
|
||||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||||
expect(value()).toBe('ds');
|
||||
});
|
||||
|
||||
it('allowMultiple 时补全只作用于最后一段,前面的地址保留', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['opencode']));
|
||||
|
||||
let current = 'dsh@/home/x.a, ';
|
||||
const onChange = vi.fn((v: string) => {
|
||||
current = v;
|
||||
});
|
||||
render(
|
||||
React.createElement(AddressInput, {
|
||||
value: current,
|
||||
onChange,
|
||||
allowMultiple: true
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('textbox'));
|
||||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('opencode'));
|
||||
|
||||
// 抄送场景:前面已填好的地址不能被补全覆盖
|
||||
expect(current).toBe('dsh@/home/x.a, opencode@');
|
||||
});
|
||||
});
|
||||
80
client/electron/test/components/BudgetChip.test.tsx
Normal file
80
client/electron/test/components/BudgetChip.test.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BudgetChip } from '../../src/components/WorkCard';
|
||||
|
||||
/**
|
||||
* 往返预算徽标。
|
||||
*
|
||||
* 它是「这个任务还剩几个来回」的唯一视觉提示,两件事必须对:
|
||||
* - 显示的是**剩余**而不是已用(人做决定看的是「还能问几次」)
|
||||
* - 0 = 不限时**不显示**,而不是显示「0/0」
|
||||
*/
|
||||
describe('BudgetChip 预算渲染', () => {
|
||||
it('显示剩余数而不是已用数', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 3 }));
|
||||
|
||||
// 20 个上限、用了 3 个 → 剩 17。显示 3/20 会让人以为快用完了
|
||||
expect(screen.getByText('17/20')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('max 为 0(不限)时完全不渲染', () => {
|
||||
const { container } = render(React.createElement(BudgetChip, { max: 0, used: 0 }));
|
||||
|
||||
// 「0/0」或「不限」对每张卡片都成立,等于纯噪声
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('max 为负数时也不渲染(脏数据兜底)', () => {
|
||||
const { container } = render(React.createElement(BudgetChip, { max: -1, used: 0 }));
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('用超时剩余按 0 显示,不出现负数', () => {
|
||||
render(React.createElement(BudgetChip, { max: 5, used: 8 }));
|
||||
|
||||
// 「-3/5」看起来像 bug,而实际情形(免配额转发不计数、人手动下调过上限)是合法的
|
||||
expect(screen.getByText('0/5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('剩余为 0 时转红并标注「已用尽」', () => {
|
||||
render(React.createElement(BudgetChip, { max: 5, used: 5 }));
|
||||
|
||||
const chip = screen.getByText('0/5').closest('span')!;
|
||||
expect(chip.className).toContain('bg-red-100');
|
||||
expect(chip.getAttribute('title')).toContain('已用尽');
|
||||
});
|
||||
|
||||
it('剩余 1 个时转橙 —— 那是需要人介入的时刻', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 19 }));
|
||||
|
||||
const chip = screen.getByText('1/20').closest('span')!;
|
||||
// 要么加预算,要么让它收尾;这一步不提示的话下一封信就被挡住了
|
||||
expect(chip.className).toContain('bg-orange-100');
|
||||
});
|
||||
|
||||
it('余量充足时用中性灰,不抢注意力', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 2 }));
|
||||
|
||||
const chip = screen.getByText('18/20').closest('span')!;
|
||||
expect(chip.className).toContain('bg-gray-100');
|
||||
expect(chip.className).not.toContain('red');
|
||||
expect(chip.className).not.toContain('orange');
|
||||
});
|
||||
|
||||
it('title 里带已用/上限,供悬停查看细节', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 7 }));
|
||||
|
||||
const chip = screen.getByText('13/20').closest('span')!;
|
||||
// 徽标上只有剩余,具体用了几个放在 title 里 —— 徽标要窄
|
||||
expect(chip.getAttribute('title')).toContain('已用 7/20');
|
||||
});
|
||||
|
||||
it('剩余 2 个时还不转橙(阈值是 <= 1)', () => {
|
||||
render(React.createElement(BudgetChip, { max: 10, used: 8 }));
|
||||
|
||||
const chip = screen.getByText('2/10').closest('span')!;
|
||||
expect(chip.className).toContain('bg-gray-100');
|
||||
});
|
||||
});
|
||||
203
client/electron/test/components/PermissionPanel.test.tsx
Normal file
203
client/electron/test/components/PermissionPanel.test.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { PermissionPanel } from '../../src/components/MailView';
|
||||
import * as api from '../../src/api/client';
|
||||
import { useMailStore } from '../../src/stores/mailStore';
|
||||
import { useSessionStore } from '../../src/stores/sessionStore';
|
||||
import type { Mail } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 权限决策面板。
|
||||
*
|
||||
* 这是全站唯一一处「人的一次点击直接放行 Agent 的危险操作」,
|
||||
* 因此断言集中在两件事:
|
||||
* - 点下去到底把什么发给了服务端(选项原文,不是归一化后的 allow/deny)
|
||||
* - 已决策的请求不能再点第二次
|
||||
*/
|
||||
|
||||
function permMail(over: Partial<Mail> = {}): Mail {
|
||||
return {
|
||||
mail_id: 'm-1',
|
||||
session_id: 's-1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'dsh',
|
||||
from_workspace: '/home/program/agentmail',
|
||||
to_name: 'admin',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: '请求批准:删除 build/',
|
||||
body: '将执行 rm -rf build/',
|
||||
mail_type: 'permission_request',
|
||||
permission_options: undefined,
|
||||
permission_result: '',
|
||||
status: 'unread',
|
||||
created_at: '2026-09-03T00:00:00Z',
|
||||
hop_limit: 5,
|
||||
...over
|
||||
} as Mail;
|
||||
}
|
||||
|
||||
describe('PermissionPanel 决策', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// fetchInbox / selectSession 会打网络,替换成空实现
|
||||
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
|
||||
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
|
||||
});
|
||||
|
||||
it('没有 permission_options 时给默认的同意/拒绝两个选项', () => {
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有 permission_options 时用它,且顺序保持', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] })
|
||||
})
|
||||
);
|
||||
|
||||
const btns = screen.getAllByRole('button').map(b => b.textContent?.trim());
|
||||
// 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上
|
||||
expect(btns).toEqual(['只这一次', '总是允许', '拒绝']);
|
||||
});
|
||||
|
||||
it('点选项时把【选项原文】发给服务端', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '拒绝'] })
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /只这一次/ }));
|
||||
|
||||
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
|
||||
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined)
|
||||
);
|
||||
});
|
||||
|
||||
it('填了备注时一起发出去', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('备注(可选)'), '只删 build,别动 dist');
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build,别动 dist')
|
||||
);
|
||||
});
|
||||
|
||||
it('备注为空时传 undefined 而不是空字符串', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 空串会在决策邮件里留一行空的「备注:」
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined));
|
||||
});
|
||||
|
||||
it('决策后变成「已处理」,不再显示按钮', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('已经有 permission_result 的邮件直接显示结论', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_result: '拒绝' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText('拒绝')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('提交中禁用所有按钮,避免重复决策', async () => {
|
||||
let release: (v: any) => void = () => {};
|
||||
vi.spyOn(api, 'decidePermission').mockReturnValue(
|
||||
new Promise(res => {
|
||||
release = res;
|
||||
}) as any
|
||||
);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 一次危险操作被批准两次,Agent 那边可能真的执行两遍
|
||||
await waitFor(() => {
|
||||
for (const b of screen.getAllByRole('button')) {
|
||||
expect(b).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// 收尾:让悬挂的 Promise 落定并等状态更新走完,
|
||||
// 否则组件在测试结束后才 setState,React 会报 act 警告
|
||||
await act(async () => {
|
||||
release({ status: 'decided' });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('提交失败时恢复可点,不假装已决策', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 失败后显示「已处理」是最糟的结果:人以为批过了,Agent 还在等
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled());
|
||||
expect(screen.queryByText('已处理:')).toBeNull();
|
||||
});
|
||||
|
||||
it('决策成功后刷新收件箱并选中该会话', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
const fetchInbox = vi.fn(async () => {});
|
||||
const selectSession = vi.fn(async () => {});
|
||||
useMailStore.setState({ fetchInbox } as any);
|
||||
useSessionStore.setState({ selectSession } as any);
|
||||
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
|
||||
await waitFor(() => {
|
||||
expect(fetchInbox).toHaveBeenCalledWith('all');
|
||||
expect(selectSession).toHaveBeenCalledWith('s-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('同意类选项用绿色,其余用红色', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] })
|
||||
})
|
||||
);
|
||||
|
||||
const cls = (name: string) =>
|
||||
screen.getByRole('button', { name: new RegExp(name) }).className;
|
||||
|
||||
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
||||
expect(cls('允许')).toContain('bg-green-700');
|
||||
expect(cls('approve')).toContain('bg-green-700');
|
||||
expect(cls('拒绝')).toContain('text-red-700');
|
||||
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
||||
expect(cls('算了')).toContain('text-red-700');
|
||||
});
|
||||
});
|
||||
281
client/electron/test/components/calendar.test.tsx
Normal file
281
client/electron/test/components/calendar.test.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
startOfDay, endOfDay, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
isSameDay, addDays, addMonths, monthGrid, weekDays, remindAt,
|
||||
bucketByDay, dayKey, renderReminder, toLocalInput, fromLocalInput,
|
||||
describeRecurrence, describeRemindBefore
|
||||
} from '../../src/lib/calendar';
|
||||
import type { CalendarEvent } from '../../src/types';
|
||||
|
||||
let seq = 0;
|
||||
function ev(over: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
seq += 1;
|
||||
return {
|
||||
event_id: `e${String(seq).padStart(3, '0')}`,
|
||||
title: `事件 ${seq}`,
|
||||
description: '',
|
||||
reminder_text: '',
|
||||
agent_name: 'pi',
|
||||
to_address: '',
|
||||
recipients: [],
|
||||
delivery_mode: 'separate',
|
||||
event_time: '2026-09-03T14:30:00+08:00',
|
||||
remind_before: 0,
|
||||
recurrence: 'none',
|
||||
status: 'active',
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:00Z',
|
||||
created_by: 'jianf',
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
describe('日期边界', () => {
|
||||
it('startOfDay / endOfDay 用本地时区', () => {
|
||||
const d = new Date(2026, 8, 3, 14, 30, 45, 123);
|
||||
expect(startOfDay(d).getHours()).toBe(0);
|
||||
expect(startOfDay(d).getMilliseconds()).toBe(0);
|
||||
expect(endOfDay(d).getHours()).toBe(23);
|
||||
expect(endOfDay(d).getMilliseconds()).toBe(999);
|
||||
// 不修改入参
|
||||
expect(d.getHours()).toBe(14);
|
||||
});
|
||||
|
||||
it('startOfMonth / endOfMonth', () => {
|
||||
const d = new Date(2026, 8, 15);
|
||||
expect(startOfMonth(d).getDate()).toBe(1);
|
||||
expect(endOfMonth(d).getDate()).toBe(30); // 9 月 30 天
|
||||
expect(endOfMonth(new Date(2026, 1, 5)).getDate()).toBe(28); // 2026 年 2 月
|
||||
});
|
||||
|
||||
it('周从周一开始,周日归到上一周末尾', () => {
|
||||
// 2026-09-06 是周日
|
||||
const sunday = new Date(2026, 8, 6);
|
||||
expect(sunday.getDay()).toBe(0);
|
||||
// 它所在周的周一应是 08-31,不是 09-07
|
||||
expect(dayKey(startOfWeek(sunday))).toBe('2026-08-31');
|
||||
expect(dayKey(endOfWeek(sunday))).toBe('2026-09-06');
|
||||
});
|
||||
|
||||
it('周一自己就是周首', () => {
|
||||
const monday = new Date(2026, 8, 7);
|
||||
expect(monday.getDay()).toBe(1);
|
||||
expect(dayKey(startOfWeek(monday))).toBe('2026-09-07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('日期运算', () => {
|
||||
it('addDays 不修改入参', () => {
|
||||
const d = new Date(2026, 8, 3);
|
||||
const r = addDays(d, 5);
|
||||
expect(dayKey(r)).toBe('2026-09-08');
|
||||
expect(dayKey(d)).toBe('2026-09-03');
|
||||
});
|
||||
|
||||
it('addDays 跨月', () => {
|
||||
expect(dayKey(addDays(new Date(2026, 8, 29), 5))).toBe('2026-10-04');
|
||||
});
|
||||
|
||||
it('addMonths 从月末加一个月不会溢出', () => {
|
||||
// setMonth 在 1 月 31 日上加一个月会给 3 月 2/3 日
|
||||
const jan31 = new Date(2026, 0, 31);
|
||||
expect(dayKey(addMonths(jan31, 1))).toBe('2026-02-01');
|
||||
});
|
||||
|
||||
it('isSameDay 只比年月日', () => {
|
||||
expect(isSameDay(new Date(2026, 8, 3, 0, 0), new Date(2026, 8, 3, 23, 59))).toBe(true);
|
||||
expect(isSameDay(new Date(2026, 8, 3), new Date(2026, 8, 4))).toBe(false);
|
||||
expect(isSameDay(new Date(2026, 8, 3), new Date(2025, 8, 3))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthGrid', () => {
|
||||
it('固定 42 格(6 行 × 7 列)', () => {
|
||||
// 行数变化会让网格高度跳动,翻月时页面内容上下弹
|
||||
expect(monthGrid(new Date(2026, 8, 15))).toHaveLength(42);
|
||||
expect(monthGrid(new Date(2026, 1, 15))).toHaveLength(42);
|
||||
});
|
||||
|
||||
it('首格是月首所在周的周一', () => {
|
||||
// 2026-09-01 是周二 → 首格应是 08-31(周一)
|
||||
const cells = monthGrid(new Date(2026, 8, 15));
|
||||
expect(dayKey(cells[0])).toBe('2026-08-31');
|
||||
});
|
||||
|
||||
it('格子连续无空洞', () => {
|
||||
const cells = monthGrid(new Date(2026, 8, 15));
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const diff = cells[i].getTime() - cells[i - 1].getTime();
|
||||
// 允许夏令时造成的 ±1 小时偏差
|
||||
expect(diff).toBeGreaterThanOrEqual(23 * 3600_000);
|
||||
expect(diff).toBeLessThanOrEqual(25 * 3600_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('包含整个当月', () => {
|
||||
const anchor = new Date(2026, 8, 15);
|
||||
const keys = monthGrid(anchor).map(dayKey);
|
||||
expect(keys).toContain('2026-09-01');
|
||||
expect(keys).toContain('2026-09-30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekDays', () => {
|
||||
it('给 7 天,周一起头', () => {
|
||||
const days = weekDays(new Date(2026, 8, 3)); // 周四
|
||||
expect(days).toHaveLength(7);
|
||||
expect(dayKey(days[0])).toBe('2026-08-31');
|
||||
expect(dayKey(days[6])).toBe('2026-09-06');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindAt', () => {
|
||||
it('提前 30 分钟', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 30 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:00:00.000Z');
|
||||
});
|
||||
|
||||
it('remind_before 为 0 时就是事件时间', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 0 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:30:00.000Z');
|
||||
});
|
||||
|
||||
it('提前一整天', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 1440 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-02T06:30:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucketByDay', () => {
|
||||
it('按本地日期分桶,不用 UTC 前缀', () => {
|
||||
// 东八区 22:00 → UTC 是前一天 14:00。用 toISOString().slice(0,10) 会归错天
|
||||
const e = ev({ event_time: '2026-09-03T22:00:00+08:00' });
|
||||
const m = bucketByDay([e]);
|
||||
expect([...m.keys()]).toEqual(['2026-09-03']);
|
||||
});
|
||||
|
||||
it('同一天内按时间正序', () => {
|
||||
const late = ev({ event_time: '2026-09-03T18:00:00+08:00', title: '晚' });
|
||||
const early = ev({ event_time: '2026-09-03T09:00:00+08:00', title: '早' });
|
||||
const m = bucketByDay([late, early]);
|
||||
expect(m.get('2026-09-03')!.map(x => x.title)).toEqual(['早', '晚']);
|
||||
});
|
||||
|
||||
it('同刻事件用 event_id 兜底定序', () => {
|
||||
const ts = '2026-09-03T09:00:00+08:00';
|
||||
const a = ev({ event_id: 'aaa', event_time: ts });
|
||||
const z = ev({ event_id: 'zzz', event_time: ts });
|
||||
const m1 = bucketByDay([a, z]).get('2026-09-03')!.map(x => x.event_id);
|
||||
const m2 = bucketByDay([z, a]).get('2026-09-03')!.map(x => x.event_id);
|
||||
expect(m1).toEqual(m2);
|
||||
expect(m1).toEqual(['aaa', 'zzz']);
|
||||
});
|
||||
|
||||
it('时间解析失败的脏数据被跳过而不是让整个视图空白', () => {
|
||||
const bad = ev({ event_time: '不是时间' });
|
||||
const good = ev({ event_time: '2026-09-03T09:00:00+08:00' });
|
||||
const m = bucketByDay([bad, good]);
|
||||
expect([...m.keys()]).toEqual(['2026-09-03']);
|
||||
});
|
||||
|
||||
it('空输入给空 Map', () => {
|
||||
expect(bucketByDay([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderReminder', () => {
|
||||
it('替换三个变量', () => {
|
||||
const out = renderReminder('【{title}】{time} — {description}', {
|
||||
title: '发布评审',
|
||||
description: '看 llmsproxy 的部署脚本',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('【发布评审】2026-09-03 14:30 — 看 llmsproxy 的部署脚本');
|
||||
});
|
||||
|
||||
it('同一变量出现多次全部替换', () => {
|
||||
// replaceAll 而非 replace:后者只换第一个
|
||||
const out = renderReminder('{title} / {title}', {
|
||||
title: 'X',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('X / X');
|
||||
});
|
||||
|
||||
it('description 缺失时替换成空串而不是 undefined', () => {
|
||||
const out = renderReminder('{description}|', {
|
||||
title: 'T',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('|');
|
||||
});
|
||||
|
||||
it('没有变量的模板原样返回', () => {
|
||||
const out = renderReminder('纯文本提醒', {
|
||||
title: 'T',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('纯文本提醒');
|
||||
});
|
||||
|
||||
it('时间解析失败时回退到原始串', () => {
|
||||
const out = renderReminder('{time}', { title: 'T', event_time: '坏时间' });
|
||||
expect(out).toBe('坏时间');
|
||||
});
|
||||
});
|
||||
|
||||
describe('datetime-local 往返', () => {
|
||||
it('toLocalInput 给本地时区的 YYYY-MM-DDTHH:mm', () => {
|
||||
const d = new Date(2026, 8, 3, 14, 30);
|
||||
expect(toLocalInput(d)).toBe('2026-09-03T14:30');
|
||||
});
|
||||
|
||||
it('补零', () => {
|
||||
const d = new Date(2026, 0, 5, 9, 5);
|
||||
expect(toLocalInput(d)).toBe('2026-01-05T09:05');
|
||||
});
|
||||
|
||||
it('fromLocalInput 按本地时区解析(人写的就是本地时间)', () => {
|
||||
const iso = fromLocalInput('2026-09-03T14:30');
|
||||
// 结果应与本地构造的 Date 一致
|
||||
expect(iso).toBe(new Date(2026, 8, 3, 14, 30).toISOString());
|
||||
});
|
||||
|
||||
it('往返不丢分钟', () => {
|
||||
const original = new Date(2026, 8, 3, 14, 30);
|
||||
expect(toLocalInput(new Date(fromLocalInput(toLocalInput(original))))).toBe('2026-09-03T14:30');
|
||||
});
|
||||
|
||||
it('非法输入给空串', () => {
|
||||
expect(fromLocalInput('')).toBe('');
|
||||
expect(fromLocalInput('坏值')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('人类可读描述', () => {
|
||||
it('重复规则', () => {
|
||||
expect(describeRecurrence(ev({ recurrence: 'none' }))).toBe('不重复');
|
||||
expect(describeRecurrence(ev({ recurrence: 'daily' }))).toBe('每天');
|
||||
expect(describeRecurrence(ev({ recurrence: 'weekly' }))).toBe('每周');
|
||||
expect(describeRecurrence(ev({ recurrence: 'monthly' }))).toBe('每月');
|
||||
});
|
||||
|
||||
it('带终止时间', () => {
|
||||
const e = ev({ recurrence: 'daily', recurrence_end: '2026-12-31T00:00:00+08:00' });
|
||||
expect(describeRecurrence(e)).toBe('每天,至 2026-12-31');
|
||||
});
|
||||
|
||||
it('none 时忽略 recurrence_end', () => {
|
||||
const e = ev({ recurrence: 'none', recurrence_end: '2026-12-31T00:00:00+08:00' });
|
||||
expect(describeRecurrence(e)).toBe('不重复');
|
||||
});
|
||||
|
||||
it('提前提醒', () => {
|
||||
expect(describeRemindBefore(0)).toBe('到点提醒');
|
||||
expect(describeRemindBefore(15)).toBe('提前 15 分钟');
|
||||
expect(describeRemindBefore(60)).toBe('提前 1 小时');
|
||||
expect(describeRemindBefore(120)).toBe('提前 2 小时');
|
||||
expect(describeRemindBefore(90)).toBe('提前 1 小时 30 分钟');
|
||||
expect(describeRemindBefore(1440)).toBe('提前 24 小时');
|
||||
});
|
||||
});
|
||||
268
client/electron/test/components/lunar.test.tsx
Normal file
268
client/electron/test/components/lunar.test.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
fromSolar, toSolar, leapMonth, daysInMonth,
|
||||
addLunarMonths, addLunarYears,
|
||||
formatLunarFull, formatLunarShort, cellLunarLabel, lunarDayName,
|
||||
formatSolarWithLunar, isLunarRecurrence,
|
||||
nextOccurrence, addSolarMonthsClamped, upcomingOccurrences,
|
||||
describeRecurrenceRule
|
||||
} from '../../src/lib/lunar';
|
||||
|
||||
describe('公历 ↔ 农历', () => {
|
||||
it('已知锚点', () => {
|
||||
const d = fromSolar(new Date(2026, 8, 3));
|
||||
expect(d).toEqual({ year: 2026, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
it('闰月用负数月份表示', () => {
|
||||
// 2025 有闰六月
|
||||
expect(leapMonth(2025)).toBe(6);
|
||||
const d = fromSolar(new Date(2025, 6, 25)); // 2025-07-25
|
||||
expect(d.month).toBe(-6);
|
||||
expect(d.day).toBe(1);
|
||||
});
|
||||
|
||||
it('无闰月的年份返回 0', () => {
|
||||
expect(leapMonth(2026)).toBe(0);
|
||||
expect(leapMonth(2027)).toBe(0);
|
||||
});
|
||||
|
||||
it('往返不丢日期', () => {
|
||||
for (const [y, m, dd] of [[2026, 8, 3], [2027, 1, 14], [2025, 6, 25]] as const) {
|
||||
const solar = new Date(y, m, dd);
|
||||
const lunar = fromSolar(solar);
|
||||
const back = toSolar(lunar, 9, 30);
|
||||
expect(back).not.toBeNull();
|
||||
expect(back!.clamped).toBe(false);
|
||||
expect(back!.date.getFullYear()).toBe(y);
|
||||
expect(back!.date.getMonth()).toBe(m);
|
||||
expect(back!.date.getDate()).toBe(dd);
|
||||
// 时钟原样带过去(农历只定义到「日」)
|
||||
expect(back!.date.getHours()).toBe(9);
|
||||
expect(back!.date.getMinutes()).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('短月份夹取', () => {
|
||||
it('2027 农历九月只有 29 天', () => {
|
||||
expect(daysInMonth(2027, 9)).toBe(29);
|
||||
expect(daysInMonth(2026, 1)).toBe(30);
|
||||
});
|
||||
|
||||
// 库在 Lunar.fromYmd(2027,9,30) 上直接 throw。
|
||||
// 「每月农历三十」必然撞上 29 天的月份,不夹住就是整片白屏。
|
||||
it('要三十而该月只有廿九时夹到廿九,不抛错', () => {
|
||||
const r = toSolar({ year: 2027, month: 9, day: 30 }, 9, 0);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.clamped).toBe(true);
|
||||
// 夹取后必须仍在同一个农历月内(滚到下月初一是错的)
|
||||
expect(fromSolar(r!.date).month).toBe(9);
|
||||
expect(fromSolar(r!.date).day).toBe(29);
|
||||
});
|
||||
|
||||
it('不存在的闰月返回 null 而不是猜一个日子', () => {
|
||||
expect(daysInMonth(2026, -6)).toBe(0);
|
||||
expect(toSolar({ year: 2026, month: -6, day: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it('非法日返回 null', () => {
|
||||
expect(toSolar({ year: 2026, month: 1, day: 0 })).toBeNull();
|
||||
expect(toSolar({ year: 2026, month: 13, day: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('农历推进', () => {
|
||||
it('加月跨年', () => {
|
||||
expect(addLunarMonths({ year: 2026, month: 12, day: 5 }, 1)).toEqual({ year: 2027, month: 1, day: 5 });
|
||||
});
|
||||
|
||||
it('推 12 次回到次年同月', () => {
|
||||
expect(addLunarMonths({ year: 2026, month: 7, day: 22 }, 12))
|
||||
.toEqual({ year: 2027, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
// 「每月十五」的期望是一年 12 次,把闰月算进去会让闰年多一次
|
||||
it('从闰月出发先归正月份', () => {
|
||||
expect(addLunarMonths({ year: 2025, month: -6, day: 15 }, 1).month).toBe(7);
|
||||
});
|
||||
|
||||
it('加年保持月日', () => {
|
||||
expect(addLunarYears({ year: 2026, month: 7, day: 22 }, 1))
|
||||
.toEqual({ year: 2027, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
// 静默让重复事件消失比退回正月份更糟
|
||||
it('目标年无同一闰月时退回正月份', () => {
|
||||
expect(addLunarYears({ year: 2025, month: -6, day: 15 }, 1))
|
||||
.toEqual({ year: 2026, month: 6, day: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('中文表示', () => {
|
||||
it('完整表示', () => {
|
||||
expect(formatLunarFull({ year: 2026, month: 7, day: 22 })).toBe('二〇二六年七月廿二');
|
||||
});
|
||||
|
||||
it('闰月带「闰」字', () => {
|
||||
expect(formatLunarFull({ year: 2025, month: -6, day: 1 })).toBe('二〇二五年闰六月初一');
|
||||
});
|
||||
|
||||
it('短表示去掉年份', () => {
|
||||
expect(formatLunarShort({ year: 2026, month: 7, day: 22 })).toBe('七月廿二');
|
||||
});
|
||||
|
||||
// 日名有五种前缀形态(初一/十一/二十/廿一/三十),
|
||||
// 用正则截取时「二十」曾被漏掉
|
||||
it('日名查表覆盖全部 30 天', () => {
|
||||
const names = Array.from({ length: 30 }, (_, i) => lunarDayName(i + 1));
|
||||
expect(names[0]).toBe('初一');
|
||||
expect(names[9]).toBe('初十');
|
||||
expect(names[10]).toBe('十一');
|
||||
expect(names[19]).toBe('二十');
|
||||
expect(names[20]).toBe('廿一');
|
||||
expect(names[29]).toBe('三十');
|
||||
expect(new Set(names).size).toBe(30); // 无重复
|
||||
expect(names.every(n => n.length === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('格子标记:初一显示月名,其余显示日名', () => {
|
||||
// 2026-08-13 是农历七月初一
|
||||
const firstDay = toSolar({ year: 2026, month: 7, day: 1 })!.date;
|
||||
expect(cellLunarLabel(firstDay)).toBe('七月');
|
||||
expect(cellLunarLabel(new Date(2026, 8, 3))).toBe('廿二');
|
||||
});
|
||||
|
||||
it('闰月初一的格子标记带「闰」', () => {
|
||||
const leapFirst = toSolar({ year: 2025, month: -6, day: 1 })!.date;
|
||||
expect(cellLunarLabel(leapFirst)).toBe('闰六月');
|
||||
});
|
||||
|
||||
it('公历+农历合并显示', () => {
|
||||
expect(formatSolarWithLunar(new Date(2026, 8, 3))).toBe('2026-09-03(农历七月廿二)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextOccurrence 与后端 repo.NextOccurrence 对齐', () => {
|
||||
const base = new Date(2026, 8, 3, 9, 30);
|
||||
|
||||
it('公历三种', () => {
|
||||
expect(nextOccurrence('daily', base)!.getDate()).toBe(4);
|
||||
expect(nextOccurrence('weekly', base)!.getDate()).toBe(10);
|
||||
expect(nextOccurrence('monthly', base)!.getMonth()).toBe(9);
|
||||
});
|
||||
|
||||
it('公历每年', () => {
|
||||
const n = nextOccurrence('yearly', base)!;
|
||||
expect(n.getFullYear()).toBe(2027);
|
||||
expect(n.getMonth()).toBe(8);
|
||||
expect(n.getDate()).toBe(3);
|
||||
});
|
||||
|
||||
it('none 与未知值给 null', () => {
|
||||
expect(nextOccurrence('none', base)).toBeNull();
|
||||
expect(nextOccurrence('每隔一个蓝月亮', base)).toBeNull();
|
||||
});
|
||||
|
||||
it('时钟保留', () => {
|
||||
for (const r of ['daily', 'weekly', 'monthly', 'yearly', 'lunar_monthly', 'lunar_yearly']) {
|
||||
const n = nextOccurrence(r, base);
|
||||
expect(n).not.toBeNull();
|
||||
expect(n!.getHours()).toBe(9);
|
||||
expect(n!.getMinutes()).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// setMonth 的溢出(3月31日 +1月 = 5月1日)会永久改变规则:
|
||||
// 31 日的事件在 2 月变成 3 月 3 日,然后从此每月 3 日提醒
|
||||
describe('公历月末夹取', () => {
|
||||
it.each([
|
||||
['2026-01-31', 1, '2026-02-28'],
|
||||
['2026-03-31', 1, '2026-04-30'],
|
||||
['2028-01-31', 1, '2028-02-29'],
|
||||
['2028-02-29', 12, '2029-02-28'],
|
||||
['2026-01-15', 1, '2026-02-15']
|
||||
])('%s + %i 月 → %s', (from, n, want) => {
|
||||
const [y, m, d] = from.split('-').map(Number);
|
||||
const got = addSolarMonthsClamped(new Date(y, m - 1, d), n);
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
expect(`${got.getFullYear()}-${pad(got.getMonth() + 1)}-${pad(got.getDate())}`).toBe(want);
|
||||
});
|
||||
});
|
||||
|
||||
describe('农历重复的公历漂移', () => {
|
||||
it('农历月间隔在 29~30 天之间浮动,不是固定值', () => {
|
||||
let cur = new Date(2026, 8, 3, 9, 0);
|
||||
const gaps = new Set<number>();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const next = nextOccurrence('lunar_monthly', cur)!;
|
||||
expect(next.getTime()).toBeGreaterThan(cur.getTime());
|
||||
gaps.add(Math.round((next.getTime() - cur.getTime()) / 86400000));
|
||||
// 农历「日」保持不变
|
||||
expect(fromSolar(next).day).toBe(22);
|
||||
cur = next;
|
||||
}
|
||||
expect(gaps.size).toBeGreaterThan(1);
|
||||
for (const g of gaps) expect(g).toBeGreaterThanOrEqual(28);
|
||||
for (const g of gaps) expect(g).toBeLessThanOrEqual(31);
|
||||
});
|
||||
|
||||
// 这是农历规则存在的理由:用公历 yearly 日子会固定,
|
||||
// 与「过农历生日/祭日」的期望不符
|
||||
it('农历年推进时公历月日每年都变', () => {
|
||||
let cur = new Date(2026, 8, 3, 9, 0);
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const next = nextOccurrence('lunar_yearly', cur)!;
|
||||
const d = fromSolar(next);
|
||||
expect(d.month).toBe(7);
|
||||
expect(d.day).toBe(22);
|
||||
seen.add(`${next.getMonth() + 1}-${next.getDate()}`);
|
||||
cur = next;
|
||||
}
|
||||
expect(seen.size).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upcomingOccurrences', () => {
|
||||
it('给出连续递增的 n 次', () => {
|
||||
const list = upcomingOccurrences('lunar_monthly', new Date(2026, 8, 3, 9, 0), 3);
|
||||
expect(list).toHaveLength(3);
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect(list[i].getTime()).toBeGreaterThan(list[i - 1].getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('不重复时给空数组', () => {
|
||||
expect(upcomingOccurrences('none', new Date(), 3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeRecurrenceRule', () => {
|
||||
it('公历规则', () => {
|
||||
expect(describeRecurrenceRule('none')).toBe('不重复');
|
||||
expect(describeRecurrenceRule('daily')).toBe('每天');
|
||||
expect(describeRecurrenceRule('weekly')).toBe('每周');
|
||||
expect(describeRecurrenceRule('monthly')).toBe('每月');
|
||||
expect(describeRecurrenceRule('yearly')).toBe('每年');
|
||||
});
|
||||
|
||||
it('农历规则把农历日子写出来', () => {
|
||||
const t = new Date(2026, 8, 3);
|
||||
expect(describeRecurrenceRule('lunar_monthly', t)).toBe('每农历月廿二');
|
||||
expect(describeRecurrenceRule('lunar_yearly', t)).toBe('每年农历七月廿二');
|
||||
});
|
||||
|
||||
it('无事件时间时退回泛化描述', () => {
|
||||
expect(describeRecurrenceRule('lunar_monthly')).toBe('每农历月同一日');
|
||||
expect(describeRecurrenceRule('lunar_yearly')).toBe('每农历年同月同日');
|
||||
});
|
||||
|
||||
it('isLunarRecurrence', () => {
|
||||
expect(isLunarRecurrence('lunar_monthly')).toBe(true);
|
||||
expect(isLunarRecurrence('lunar_yearly')).toBe(true);
|
||||
expect(isLunarRecurrence('monthly')).toBe(false);
|
||||
expect(isLunarRecurrence('yearly')).toBe(false);
|
||||
});
|
||||
});
|
||||
333
client/electron/test/components/mailGroups.test.tsx
Normal file
333
client/electron/test/components/mailGroups.test.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
groupMailsBySession,
|
||||
isFlatGroup,
|
||||
isPendingPermission,
|
||||
splitByPermission,
|
||||
countPendingPermissions,
|
||||
groupPermissions
|
||||
} from '../../src/lib/mailGroups';
|
||||
import type { Mail } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 收件箱会话分组 + 授权请求独立分组。
|
||||
*
|
||||
* 这些用例锁的是生产实测过的形状:一个会话独占 17 封权限邮件,
|
||||
* 把另外两个会话的信挤出视野。修法有两层 ——
|
||||
* 权限请求整体移出收件箱(各归各的导航项),剩下的普通邮件按会话折叠。
|
||||
*/
|
||||
|
||||
let seq = 0;
|
||||
function mail(over: Partial<Mail> = {}): Mail {
|
||||
seq += 1;
|
||||
return {
|
||||
mail_id: `m${String(seq).padStart(3, '0')}`,
|
||||
session_id: 's1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: `主题 ${seq}`,
|
||||
body: '正文',
|
||||
mail_type: 'normal',
|
||||
permission_options: null,
|
||||
permission_result: null,
|
||||
status: 'read',
|
||||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||||
from_human: false,
|
||||
to_human: true,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function perm(over: Partial<Mail> = {}): Mail {
|
||||
return mail({ mail_type: 'permission_request', ...over });
|
||||
}
|
||||
|
||||
describe('groupMailsBySession', () => {
|
||||
it('把同一会话的多封邮件折成一组', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'b' })
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.find(g => g.sessionId === 'a')!.mails).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('组内按时间倒序,最新那封做组头', () => {
|
||||
const old = mail({ session_id: 'a', created_at: '2026-09-03T08:00:00Z', subject: '旧' });
|
||||
const mid = mail({ session_id: 'a', created_at: '2026-09-03T09:00:00Z', subject: '中' });
|
||||
const now = mail({ session_id: 'a', created_at: '2026-09-03T10:00:00Z', subject: '新' });
|
||||
const [g] = groupMailsBySession([mid, old, now]); // 故意乱序传入
|
||||
expect(g.mails.map(m => m.subject)).toEqual(['新', '中', '旧']);
|
||||
expect(g.latest.subject).toBe('新');
|
||||
// 组标题取最新一封:会话主题随任务推进被改写,最新的最贴切
|
||||
expect(g.subject).toBe('新');
|
||||
});
|
||||
|
||||
it('组之间按最新邮件时间倒序', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ session_id: 'stale', created_at: '2026-09-01T10:00:00Z' }),
|
||||
mail({ session_id: 'fresh', created_at: '2026-09-03T10:00:00Z' }),
|
||||
mail({ session_id: 'mid', created_at: '2026-09-02T10:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['fresh', 'mid', 'stale']);
|
||||
});
|
||||
|
||||
it('同一时刻用 mail_id 兜底定序(SQLite 时间戳精度有限)', () => {
|
||||
const ts = '2026-09-03T10:00:00Z';
|
||||
const a = mail({ mail_id: 'aaa', session_id: 's', created_at: ts });
|
||||
const z = mail({ mail_id: 'zzz', session_id: 's', created_at: ts });
|
||||
const [g1] = groupMailsBySession([a, z]);
|
||||
const [g2] = groupMailsBySession([z, a]);
|
||||
// 两种输入顺序必须给出同一结果,否则「刷新一次顺序就变了」
|
||||
expect(g1.mails.map(m => m.mail_id)).toEqual(g2.mails.map(m => m.mail_id));
|
||||
expect(g1.mails[0].mail_id).toBe('zzz');
|
||||
});
|
||||
|
||||
it('统计未读数', () => {
|
||||
const [g] = groupMailsBySession([
|
||||
mail({ session_id: 's', status: 'unread' }),
|
||||
mail({ session_id: 's', status: 'unread' }),
|
||||
mail({ session_id: 's', status: 'read' })
|
||||
]);
|
||||
expect(g.unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('别名取自最新一封;无别名给空串而不是 undefined', () => {
|
||||
const [withAlias] = groupMailsBySession([
|
||||
mail({ session_id: 's', session_alias: 'deploy-review' })
|
||||
]);
|
||||
expect(withAlias.alias).toBe('deploy-review');
|
||||
const [without] = groupMailsBySession([mail({ session_id: 's' })]);
|
||||
expect(without.alias).toBe('');
|
||||
});
|
||||
|
||||
it('session_id 缺失的脏数据各自成组而不是挤成一堆', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ mail_id: 'x', session_id: '' }),
|
||||
mail({ mail_id: 'y', session_id: '' })
|
||||
]);
|
||||
// 全归到 '' 这一组会把两封无关的信显示成一个会话
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('created_at 解析失败不会让顺序变得不确定', () => {
|
||||
const bad = mail({ mail_id: 'bad', session_id: 's', created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'good', session_id: 's', created_at: '2026-09-03T10:00:00Z' });
|
||||
const [g] = groupMailsBySession([bad, good]);
|
||||
// NaN 参与比较恒为 false,会让排序结果取决于原数组顺序
|
||||
expect(g.mails.map(m => m.mail_id)).toEqual(['good', 'bad']);
|
||||
});
|
||||
|
||||
it('不修改入参数组', () => {
|
||||
const input = [
|
||||
mail({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
|
||||
mail({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
const snapshot = input.map(m => m.mail_id);
|
||||
groupMailsBySession(input);
|
||||
expect(input.map(m => m.mail_id)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('空数组返回空数组', () => {
|
||||
expect(groupMailsBySession([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFlatGroup', () => {
|
||||
it('单封邮件的组平铺显示,不套折叠头', () => {
|
||||
const [g] = groupMailsBySession([mail({ session_id: 'solo' })]);
|
||||
expect(isFlatGroup(g)).toBe(true);
|
||||
});
|
||||
|
||||
it('两封以上才折叠', () => {
|
||||
const [g] = groupMailsBySession([mail({ session_id: 's' }), mail({ session_id: 's' })]);
|
||||
expect(isFlatGroup(g)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitByPermission', () => {
|
||||
it('权限请求与普通邮件分开', () => {
|
||||
const { normal, permissions } = splitByPermission([
|
||||
mail({ subject: '来信' }),
|
||||
perm({ subject: '要跑 bash' }),
|
||||
mail({ subject: '又一封' })
|
||||
]);
|
||||
expect(normal.map(m => m.subject)).toEqual(['来信', '又一封']);
|
||||
expect(permissions.map(m => m.subject)).toEqual(['要跑 bash']);
|
||||
});
|
||||
|
||||
it('保持原有顺序(调用方自己排序)', () => {
|
||||
const a = mail({ mail_id: 'a' });
|
||||
const b = mail({ mail_id: 'b' });
|
||||
expect(splitByPermission([a, b]).normal.map(m => m.mail_id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('空输入给两个空数组而不是 undefined', () => {
|
||||
const r = splitByPermission([]);
|
||||
expect(r.normal).toEqual([]);
|
||||
expect(r.permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPendingPermission', () => {
|
||||
it('普通邮件不是权限请求', () => {
|
||||
expect(isPendingPermission(mail())).toBe(false);
|
||||
});
|
||||
|
||||
it('permission_result 为空串或 null 都算未决策', () => {
|
||||
// 后端用 COALESCE(permission_result,'') 归一,两种都会出现
|
||||
expect(isPendingPermission(perm({ permission_result: null }))).toBe(true);
|
||||
expect(isPendingPermission(perm({ permission_result: '' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('有决策结果就不再是待决策', () => {
|
||||
expect(isPendingPermission(perm({ permission_result: '拒绝' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countPendingPermissions', () => {
|
||||
it('只数待决策的那些', () => {
|
||||
const n = countPendingPermissions([
|
||||
perm({ permission_result: null }),
|
||||
perm({ permission_result: '' }),
|
||||
perm({ permission_result: '同意' }),
|
||||
mail()
|
||||
]);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
|
||||
it('没有待决策时给 0', () => {
|
||||
expect(countPendingPermissions([perm({ permission_result: '同意' })])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupPermissions', () => {
|
||||
it('只收权限请求,普通邮件不进来', () => {
|
||||
const groups = groupPermissions([
|
||||
mail({ session_id: 'a' }),
|
||||
perm({ session_id: 'a' }),
|
||||
perm({ session_id: 'a' })
|
||||
]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].pending.length + groups[0].settled.length).toBe(2);
|
||||
});
|
||||
|
||||
it('待决策与已决策分开装', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({ session_id: 's', permission_result: null, subject: '在等' }),
|
||||
perm({ session_id: 's', permission_result: '同意', subject: '批过' }),
|
||||
perm({ session_id: 's', permission_result: '拒绝', subject: '拒过' })
|
||||
]);
|
||||
expect(g.pending.map(m => m.subject)).toEqual(['在等']);
|
||||
expect(g.settled.map(m => m.subject).sort()).toEqual(['批过', '拒过']);
|
||||
});
|
||||
|
||||
it('有待决策的会话排在前面,哪怕它更旧', () => {
|
||||
const groups = groupPermissions([
|
||||
// 刚刚批完的
|
||||
perm({ session_id: 'done', permission_result: '同意', created_at: '2026-09-03T12:00:00Z' }),
|
||||
// 三天前就卡着的 —— 那条 Agent 会话正在那儿等
|
||||
perm({ session_id: 'stuck', permission_result: null, created_at: '2026-08-31T09:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['stuck', 'done']);
|
||||
});
|
||||
|
||||
it('都有待决策时,卡住更多请求的会话更靠前', () => {
|
||||
const groups = groupPermissions([
|
||||
perm({ session_id: 'one', permission_result: null, created_at: '2026-09-03T12:00:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:00:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:01:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:02:00Z' })
|
||||
]);
|
||||
expect(groups[0].sessionId).toBe('many');
|
||||
});
|
||||
|
||||
it('都无待决策时按最新时间倒序', () => {
|
||||
const groups = groupPermissions([
|
||||
perm({ session_id: 'old', permission_result: '同意', created_at: '2026-09-01T10:00:00Z' }),
|
||||
perm({ session_id: 'new', permission_result: '同意', created_at: '2026-09-03T10:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['new', 'old']);
|
||||
});
|
||||
|
||||
it('组头带上发起请求的 Agent 与会话工作目录', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({
|
||||
session_id: 's',
|
||||
from_name: 'dsh',
|
||||
// from_workspace 对 Agent 存的是 **Agent 名**而不是路径(历史遗留)。
|
||||
// 拿它当路径用会在授权页拼出 `dsh@dsh`,而权限请求的
|
||||
// from_workspace 实测是**空串** —— 于是那一行永远不渲染,
|
||||
// 人根本不知道是哪个目录里的哪条线索在请求权限。
|
||||
from_workspace: 'dsh',
|
||||
session_workspace: '/home/program/llmsproxy'
|
||||
})
|
||||
]);
|
||||
// 同名 Agent 在不同目录是不同的活,光有名字判断不了
|
||||
expect(g.agentName).toBe('dsh');
|
||||
// path 必须取 session_workspace(会话的 workspace,权威来源)
|
||||
expect(g.path).toBe('/home/program/llmsproxy');
|
||||
});
|
||||
|
||||
it('组内时间倒序', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T08:00:00Z', subject: '早' }),
|
||||
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T10:00:00Z', subject: '晚' })
|
||||
]);
|
||||
expect(g.pending.map(m => m.subject)).toEqual(['晚', '早']);
|
||||
});
|
||||
|
||||
it('没有权限请求时返回空数组', () => {
|
||||
expect(groupPermissions([mail(), mail()])).toEqual([]);
|
||||
});
|
||||
|
||||
it('不修改入参数组', () => {
|
||||
const input = [
|
||||
perm({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
|
||||
perm({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
const snapshot = input.map(m => m.mail_id);
|
||||
groupPermissions(input);
|
||||
expect(input.map(m => m.mail_id)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('生产实测形状:17 封权限邮件的会话', () => {
|
||||
it('权限请求移出收件箱后,剩下的信按会话分组且不再被淹', () => {
|
||||
const flood: Mail[] = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
flood.push(
|
||||
perm({
|
||||
session_id: 'f3ce62b0',
|
||||
permission_result: i === 16 ? null : '同意',
|
||||
created_at: `2026-09-03T09:${String(i).padStart(2, '0')}:00Z`
|
||||
})
|
||||
);
|
||||
}
|
||||
const realMail = [
|
||||
mail({ session_id: 'fa420c33', created_at: '2026-09-03T10:00:00Z', subject: '进展汇报' }),
|
||||
mail({ session_id: 'c2db6b62', created_at: '2026-09-03T11:00:00Z', subject: '需要确认' })
|
||||
];
|
||||
const inbox = [...flood, ...realMail];
|
||||
|
||||
// 收件箱侧:权限请求整体不进来,只剩两封真信
|
||||
const { normal, permissions } = splitByPermission(inbox);
|
||||
expect(normal).toHaveLength(2);
|
||||
expect(permissions).toHaveLength(17);
|
||||
const inboxGroups = groupMailsBySession(normal);
|
||||
expect(inboxGroups.map(g => g.subject)).toEqual(['需要确认', '进展汇报']);
|
||||
|
||||
// 授权侧:17 封折成 1 组,只有 1 个在等人
|
||||
const permGroups = groupPermissions(permissions);
|
||||
expect(permGroups).toHaveLength(1);
|
||||
expect(permGroups[0].pending).toHaveLength(1);
|
||||
expect(permGroups[0].settled).toHaveLength(16);
|
||||
expect(countPendingPermissions(permissions)).toBe(1);
|
||||
});
|
||||
});
|
||||
421
client/electron/test/components/replyTarget.test.tsx
Normal file
421
client/electron/test/components/replyTarget.test.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatAddress,
|
||||
mailCounterpart,
|
||||
sessionCounterpart,
|
||||
sessionReplyTarget,
|
||||
mailReplyTarget,
|
||||
replyAllCC,
|
||||
participantAddress
|
||||
} from '../../src/lib/replyTarget';
|
||||
import type { Mail, Session } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 「这封回复该发给谁」。
|
||||
*
|
||||
* 锁的是一次生产事故:判据写死 `from_name === 'human'`(单用户时代的遗留),
|
||||
* 多用户下登录名是 jianf,判据恒为假 → 对端取成 from_name(自己)→ 信发给自己。
|
||||
* 数据库里的链条:
|
||||
* pi → jianf 权限请求
|
||||
* jianf → pi Re: 权限请求 (对的,锚点是 pi 发来的)
|
||||
* pi → jianf 权限请求
|
||||
* jianf → jianf Re: Re: 权限请求 (错的,锚点是自己发的)
|
||||
*/
|
||||
|
||||
let seq = 0;
|
||||
function mail(over: Partial<Mail> = {}): Mail {
|
||||
seq += 1;
|
||||
return {
|
||||
mail_id: `m${String(seq).padStart(3, '0')}`,
|
||||
session_id: 's1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home/program/llmsproxy',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: '主题',
|
||||
body: '正文',
|
||||
mail_type: 'normal',
|
||||
permission_options: null,
|
||||
permission_result: null,
|
||||
status: 'read',
|
||||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||||
// 默认值:会话工作目录、发件方是 Agent、收件方是人 ——
|
||||
// 对应「pi 在 /home/program/llmsproxy 干活 → jianf」这条最常见的信
|
||||
session_workspace: '/home/program/llmsproxy',
|
||||
from_human: false,
|
||||
to_human: true,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
const session = (alias: string | null): Session => ({
|
||||
session_id: 's1',
|
||||
session_alias: alias,
|
||||
from_agent: 'jianf',
|
||||
subject: '关于llmsproxy工程的联合审查',
|
||||
status: 'active',
|
||||
created_at: '2026-09-03T09:00:00Z',
|
||||
updated_at: '2026-09-03T10:00:00Z'
|
||||
});
|
||||
|
||||
describe('formatAddress', () => {
|
||||
it('三段齐全', () => {
|
||||
expect(formatAddress('pi', '/home', 'deploy')).toBe('pi@/home.deploy');
|
||||
});
|
||||
it('无会话段时省略', () => {
|
||||
expect(formatAddress('pi', '/home')).toBe('pi@/home');
|
||||
expect(formatAddress('pi', '/home', null)).toBe('pi@/home');
|
||||
});
|
||||
it('path 与 session 都为空时返回裸名字(裸名字 = 默认会话)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||||
});
|
||||
it('path 为空但有 session 时必须保留 @', () => {
|
||||
// `jianf@.任务` 能被 ParseAddress 还原(按最后一个 . 切分);
|
||||
// 漏掉 @ 的 `jianf.任务` 会被整串当成名字 —— 那是个不存在的 Agent
|
||||
expect(formatAddress('jianf', '', '任务')).toBe('jianf@.任务');
|
||||
});
|
||||
it('名字为空给空串而不是拼出 @', () => {
|
||||
expect(formatAddress('', '/home')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailCounterpart', () => {
|
||||
it('别人发来的 → 回给发件人', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||||
});
|
||||
|
||||
it('我发出的 → 回给收件人(不是回给自己)', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_human: true,
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_human: false,
|
||||
to_workspace: '/home/program/llmsproxy'
|
||||
});
|
||||
// 这一条就是生产 bug:原代码在这里返回 jianf
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||||
});
|
||||
|
||||
it('登录名未知时退化为回给发件人,不会回给自己', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||||
expect(mailCounterpart(m, '').name).toBe('pi');
|
||||
});
|
||||
|
||||
it('不把 human 当特殊值', () => {
|
||||
// 老判据写死 human;现在它只是个普通名字
|
||||
const m = mail({ from_name: 'human', to_name: 'pi' });
|
||||
expect(mailCounterpart(m, 'jianf').name).toBe('human');
|
||||
expect(mailCounterpart(m, 'human').name).toBe('pi');
|
||||
});
|
||||
|
||||
it('path 从 session_workspace 取,不用 from_workspace(后者存的是 Agent 名)', () => {
|
||||
// from_workspace 存的是 Agent 名而不是路径(历史遗留),
|
||||
// 从 session_workspace 取路径才不会拼出 dsh@dsh
|
||||
const m = mail({
|
||||
from_name: 'dsh',
|
||||
from_workspace: 'dsh', // Agent 名,不是路径
|
||||
to_name: 'jianf',
|
||||
to_human: true,
|
||||
session_workspace: '/home/program/webui4frpc'
|
||||
});
|
||||
const peer = mailCounterpart(m, 'jianf');
|
||||
expect(peer.path).toBe('/home/program/webui4frpc');
|
||||
expect(peer.path).not.toBe('dsh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionCounterpart', () => {
|
||||
it('按会话定对端,不受最后一封是谁发的影响', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }),
|
||||
// 最后一封是我自己发的 —— 原代码在这里会把自己当对端
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||||
});
|
||||
|
||||
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
|
||||
];
|
||||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||||
});
|
||||
|
||||
it('补上首次出现时缺失的 path', () => {
|
||||
const mails = [
|
||||
// 首封的 session_workspace 是空的
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
|
||||
// 后一封才带上目录
|
||||
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', session_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T09:30:00Z' })
|
||||
];
|
||||
// 同名 Agent 在不同目录是不同的活,path 不能丢
|
||||
expect(sessionCounterpart(mails, 'jianf')).toEqual({
|
||||
name: 'pi',
|
||||
path: '/home/program/llmsproxy',
|
||||
isHuman: false
|
||||
});
|
||||
});
|
||||
|
||||
it('同刻邮件用 mail_id 定序,结果稳定', () => {
|
||||
const ts = '2026-09-03T09:00:00Z';
|
||||
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: ts });
|
||||
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: ts });
|
||||
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
|
||||
});
|
||||
|
||||
it('全是自己的会话返回 null 而不是自己', () => {
|
||||
const mails = [mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true })];
|
||||
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
|
||||
});
|
||||
|
||||
it('空会话返回 null', () => {
|
||||
expect(sessionCounterpart([], 'jianf')).toBeNull();
|
||||
});
|
||||
|
||||
it('created_at 解析失败不影响确定性', () => {
|
||||
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'good', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: '2026-09-03T09:00:00Z' });
|
||||
expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe(
|
||||
sessionCounterpart([good, bad], 'jianf')?.name
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionReplyTarget', () => {
|
||||
it('带上会话别名:不带会落到该 Agent 的默认会话', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy' })
|
||||
];
|
||||
expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe(
|
||||
'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查'
|
||||
);
|
||||
});
|
||||
|
||||
it('会话未命名时省略会话段', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '/home' })
|
||||
];
|
||||
expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home');
|
||||
});
|
||||
|
||||
it('生产链条重现:回复自己发的那封仍指向 pi', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:56:34Z' })
|
||||
];
|
||||
const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf');
|
||||
expect(target.startsWith('pi@')).toBe(true);
|
||||
expect(target.startsWith('jianf@')).toBe(false);
|
||||
});
|
||||
|
||||
it('找不到对端时给空串(调用方据此禁用发送)', () => {
|
||||
expect(sessionReplyTarget([], session('x'), 'jianf')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailReplyTarget', () => {
|
||||
it('单封视图带上该封的会话别名', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf', session_alias: 'deploy-review' });
|
||||
expect(mailReplyTarget(m, 'jianf')).toBe('pi@/home/program/llmsproxy.deploy-review');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyAllCC', () => {
|
||||
it('去掉自己与主收件人', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||||
});
|
||||
// 主收件人是 pi(回复对象),自己是 jianf —— 都不该出现在抄送里
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
});
|
||||
|
||||
it('自己是 jianf 时不会把自己抄送进去(老判据只挡 human)', () => {
|
||||
const m = mail({ from_name: 'pi', from_workspace: '/home', to_name: 'jianf', to_workspace: '' });
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual([]);
|
||||
});
|
||||
|
||||
it('cc_list 取 raw 保留会话段', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'audit', raw: 'dsh@/opt.audit' }]
|
||||
});
|
||||
// 重新拼 name@path 会丢掉 .audit
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.audit']);
|
||||
});
|
||||
|
||||
it('同一个人既在 to 又在 cc 时只出现一次', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'dsh',
|
||||
to_human: false,
|
||||
to_workspace: '/opt',
|
||||
session_workspace: '/opt',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ─── 地址展示 ───
|
||||
*
|
||||
* 锁的是一次生产事故:单封邮件视图的「发件」行显示成
|
||||
* jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
|
||||
* 而人指定的收件方是 pi@/home/program/agentmail 的那条会话。三处错叠在一起:
|
||||
*
|
||||
* 1. 会话别名被拼给了**发件人** —— 别名是会话的属性、两端共有,不属于任何一方
|
||||
* 2. from_workspace 为空(人类没有工作目录)时拼出 `jianf.<别名>`,
|
||||
* 按三维寻址「最后一个 . 切分」规则,那是 path 位为空的**非法地址**
|
||||
* 3. 抄送显示 `pi@/home/program/agentmail.new` —— `.new` 建完会话就失效了,
|
||||
* 留着会让人以为再发一次还能投进同一条会话
|
||||
*/
|
||||
describe('formatAddress 边界', () => {
|
||||
it('path 为空时保留 @,不能拼成 name.session', () => {
|
||||
// bug 现场:jianf 没有工作目录,界面拼出了 `jianf.某会话别名`。
|
||||
// 那会被 ParseAddress 整串当成名字(实测 name="jianf.某会话别名")——
|
||||
// 一个不存在的 Agent。正确形式是 `jianf@.某会话别名`。
|
||||
expect(formatAddress('jianf', '', '某会话别名')).toBe('jianf@.某会话别名');
|
||||
expect(formatAddress('jianf', '', '某会话别名')).not.toBe('jianf.某会话别名');
|
||||
});
|
||||
|
||||
it('无 session 时 path 为空返回裸名字(不留孤零零的 @)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||||
expect(formatAddress('jianf', ' ')).toBe('jianf');
|
||||
});
|
||||
|
||||
it('name 为空返回空串而不是残缺地址', () => {
|
||||
expect(formatAddress('', '/home')).toBe('');
|
||||
expect(formatAddress(' ', '/home', 'x')).toBe('');
|
||||
});
|
||||
|
||||
it('三段齐全时正常拼接', () => {
|
||||
expect(formatAddress('pi', '/home/program/agentmail', 'my-task'))
|
||||
.toBe('pi@/home/program/agentmail.my-task');
|
||||
});
|
||||
|
||||
it('去首尾空白', () => {
|
||||
expect(formatAddress(' pi ', ' /home ', ' t ')).toBe('pi@/home.t');
|
||||
});
|
||||
|
||||
it('path 含 . 与 / 时仍按最后一个 . 切分(与后端同构)', () => {
|
||||
// 拼出来的地址必须能被后端 ParseAddress 还原
|
||||
const addr = formatAddress('pi', '/home/a.b/c', 'sess');
|
||||
expect(addr).toBe('pi@/home/a.b/c.sess');
|
||||
expect(addr.slice(addr.lastIndexOf('.') + 1)).toBe('sess');
|
||||
});
|
||||
});
|
||||
|
||||
describe('participantAddress', () => {
|
||||
/**
|
||||
* 人与 Agent 的地址维度不同:
|
||||
* Agent 要三段(name@path.session)才唯一确定「哪个 Agent、哪个目录、哪条线索」
|
||||
* 人只要名字(没有工作目录,也不需要指定会话)
|
||||
*/
|
||||
it('Agent 带完整三段 —— 少任何一段都不是可投递地址', () => {
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail', '我的任务'))
|
||||
.toBe('pi@/home/program/agentmail.我的任务');
|
||||
});
|
||||
|
||||
it('Agent 无会话别名时退到 name@path(默认会话)', () => {
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail', null))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail'))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
});
|
||||
|
||||
it('人只显示名字,即便传了会话别名也不拼', () => {
|
||||
// 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上
|
||||
expect(participantAddress('jianf', true, '', '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true, null, '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true)).toBe('jianf');
|
||||
});
|
||||
|
||||
it('人的地址里不含 @ 也不含 .', () => {
|
||||
const addr = participantAddress('jianf', true, '', '邮件驱动·多智能体协作平台-完整设计文档');
|
||||
expect(addr).toBe('jianf');
|
||||
expect(addr).not.toContain('@');
|
||||
expect(addr).not.toContain('.');
|
||||
});
|
||||
|
||||
it('人是 Agent 时 workspace 为空也不会拼出裸 @', () => {
|
||||
// 边界:Agent 名在库里但 session_workspace 为空(数据不完整),
|
||||
// 仍然要留 @ 而不是裸名字 —— 否则按最后一个 . 切分会错
|
||||
expect(participantAddress('pi', false, '', 'sess'))
|
||||
.toBe('pi@.sess');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyAllCC 保留 cc_list 的原始意图', () => {
|
||||
// `.new` 是**原始意图**,不该被替换成主收件人的别名。
|
||||
//
|
||||
// 每个抄送方的 `.new` 是独立的:`pi@/x.new` 给 pi 开一条会话、
|
||||
// `dsh@/x.new` 给 dsh 开另一条,各有自己的别名。把它们统一换成主收件人
|
||||
// 那条会话的别名,等于把三条不同的线索说成同一条 —— 而数据库里 cc_list
|
||||
// 存的就是原文,显示原文没有任何问题。
|
||||
it('抄送里的 .new 原样保留', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home/program/agentmail',
|
||||
cc_list: [{
|
||||
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
|
||||
}]
|
||||
});
|
||||
// 自己是 jianf、主收件人是 pi,剩下 dsh —— `.new` 是原文,不动
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.new']);
|
||||
});
|
||||
|
||||
it('已有具体别名的抄送也原样保留', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'other-task', raw: 'dsh@/opt.other-task' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.other-task']);
|
||||
});
|
||||
|
||||
it('抄送无 raw 时按结构化字段重拼(含会话位)', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'x', raw: '' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.x']);
|
||||
});
|
||||
|
||||
it('人类发件人(无 workspace)在抄送里是裸名字,不带会话位', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_human: true,
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_human: false,
|
||||
to_workspace: '/home',
|
||||
session_alias: '某个很长的会话别名'
|
||||
});
|
||||
// replyAllCC 里的 from/to 走 formatAddress 且**不传 session** ——
|
||||
// 抄送清单是「还要发给谁」,会话由主收件人的地址决定,
|
||||
// 每个抄送方都带一遍会话位是冗余的(且回复时后端按 reply_to 定位会话)
|
||||
expect(replyAllCC(m, 'dsh', 'pi')).toEqual(['jianf']);
|
||||
});
|
||||
});
|
||||
45
client/electron/test/components/setup.ts
Normal file
45
client/electron/test/components/setup.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { afterEach, expect } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||
|
||||
/**
|
||||
* 组件测试的全局准备。
|
||||
*
|
||||
* 三件事,每件都对应一类会串味的状态:
|
||||
* 1. 每个用例后卸载组件树(不卸载的话下一个用例的 getByText 会命中上一个的 DOM)
|
||||
* 2. 重置 zustand store(它是模块级单例,跨用例共享)
|
||||
* 3. 提供 matchMedia —— jsdom 没有实现它,而 useIsNarrow 直接调
|
||||
*/
|
||||
expect.extend(matchers);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* jsdom 不实现 matchMedia。useIsNarrow 会直接调它并挂 change 监听,
|
||||
* 缺了就抛 `matchMedia is not a function`,整个测试文件挂掉。
|
||||
*
|
||||
* 默认返回 false(宽屏)。要测窄屏的用例用 setViewport(true) 覆盖。
|
||||
*/
|
||||
function installMatchMedia(narrow: boolean) {
|
||||
const listeners = new Set<(e: MediaQueryListEvent) => void>();
|
||||
(window as any).matchMedia = (query: string) => ({
|
||||
matches: narrow,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
|
||||
// 老式 API,某些库仍在用
|
||||
addListener: (cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
|
||||
removeListener: (cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
|
||||
dispatchEvent: () => false
|
||||
});
|
||||
}
|
||||
|
||||
installMatchMedia(false);
|
||||
|
||||
/** 让当前测试文件里的组件按窄屏/宽屏渲染。在 render 之前调。 */
|
||||
export function setViewport(narrow: boolean) {
|
||||
installMatchMedia(narrow);
|
||||
}
|
||||
Reference in New Issue
Block a user