feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* task_manager 工具 + 渲染层可测纯域(v0.7.0 覆盖补齐)
|
||||
* task_manager 工具(v0.7.0 覆盖补齐 → v0.7.5 大幅扩充)
|
||||
*
|
||||
* - TaskManagerTool:SQLite 持久化 CRUD / 会话隔离 / 父子级联 / onTaskChanged 回调
|
||||
* - TaskManagerTool:SQLite 持久化 CRUD / 会话隔离 / 父子级联递归删除 /
|
||||
* order_idx 递增 / 枚举校验 / onTaskChanged 回调
|
||||
* (better-sqlite3 ABI 门控:系统 Node 自动跳过,test:electron 全执行)
|
||||
* - 渲染层纯函数(node 环境即可):formatters、export-markdown、tool-result-display
|
||||
* - i18n:i18next 桥的缺失 key 兜底与注册语义
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
@@ -30,13 +29,17 @@ try {
|
||||
dbAvailable = false;
|
||||
}
|
||||
|
||||
// 工具返回的是 task-manager.ts 的 Task 形状(camelCase,见 mapRow()),
|
||||
// 非数据库行 snake_case。此接口仅供测试内类型标注,需与真实返回对齐。
|
||||
interface TaskRowLike {
|
||||
id: string;
|
||||
session_id?: string;
|
||||
sessionId?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
parent_id?: string | null;
|
||||
parentId?: string | null;
|
||||
order?: number;
|
||||
completedAt?: number | null;
|
||||
}
|
||||
|
||||
describe.skipIf(!dbAvailable)('task_manager — CRUD / 会话隔离 / 回调联动', () => {
|
||||
@@ -81,10 +84,9 @@ describe.skipIf(!dbAvailable)('task_manager — CRUD / 会话隔离 / 回调联
|
||||
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_task', ${Date.now()}, ${Date.now()});
|
||||
INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_other', ${Date.now()}, ${Date.now()});
|
||||
`);
|
||||
|
||||
// v0.7.2 清理: 原此处有一个结果未接收的重复动态 import(死代码),仅保留
|
||||
// 实际消费的解构导入
|
||||
const { TaskManagerTool } = await import('../task-manager');
|
||||
notifyCalls = [];
|
||||
const manager = new TaskManagerTool(
|
||||
@@ -139,23 +141,24 @@ describe.skipIf(!dbAvailable)('task_manager — CRUD / 会话隔离 / 回调联
|
||||
const delRes = await tool.execute({ operation: 'delete', task_id: taskId }, ctxFor('s_task'));
|
||||
expect(delRes).toBeDefined();
|
||||
expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(notifyCalls.every((c) => c.sessionId === 's_task' || c.sessionId === undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('会话隔离:列表按 session 过滤,跨会话不可见', async () => {
|
||||
it('会话隔离:列表按 session 过滤,跨会话不可见(真实断言,替代恒真)', async () => {
|
||||
await tool.execute({ operation: 'create', title: '隔离样例' }, ctxFor('s_task'));
|
||||
const otherList = (await tool.execute({ operation: 'list' }, ctxFor('s_other'))) as {
|
||||
tasks?: Array<TaskRowLike>;
|
||||
rows?: Array<TaskRowLike>;
|
||||
};
|
||||
const rows = otherList.tasks ?? otherList.rows ?? [];
|
||||
expect(rows.every((r) => r.title !== '隔离样例' || r.session_id === 's_other' || true)).toBe(
|
||||
true,
|
||||
);
|
||||
// 更稳的一致性断言:若实现带 session 过滤,则 s_other 列表不含该标题;
|
||||
// 若实现为跨会话聚合,则至少不得因未知会话而崩溃
|
||||
// 修正:原断言 `r.title !== '隔离样例' || ... || true` 恒真。真实契约是
|
||||
// listTasks 按 session_id 过滤 —— s_other 列表绝不包含 s_task 创建的任务。
|
||||
expect(rows.some((r) => r.title === '隔离样例')).toBe(false);
|
||||
|
||||
// 双向验证:s_task 自己能看到该任务
|
||||
const ownList = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as {
|
||||
tasks?: Array<TaskRowLike>;
|
||||
};
|
||||
expect((ownList.tasks ?? []).some((r) => r.title === '隔离样例')).toBe(true);
|
||||
});
|
||||
|
||||
it('非法 operation 枚举失败;缺 title 的 create 失败', async () => {
|
||||
@@ -166,4 +169,325 @@ describe.skipIf(!dbAvailable)('task_manager — CRUD / 会话隔离 / 回调联
|
||||
expect(badSignal).toBe(true);
|
||||
expect(JSON.stringify(badCreate)).toContain('"success":false');
|
||||
});
|
||||
|
||||
it('create 校验 priority 枚举:非法值拒绝', async () => {
|
||||
const bad = (await tool.execute(
|
||||
{ operation: 'create', title: 'x', priority: 'urgent' },
|
||||
ctxFor('s_task'),
|
||||
)) as { success: boolean; error?: string };
|
||||
expect(bad.success).toBe(false);
|
||||
expect(String(bad.error)).toContain('Invalid priority');
|
||||
|
||||
const ok = (await tool.execute(
|
||||
{ operation: 'create', title: 'pri-ok', priority: 'critical' },
|
||||
ctxFor('s_task'),
|
||||
)) as { success: boolean };
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it('order_idx 同 session 同 parent 下递增', async () => {
|
||||
await tool.execute({ operation: 'create', title: 'o1' }, ctxFor('s_task'));
|
||||
await tool.execute({ operation: 'create', title: 'o2' }, ctxFor('s_task'));
|
||||
const list = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as {
|
||||
tasks: Array<TaskRowLike>;
|
||||
};
|
||||
const orders = list.tasks
|
||||
.filter((t) => ['o1', 'o2'].includes(String(t.title)))
|
||||
.map((t) => Number(t.order))
|
||||
.sort((a, b) => a - b);
|
||||
expect(orders).toEqual([orders[0], orders[0] + 1]); // 连续递增
|
||||
});
|
||||
|
||||
it('create 支持 parent_id 建立父子关系', async () => {
|
||||
const parent = (await tool.execute(
|
||||
{ operation: 'create', title: '父任务' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const parentId = parent.task!.id;
|
||||
const child = (await tool.execute(
|
||||
{ operation: 'create', title: '子任务', parent_id: parentId },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
expect(child.task!.parentId).toBe(parentId);
|
||||
expect(child.task!.order).toBe(0); // 子任务独立 order 序列
|
||||
});
|
||||
|
||||
it('get 返回任务与其子任务(仅限本会话)', async () => {
|
||||
const parent = (await tool.execute(
|
||||
{ operation: 'create', title: 'get-父' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const parentId = parent.task!.id;
|
||||
await tool.execute(
|
||||
{ operation: 'create', title: 'get-子1', parent_id: parentId },
|
||||
ctxFor('s_task'),
|
||||
);
|
||||
const r = (await tool.execute({ operation: 'get', task_id: parentId }, ctxFor('s_task'))) as {
|
||||
success: boolean;
|
||||
task?: TaskRowLike;
|
||||
subtasks?: Array<TaskRowLike>;
|
||||
};
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.task?.id).toBe(parentId);
|
||||
expect((r.subtasks ?? []).map((s) => String(s.title))).toContain('get-子1');
|
||||
});
|
||||
|
||||
it('get 跨会话访问不存在 → 失败(会话隔离)', async () => {
|
||||
const parent = (await tool.execute(
|
||||
{ operation: 'create', title: 'get-隔离' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const r = (await tool.execute(
|
||||
{ operation: 'get', task_id: parent.task!.id },
|
||||
ctxFor('s_other'),
|
||||
)) as { success: boolean };
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('complete 标记 completed_at 且状态正确', async () => {
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'complete-me' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const id = created.task!.id;
|
||||
const r = (await tool.execute({ operation: 'complete', task_id: id }, ctxFor('s_task'))) as {
|
||||
success: boolean;
|
||||
completed_at?: number;
|
||||
};
|
||||
expect(r.success).toBe(true);
|
||||
expect(typeof r.completed_at).toBe('number');
|
||||
|
||||
const got = (await tool.execute({ operation: 'get', task_id: id }, ctxFor('s_task'))) as {
|
||||
task?: TaskRowLike;
|
||||
};
|
||||
expect(got.task?.status).toBe('completed');
|
||||
expect(got.task?.completedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('complete 跨会话任务 → 失败(会话隔离)', async () => {
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'complete-隔离' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const r = (await tool.execute(
|
||||
{ operation: 'complete', task_id: created.task!.id },
|
||||
ctxFor('s_other'),
|
||||
)) as { success: boolean };
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('update 非法 status 经顶层参数拒绝', async () => {
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'update-enum' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const id = created.task!.id;
|
||||
const badStatus = (await tool.execute(
|
||||
{ operation: 'update', task_id: id, status: 'done' },
|
||||
ctxFor('s_task'),
|
||||
)) as { success: boolean };
|
||||
expect(badStatus.success).toBe(false);
|
||||
|
||||
const badPri = (await tool.execute(
|
||||
{ operation: 'update', task_id: id, priority: 'urgent' },
|
||||
ctxFor('s_task'),
|
||||
)) as { success: boolean };
|
||||
expect(badPri.success).toBe(false);
|
||||
});
|
||||
|
||||
it('update 可同时改多个字段(顶层字段语义,实况契约:更新字段非 updates 包)', async () => {
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'multi-update' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const id = created.task!.id;
|
||||
const r = (await tool.execute(
|
||||
{ operation: 'update', task_id: id, title: '改名', status: 'in_progress', priority: 'high' },
|
||||
ctxFor('s_task'),
|
||||
)) as { success: boolean; task?: TaskRowLike };
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.task?.title).toBe('改名');
|
||||
expect(r.task?.status).toBe('in_progress');
|
||||
expect(r.task?.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('update 通过 updates 包装字段 → 无可更新字段而失败(实况契约:参数在顶层)', async () => {
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'updates-wrapper' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const r = (await tool.execute(
|
||||
{ operation: 'update', task_id: created.task!.id, updates: { title: '被忽略' } },
|
||||
ctxFor('s_task'),
|
||||
)) as { success: boolean };
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('update 无可更新字段 → 失败', async () => {
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'no-op-update' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const r = (await tool.execute(
|
||||
{ operation: 'update', task_id: created.task!.id, updates: {} },
|
||||
ctxFor('s_task'),
|
||||
)) as { success: boolean };
|
||||
expect(r.success).toBe(false);
|
||||
expect(String((r as { error?: string }).error)).toContain('No fields to update');
|
||||
});
|
||||
|
||||
it('update 跨会话任务 → 失败(会话隔离)', async () => {
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'update-隔离' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const r = (await tool.execute(
|
||||
{ operation: 'update', task_id: created.task!.id, updates: { title: 'hack' } },
|
||||
ctxFor('s_other'),
|
||||
)) as { success: boolean };
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('缺 task_id 的 update/complete/delete/get 各自失败', async () => {
|
||||
for (const op of ['update', 'complete', 'delete', 'get']) {
|
||||
const r = (await tool.execute({ operation: op }, ctxFor('s_task'))) as { success: boolean };
|
||||
expect(r.success, `expected ${op} to reject missing task_id`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('delete 父任务递归删除全部子任务(级联)', async () => {
|
||||
const parent = (await tool.execute(
|
||||
{ operation: 'create', title: 'del-父' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const parentId = parent.task!.id;
|
||||
const child1 = (await tool.execute(
|
||||
{ operation: 'create', title: 'del-子1', parent_id: parentId },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const child2 = (await tool.execute(
|
||||
{ operation: 'create', title: 'del-子2', parent_id: parentId },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
|
||||
const r = (await tool.execute(
|
||||
{ operation: 'delete', task_id: parentId },
|
||||
ctxFor('s_task'),
|
||||
)) as {
|
||||
success: boolean;
|
||||
deleted?: number;
|
||||
};
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.deleted).toBe(3); // 父 + 2 子
|
||||
|
||||
for (const cid of [parentId, child1.task!.id, child2.task!.id]) {
|
||||
const got = (await tool.execute({ operation: 'get', task_id: cid }, ctxFor('s_task'))) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(got.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('delete 跨会话任务 → 失败(会话隔离)', async () => {
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'delete-隔离' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const r = (await tool.execute(
|
||||
{ operation: 'delete', task_id: created.task!.id },
|
||||
ctxFor('s_other'),
|
||||
)) as { success: boolean };
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('list 支持 status 过滤与 by_status 统计', async () => {
|
||||
await tool.execute({ operation: 'create', title: 'stat-a' }, ctxFor('s_task'));
|
||||
const pending = (await tool.execute(
|
||||
{ operation: 'list', status: 'pending' },
|
||||
ctxFor('s_task'),
|
||||
)) as { tasks: Array<TaskRowLike>; by_status: Record<string, number> };
|
||||
expect(pending.tasks.every((t) => t.status === 'pending')).toBe(true);
|
||||
expect(typeof pending.by_status.pending).toBe('number');
|
||||
expect(pending.by_status.pending).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('list 返回 count 与各状态计数键', async () => {
|
||||
const r = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as {
|
||||
count: number;
|
||||
tasks: Array<TaskRowLike>;
|
||||
by_status: Record<string, number>;
|
||||
};
|
||||
expect(r.count).toBe(r.tasks.length);
|
||||
for (const s of ['pending', 'in_progress', 'completed', 'blocked', 'cancelled']) {
|
||||
expect(s in r.by_status).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('list 按 order_idx 升序排列', async () => {
|
||||
const r = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as {
|
||||
tasks: Array<TaskRowLike>;
|
||||
};
|
||||
const orders = r.tasks.map((t) => Number(t.order));
|
||||
const sorted = [...orders].sort((a, b) => a - b);
|
||||
expect(orders).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('notify 回调仅对写操作触发(create/update/complete/delete),list/get 不触发', async () => {
|
||||
const before = notifyCalls.length;
|
||||
await tool.execute({ operation: 'list' }, ctxFor('s_task'));
|
||||
const created = (await tool.execute(
|
||||
{ operation: 'create', title: 'notify-probe' },
|
||||
ctxFor('s_task'),
|
||||
)) as { task?: TaskRowLike };
|
||||
const got = await tool.execute(
|
||||
{ operation: 'get', task_id: created.task!.id },
|
||||
ctxFor('s_task'),
|
||||
);
|
||||
void got;
|
||||
// 只有 create 触发;list/get 不触发
|
||||
expect(notifyCalls.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('notify 回调异常不影响工具主流程', async () => {
|
||||
// 单独构造一个回调抛错的 manager
|
||||
const { TaskManagerTool } = await import('../task-manager');
|
||||
const badManager = new TaskManagerTool(
|
||||
() => db,
|
||||
() => {
|
||||
throw new Error('callback boom');
|
||||
},
|
||||
);
|
||||
const badTool = badManager as unknown as typeof tool;
|
||||
const r = (await badTool.execute(
|
||||
{ operation: 'create', title: 'cb-ok' },
|
||||
ctxFor('s_task'),
|
||||
)) as { success: boolean };
|
||||
expect(r.success).toBe(true); // 回调失败不阻断创建
|
||||
});
|
||||
|
||||
it('create 返回完整 task 结构(camelCase sessionId/parentId/assignedTo/order)', async () => {
|
||||
const r = (await tool.execute({ operation: 'create', title: 'shape' }, ctxFor('s_task'))) as {
|
||||
success: boolean;
|
||||
task?: Record<string, unknown>;
|
||||
};
|
||||
expect(r.success).toBe(true);
|
||||
const t = r.task!;
|
||||
expect(t.sessionId).toBe('s_task');
|
||||
expect(t.parentId).toBeNull();
|
||||
expect(typeof t.order).toBe('number');
|
||||
expect(t.status).toBe('pending');
|
||||
expect(t.priority).toBe('medium'); // 默认值
|
||||
expect(t.completedAt).toBeNull();
|
||||
expect(typeof t.id).toBe('string');
|
||||
});
|
||||
|
||||
it('不同 session 的 order_idx 各自独立', async () => {
|
||||
await tool.execute({ operation: 'create', title: 'ord-a' }, ctxFor('s_other'));
|
||||
await tool.execute({ operation: 'create', title: 'ord-b' }, ctxFor('s_other'));
|
||||
const r = (await tool.execute({ operation: 'list' }, ctxFor('s_other'))) as {
|
||||
tasks: Array<TaskRowLike>;
|
||||
};
|
||||
const orders = r.tasks.map((t) => Number(t.order));
|
||||
expect(orders).toEqual([0, 1]); // s_other 独立从 0 开始
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user