chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View 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=0dsh下移一格到 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@');
});
});

View 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');
});
});

View 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 落定并等状态更新走完,
// 否则组件在测试结束后才 setStateReact 会报 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');
});
});

View 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 小时');
});
});

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

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

View 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']);
});
});

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